Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/src/ViewControllers/Avatars/AvatarSettingsViewController.swift
1 views
//
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import CoreServices
import SignalServiceKit
import SignalUI
import UniformTypeIdentifiers

class AvatarSettingsViewController: OWSTableViewController2 {
    let context: AvatarHistoryManager.Context

    static let headerAvatarSize: CGFloat = UIDevice.current.isIPhone5OrShorter ? 120 : 160

    enum State: Equatable {
        case original(UIImage?)
        case new(AvatarModel?)

        var isNew: Bool {
            guard case .new = self else { return false }
            return true
        }
    }

    private var state: State {
        didSet {
            guard state != oldValue else { return }
            updateHeaderView()
            updateNavigation()
        }
    }

    private var selectedAvatarModel: AvatarModel? {
        guard case .new(let model) = state else { return nil }
        return model
    }

    private lazy var defaultAvatarImage: UIImage? = {
        let avatarBuilder = SSKEnvironment.shared.avatarBuilderRef
        let databaseStorage = SSKEnvironment.shared.databaseStorageRef

        return databaseStorage.read { tx in
            switch context {
            case .groupId(let groupId):
                return avatarBuilder.defaultAvatarImage(
                    forGroupId: groupId,
                    diameterPoints: UInt(Self.headerAvatarSize),
                    transaction: tx,
                )
            case .profile:
                return avatarBuilder.defaultAvatarImageForLocalUser(
                    diameterPoints: UInt(Self.headerAvatarSize),
                    transaction: tx,
                )
            }
        }
    }()

    private let avatarChangeCallback: (UIImage?) -> Void

    init(
        context: AvatarHistoryManager.Context,
        currentAvatarImage: UIImage?,
        avatarChangeCallback: @escaping (UIImage?) -> Void,
    ) {
        self.context = context
        self.state = .original(currentAvatarImage)
        self.avatarChangeCallback = avatarChangeCallback
        super.init()
        createTopHeader()

        // We only support portrait on non-iPad devices, but if we're
        // already in landscape we need to force the device to rotate.
        // TODO: There might be a better spot to do this, but generally
        // this should never be initialized unless about to be shown.
        if !UIDevice.current.isIPad { UIDevice.current.ows_setOrientation(.portrait) }
    }

    override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
        return UIDevice.current.isIPad ? .all : .portrait
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        navigationItem.leftBarButtonItem = .cancelButton(
            dismissingFrom: self,
            hasUnsavedChanges: { [weak self] in self?.state.isNew },
        )

        navigationItem.rightBarButtonItem = .setButton { [weak self] in
            self?.didTapDone()
        }

        view.backgroundColor = .Signal.groupedBackground
        updateTableContents()
        updateNavigation()
    }

    override func themeDidChange() {
        super.themeDidChange()

        updateHeaderViewLayout(forceUpdate: true)
        optionViews.removeAll()
        updateTableContents()
    }

    private func didTapDone() {
        defer { dismiss(animated: true) }

        guard case .new(let model) = state else {
            return owsFailDebug("Tried to tap done in unexpected state")
        }

        if let model {
            DependenciesBridge.shared.db.asyncWrite { [context] tx in
                AppEnvironment.shared.avatarHistoryManager.touchedModel(model, in: context, tx: tx)
            }
            guard
                let newAvatar = SSKEnvironment.shared.avatarBuilderRef.avatarImage(
                    model: model,
                    diameterPixels: OWSProfileManager.maxAvatarDiameterPixels,
                )
            else {
                owsFailDebug("Failed to generate new avatar.")
                return
            }
            avatarChangeCallback(newAvatar)
        } else {
            // Avatar was cleared.
            avatarChangeCallback(nil)
        }
    }

    private let headerImageView = AvatarImageView()
    private let topHeaderStack = UIStackView()
    private func createTopHeader() {
        topHeaderStack.isLayoutMarginsRelativeArrangement = true
        topHeaderStack.axis = .vertical
        topHeaderStack.alignment = .center
        topHeaderStack.spacing = 24

        headerImageView.autoSetDimensions(to: CGSize(square: Self.headerAvatarSize))
        topHeaderStack.addArrangedSubview(headerImageView)

        headerButtonStack.axis = .vertical
        headerButtonStack.alignment = .center
        headerButtonStack.spacing = 8
        topHeaderStack.addArrangedSubview(headerButtonStack)

        createClearButton()

        topHeader = topHeaderStack

        updateHeaderView()
    }

    private lazy var clearButton = UIView()
    private let xImageView = UIImageView()
    private func createClearButton() {
        clearButton.autoSetDimensions(to: CGSize.square(32))
        clearButton.layer.cornerRadius = 16

        clearButton.layer.shadowColor = UIColor.black.cgColor
        clearButton.layer.shadowOpacity = 0.2
        clearButton.layer.shadowRadius = 4
        clearButton.layer.shadowOffset = CGSize(width: 0, height: 2)

        let secondaryShadowView = UIView()
        secondaryShadowView.layer.shadowColor = UIColor.black.cgColor
        secondaryShadowView.layer.shadowOpacity = 0.12
        secondaryShadowView.layer.shadowRadius = 16
        secondaryShadowView.layer.shadowOffset = CGSize(width: 0, height: 4)

        clearButton.addSubview(secondaryShadowView)
        secondaryShadowView.autoPinEdgesToSuperviewEdges()

        xImageView.image = UIImage(imageLiteralResourceName: "x-20")
        xImageView.autoSetDimensions(to: CGSize.square(20))
        xImageView.contentMode = .scaleAspectFit

        clearButton.addSubview(xImageView)
        xImageView.autoCenterInSuperview()

        topHeaderStack.addSubview(clearButton)
        clearButton.autoPinEdge(.trailing, to: .trailing, of: headerImageView, withOffset: -8)
        clearButton.autoPinEdge(.top, to: .top, of: headerImageView, withOffset: 8)

        clearButton.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTapClear)))
    }

    @objc
    private func didTapClear() {
        state = .new(nil)
        updateTableContents()
    }

    private func updateTableContents() {
        let contents = OWSTableContents()
        defer { self.contents = contents }

        let section = OWSTableSection()
        section.headerTitle = OWSLocalizedString(
            "AVATAR_SETTINGS_VIEW_SELECT_AN_AVATAR",
            comment: "Title for the previously used and preset avatar section.",
        )
        section.add(.init { [weak self] in
            let cell = OWSTableItem.newCell()
            guard let self else { return cell }
            cell.selectionStyle = .none
            self.configureAvatarsCell(cell)
            return cell
        } actionBlock: {})
        contents.add(section)
    }

    private func updateNavigation() {
        navigationItem.rightBarButtonItem?.isEnabled = state.isNew
    }

    override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
        super.viewWillTransition(to: size, with: coordinator)

        coordinator.animate { [weak self] _ in
            self?.updateHeaderViewLayout()
        } completion: { [weak self] _ in
            self?.updateHeaderViewLayout()
        }
    }

    // MARK: - Avatar Options

    private var optionViews = [OptionView]()
    private func reusableOptionView(for index: Int) -> OptionView {
        guard let optionView = optionViews[safe: index] else {
            while optionViews.count <= index {
                let optionView = OptionView(delegate: self)
                optionViews.append(optionView)
            }
            return reusableOptionView(for: index)
        }
        return optionView
    }

    private func configureAvatarsCell(_ cell: UITableViewCell) {
        let rowWidth = max(0, view.width - (view.safeAreaInsets.totalWidth + cellOuterInsets.totalWidth + Self.cellHInnerMargin * 2))
        let avatarSpacing: CGFloat = 16
        let minAvatarSize: CGFloat = min(66, (rowWidth - (avatarSpacing * 3)) / 4)
        let avatarsPerRow = max(1, Int(floor(rowWidth + avatarSpacing) / (minAvatarSize + avatarSpacing)))
        let avatarSize = max(minAvatarSize, (rowWidth - (avatarSpacing * CGFloat(avatarsPerRow - 1))) / CGFloat(avatarsPerRow))

        let vStackView = UIStackView()
        vStackView.axis = .vertical
        vStackView.spacing = avatarSpacing
        vStackView.alignment = .leading
        cell.contentView.addSubview(vStackView)
        vStackView.autoPinEdgesToSuperviewMargins()

        let avatars: [(model: AvatarModel, image: UIImage)] = DependenciesBridge.shared.db.read { tx in
            var allModels = [AvatarModel]()

            let persistedModels = AppEnvironment.shared.avatarHistoryManager.models(for: context, tx: tx)
            allModels.append(contentsOf: persistedModels)

            // Insert models for default icons that aren't persisted
            let defaultIcons: [AvatarIcon] = switch context {
            case .groupId: AvatarIcon.defaultGroupIcons
            case .profile: AvatarIcon.defaultProfileIcons
            }
            let iconsFromPersistedModels: Set<AvatarIcon> = Set(persistedModels.compactMap {
                switch $0.type {
                case .icon(let icon): return icon
                case .image, .text: return nil
                }
            })
            allModels.append(contentsOf: defaultIcons.compactMap { icon in
                if iconsFromPersistedModels.contains(icon) { return nil }
                return AvatarModel(type: .icon(icon), theme: .forIcon(icon))
            })

            return allModels.compactMap { model in
                guard
                    let image = SSKEnvironment.shared.avatarBuilderRef.avatarImage(
                        model: model,
                        diameterPoints: UInt(avatarSize),
                    )
                else {
                    owsFailDebug("Failed to prepare avatar for model \(model.identifier).")
                    return nil
                }
                return (model, image)
            }
        }

        for (row, avatars) in avatars.chunked(by: avatarsPerRow).enumerated() {
            let hStackView = UIStackView()
            hStackView.axis = .horizontal
            hStackView.spacing = avatarSpacing
            vStackView.addArrangedSubview(hStackView)

            for (index, avatar) in avatars.enumerated() {
                let view = reusableOptionView(for: (row * avatarsPerRow) + index)
                view.autoSetDimensions(to: CGSize(square: avatarSize))
                view.configure(model: avatar.model, image: avatar.image, isSelected: avatar.model == selectedAvatarModel)
                hStackView.addArrangedSubview(view)
            }
        }
    }

    // MARK: - Header

    func updateHeaderView() {
        switch state {
        case .new(let model):
            if let model {
                clearButton.isHidden = false
                headerImageView.image = SSKEnvironment.shared.avatarBuilderRef.avatarImage(model: model, diameterPoints: UInt(Self.headerAvatarSize))
            } else {
                clearButton.isHidden = true
                headerImageView.image = defaultAvatarImage
            }
        case .original(let image):
            if let image {
                clearButton.isHidden = false
                headerImageView.image = image
            } else {
                clearButton.isHidden = true
                headerImageView.image = defaultAvatarImage
            }
        }

        updateHeaderViewLayout()
    }

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()

        updateHeaderViewLayout()
    }

    private var previousSizeReference: CGFloat?
    private func updateHeaderViewLayout(forceUpdate: Bool = false) {
        // Update button layout only when the view size changes.
        guard view.width != previousSizeReference || forceUpdate else { return }
        previousSizeReference = view.width

        topHeaderStack.layoutMargins = .init(top: 24, left: 0, bottom: 13, right: 0)

        updateHeaderButtons()
    }

    // MARK: - Header Buttons

    private let headerButtonStack = UIStackView()

    private func buildHeaderButtons() -> [UIView] {
        return [
            buildHeaderButton(
                icon: .buttonCamera,
                title: OWSLocalizedString(
                    "AVATAR_SETTINGS_VIEW_CAMERA_BUTTON",
                    comment: "Text indicating the user can select an avatar from their camera",
                ),
                action: { [weak self] in
                    guard let self else { return }
                    let picker = OWSImagePickerController()
                    picker.delegate = self
                    picker.allowsEditing = false
                    picker.sourceType = .camera
                    picker.mediaTypes = [UTType.image.identifier]
                    self.present(picker, animated: true)
                },
            ),
            buildHeaderButton(
                icon: .buttonPhotoLibrary,
                title: OWSLocalizedString(
                    "AVATAR_SETTINGS_VIEW_PHOTO_BUTTON",
                    comment: "Text indicating the user can select an avatar from their photos",
                ),
                action: { [weak self] in
                    guard let self else { return }
                    let picker = OWSImagePickerController()
                    picker.delegate = self
                    picker.sourceType = .photoLibrary
                    picker.mediaTypes = [UTType.image.identifier]
                    self.present(picker, animated: true)
                },
            ),
            buildHeaderButton(
                icon: .buttonText,
                title: OWSLocalizedString(
                    "AVATAR_SETTINGS_VIEW_TEXT_BUTTON",
                    comment: "Text indicating the user can create a new avatar with text",
                ),
                action: { [weak self] in
                    let model = AvatarModel(type: .text(""), theme: .default)
                    let vc = AvatarEditViewController(model: model) { [weak self] editedModel in
                        DependenciesBridge.shared.db.asyncWrite { tx in
                            guard let self else { return }
                            AppEnvironment.shared.avatarHistoryManager.touchedModel(
                                editedModel,
                                in: self.context,
                                tx: tx,
                            )
                        } completion: {
                            self?.state = .new(editedModel)
                            self?.updateTableContents()
                        }
                    }
                    self?.presentFormSheet(OWSNavigationController(rootViewController: vc), animated: true)
                },
            ),
        ]
    }

    private func updateHeaderButtons() {
        clearButton.backgroundColor = Theme.isDarkThemeEnabled ? .ows_gray15 : UIColor(rgbHex: 0xf8f9f9)
        xImageView.tintColor = Theme.isDarkThemeEnabled ? .ows_gray80 : .ows_black

        headerButtonStack.removeAllSubviews()
        let headerButtons = buildHeaderButtons()

        let spacerWidth: CGFloat = 8
        let totalSpacerWidth = CGFloat(headerButtons.count - 1) * spacerWidth
        let maxAvailableButtonWidth = view.width - (cellOuterInsets.totalWidth + totalSpacerWidth)
        let minButtonWidth = maxAvailableButtonWidth / 4

        var buttonWidth = max(maxIconButtonWidth, minButtonWidth)
        let needsTwoRows = buttonWidth * CGFloat(headerButtons.count) > maxAvailableButtonWidth
        if needsTwoRows { buttonWidth *= 2 }
        headerButtons.forEach { $0.autoSetDimension(.width, toSize: buttonWidth) }

        func addButtonRow(_ buttons: [UIView]) {
            let stackView = UIStackView()
            stackView.axis = .horizontal
            stackView.distribution = .fillEqually
            stackView.spacing = spacerWidth
            buttons.forEach { stackView.addArrangedSubview($0) }
            headerButtonStack.addArrangedSubview(stackView)
        }

        if needsTwoRows {
            addButtonRow(Array(headerButtons.prefix(Int(ceil(CGFloat(headerButtons.count) / 2)))))
            addButtonRow(headerButtons.suffix(Int(floor(CGFloat(headerButtons.count) / 2))))
        } else {
            addButtonRow(headerButtons)
        }
    }

    private var maxIconButtonWidth: CGFloat = 0
    private func buildHeaderButton(icon: ThemeIcon, title: String, isEnabled: Bool = true, action: @escaping () -> Void) -> UIView {
        let button = SettingsHeaderButton(title: title.capitalized, icon: icon, actionHandler: action)
        button.buttonBackgroundColor = Self.cellBackgroundColor(isUsingPresentedStyle: true)
        button.selectedButtonBackgroundColor = Self.cellSelectedBackgroundColor()
        button.isEnabled = isEnabled

        if maxIconButtonWidth < button.minimumWidth {
            maxIconButtonWidth = button.minimumWidth
        }

        return button
    }
}

extension AvatarSettingsViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate {
    func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
        dismiss(animated: true, completion: nil)
    }

    func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
        guard let originalImage = info[.originalImage] as? UIImage else {
            return owsFailDebug("Failed to pick image")
        }

        dismiss(animated: true) { [weak self] in
            let vc = CropScaleImageViewController(srcImage: originalImage) { croppedImage in
                guard let self else { return }
                let imageModel = DependenciesBridge.shared.db.write { tx in
                    AppEnvironment.shared.avatarHistoryManager.recordModelForImage(
                        croppedImage,
                        in: self.context,
                        tx: tx,
                    )
                }
                DispatchQueue.main.async {
                    self.state = .new(imageModel)
                    self.updateTableContents()
                }
            }
            self?.present(vc, animated: true)
        }
    }
}

extension AvatarSettingsViewController: OptionViewDelegate {
    fileprivate func didSelectOptionView(_ optionView: OptionView, model: AvatarModel) {
        optionViews.forEach { $0.isSelected = $0 == optionView }
        state = .new(model)
    }

    fileprivate func didEditOptionView(_ optionView: OptionView, model: AvatarModel) {
        owsAssertDebug(model.type.isEditable)

        let vc = AvatarEditViewController(model: model) { [weak self, context] editedModel in
            DependenciesBridge.shared.db.asyncWrite { tx in
                AppEnvironment.shared.avatarHistoryManager.touchedModel(
                    editedModel,
                    in: context,
                    tx: tx,
                )
            } completion: {
                self?.state = .new(editedModel)
                self?.updateTableContents()
            }
        }
        presentFormSheet(OWSNavigationController(rootViewController: vc), animated: true)
    }

    fileprivate func didDeleteOptionView(_ optionView: OptionView, model: AvatarModel) {
        owsAssertDebug(model.type.isDeletable)
        DependenciesBridge.shared.db.asyncWrite { [context] tx in
            AppEnvironment.shared.avatarHistoryManager.deletedModel(
                model,
                in: context,
                tx: tx,
            )
        } completion: { [weak self] in
            // If we just deleted the selected avatar, also clear it.
            if self?.selectedAvatarModel == model {
                self?.state = .new(nil)
            }
            self?.updateTableContents()
        }
    }
}

private protocol OptionViewDelegate: AnyObject {
    func didSelectOptionView(_ optionView: OptionView, model: AvatarModel)
    func didEditOptionView(_ optionView: OptionView, model: AvatarModel)
    func didDeleteOptionView(_ optionView: OptionView, model: AvatarModel)
}

private class OptionView: UIView {
    private let imageView = AvatarImageView()
    private var imageViewInsetConstraints: [NSLayoutConstraint]?
    private let editOverlayView = AvatarImageView()

    private weak var delegate: (OptionViewDelegate & UIViewController)?

    var isSelected = false {
        didSet {
            guard isSelected != oldValue else { return }
            updateSelectionState()
        }
    }

    init(delegate: OptionViewDelegate & UIViewController) {
        self.delegate = delegate

        super.init(frame: .zero)

        addSubview(imageView)
        imageView.autoPinEdgesToSuperviewEdges()
        updateSelectionState()

        editOverlayView.image = UIImage(imageLiteralResourceName: "edit-fill")
        editOverlayView.backgroundColor = .ows_blackAlpha20
        editOverlayView.tintColor = .white
        editOverlayView.contentMode = .center
        imageView.addSubview(editOverlayView)
        editOverlayView.autoPinEdgesToSuperviewEdges(with: UIEdgeInsets(margin: 2.5))
        editOverlayView.layer.borderWidth = 1.5
        editOverlayView.isHidden = true

        addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleTap)))
        addGestureRecognizer(UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress)))
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func layoutSubviews() {
        super.layoutSubviews()

        layer.cornerRadius = width / 2
        layer.masksToBounds = true
    }

    @objc
    private func handleTap() {
        guard let model else {
            return owsFailDebug("Unexpectedly missing model in OptionView")
        }

        if !isSelected {
            isSelected = true
            delegate?.didSelectOptionView(self, model: model)
        } else if model.type.isEditable {
            delegate?.didEditOptionView(self, model: model)
        }
    }

    @objc
    private func handleLongPress() {
        guard let model else {
            return owsFailDebug("Unexpectedly missing model in OptionView")
        }

        let actionSheet = ActionSheetController()
        actionSheet.addAction(OWSActionSheets.cancelAction)
        if model.type.isEditable {
            actionSheet.addAction(.init(title: CommonStrings.editButton, handler: { [weak self] _ in
                guard let self else { return }
                self.delegate?.didEditOptionView(self, model: model)
            }))
        }
        if model.type.isDeletable {
            actionSheet.addAction(.init(title: CommonStrings.deleteButton, handler: { [weak self] _ in
                guard let self else { return }
                self.delegate?.didDeleteOptionView(self, model: model)
            }))
        }
        delegate?.presentActionSheet(actionSheet)
    }

    func updateSelectionState() {
        if isSelected {
            layer.borderColor = Theme.primaryTextColor.cgColor
            layer.borderWidth = 2.5
        } else {
            layer.borderColor = nil
            layer.borderWidth = 0
        }

        editOverlayView.isHidden = true
        editOverlayView.layer.borderColor = OWSTableViewController2.cellBackgroundColor(isUsingPresentedStyle: true).cgColor

        guard let model else { return }

        if model.type.isEditable {
            editOverlayView.isHidden = !isSelected
        }
    }

    private var model: AvatarModel?
    func configure(model: AvatarModel, image: UIImage, isSelected: Bool) {
        self.model = model
        self.isSelected = isSelected
        imageView.image = image
    }
}