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

import SignalServiceKit
import SignalUI

protocol UsernameLinkQRCodeColorPickerDelegate: SheetDismissalDelegate {
    func didFinalizeSelectedColor(color: QRCodeColor)
}

class UsernameLinkQRCodeColorPickerViewController: OWSTableViewController2 {
    private let startingColor: QRCodeColor
    private var currentColor: QRCodeColor

    private let username: String
    private let qrCode: UIImage

    private weak var colorPickerDelegate: UsernameLinkQRCodeColorPickerDelegate?

    init(
        currentColor: QRCodeColor,
        username: String,
        qrCode: UIImage,
        delegate: UsernameLinkQRCodeColorPickerDelegate,
    ) {
        self.startingColor = currentColor
        self.currentColor = currentColor

        self.username = username
        self.qrCode = qrCode

        self.colorPickerDelegate = delegate

        super.init()
    }

    // MARK: - Table contents

    /// Build a view containing the QR code, username, and colored background.
    ///
    /// This view has a fixed width, built around the fixed-width QR code.
    private func buildQRCodeView() -> UIView {
        let qrCodeView: QRCodeView = {
            let view = QRCodeView(
                qrCodeTintColor: currentColor,
                contentInset: 16,
            )
            view.autoSetDimensions(to: .square(184))
            view.setQRCode(image: qrCode)
            return view
        }()

        let usernameLabel: UILabel = {
            let label = UILabel()

            label.textColor = currentColor.username
            label.numberOfLines = 0
            label.lineBreakMode = .byCharWrapping
            label.textAlignment = .center
            label.font = .dynamicTypeHeadline.semibold()
            label.text = username

            return label
        }()

        let backgroundView = UIView()
        backgroundView.backgroundColor = currentColor.background
        backgroundView.layer.cornerRadius = 24
        backgroundView.layoutMargins = UIEdgeInsets(hMargin: 40, vMargin: 32)

        backgroundView.addSubview(qrCodeView)
        backgroundView.addSubview(usernameLabel)

        qrCodeView.autoPinEdges(toSuperviewMarginsExcludingEdge: .bottom)

        qrCodeView.autoPinEdge(.bottom, to: .top, of: usernameLabel, withOffset: -16)

        usernameLabel.autoPinEdges(toSuperviewMarginsExcludingEdge: .top)

        return backgroundView
    }

    private func buildColorOptionsView() -> UIView {
        let colorOptionButtons: [QRCodeColor: ColorOptionButton] = {
            return QRCodeColor.allCases.reduce(into: [:]) { partial, color in
                let button = ColorOptionButton(
                    size: 56,
                    color: color.background,
                    selected: color == currentColor,
                ) { [weak self] in
                    self?.didSelectColor(color: color)
                }

                partial[color] = button
            }
        }()

        func stack(colors: [QRCodeColor]) -> UIStackView {
            let stack = UIStackView(arrangedSubviews: colors.map { color in
                return colorOptionButtons[color]!
            })

            stack.layoutMargins = .zero
            stack.axis = .horizontal
            stack.alignment = .center
            stack.distribution = .equalSpacing

            return stack
        }

        let topStack = stack(colors: [.blue, .white, .grey, .olive])
        let bottomStack = stack(colors: [.green, .orange, .pink, .purple])

        let view = UIView()
        view.addSubview(topStack)
        view.addSubview(bottomStack)

        topStack.autoPinEdges(toSuperviewEdgesExcludingEdge: .bottom)
        topStack.autoPinEdge(.bottom, to: .top, of: bottomStack, withOffset: -26)
        bottomStack.autoPinEdges(toSuperviewEdgesExcludingEdge: .top)

        return view
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        navigationItem.leftBarButtonItem = .cancelButton(dismissingFrom: self)

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

        navigationItem.title = OWSLocalizedString(
            "USERNAME_LINK_QR_CODE_COLOR_PICKER_VIEW_TITLE_COLOR",
            comment: "A title for a view that allows you to pick a color for a QR code for your username link.",
        )

        buildTableContents()
    }

    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        colorPickerDelegate?.didDismissPresentedSheet()
    }

    private func buildTableContents() {
        let section = OWSTableSection(items: [
            .itemWrappingView(
                viewBlock: { [weak self] in
                    guard let self else { return nil }

                    let qrCodeView = self.buildQRCodeView()

                    // The QR code view has a fixed width, so wrap it in a view
                    // that can stretch.
                    let wrapper = UIView()
                    wrapper.addSubview(qrCodeView)

                    qrCodeView.autoPinEdge(toSuperviewEdge: .top)
                    qrCodeView.autoPinEdge(toSuperviewEdge: .bottom)
                    qrCodeView.autoHCenterInSuperview()

                    return wrapper
                },
                margins: UIEdgeInsets(top: 20, leading: 32, bottom: 24, trailing: 32),
            ),
            .itemWrappingView(
                viewBlock: { [weak self] in
                    self?.buildColorOptionsView()
                },
                margins: UIEdgeInsets(top: 24, leading: 36, bottom: 16, trailing: 36),
            ),
        ])

        section.hasBackground = false
        section.hasSeparators = false

        contents = OWSTableContents(sections: [section])
    }

    private func reloadTableContents() {
        self.tableView.reloadData()
    }

    // MARK: - Events

    private func didTapDone() {
        if startingColor != currentColor {
            colorPickerDelegate?.didFinalizeSelectedColor(color: currentColor)
        }

        dismiss(animated: true)
    }

    private func didSelectColor(color selectedColor: QRCodeColor) {
        currentColor = selectedColor
        reloadTableContents()
    }
}

// MARK: - ColorOptionButton

private extension UsernameLinkQRCodeColorPickerViewController {
    /// Represents a single color that can be selected by the user.
    class ColorOptionButton: UIButton {
        private let size: CGFloat
        private let color: UIColor

        private let onTap: () -> Void

        init(
            size: CGFloat,
            color: UIColor,
            selected: Bool,
            onTap: @escaping () -> Void,
        ) {
            self.size = size
            self.color = color
            self.onTap = onTap

            super.init(frame: .zero)

            setImage(selected: selected)

            ows_adjustsImageWhenHighlighted = false
            autoPinToSquareAspectRatio()
            autoSetDimension(.width, toSize: size)

            addTarget(self, action: #selector(didTap), for: .touchUpInside)
        }

        required init?(coder: NSCoder) { owsFail("Not implemented!") }

        override var frame: CGRect {
            didSet { layer.cornerRadius = width / 2 }
        }

        private func setImage(selected: Bool) {
            let image: UIImage = {
                if selected {
                    return Self.drawSelectedImage(
                        color: color.cgColor,
                        outerCircleColor: Theme.isDarkThemeEnabled ? .white : .black,
                        size: .square(size),
                    )
                } else {
                    return Self.drawUnselectedImage(
                        color: color.cgColor,
                        size: .square(size),
                    )
                }
            }()

            setImage(image, for: .normal)
        }

        @objc
        private func didTap() {
            onTap()
        }

        // MARK: Image drawing

        /// A colored circle with a dimmed border.
        private static func drawUnselectedImage(
            color: CGColor,
            size: CGSize,
        ) -> UIImage {
            return UIGraphicsImageRenderer(size: size).image { uiContext in
                drawColoredCircleWithBorder(
                    cgContext: uiContext.cgContext,
                    color: color,
                    rect: CGRect(origin: .zero, size: size),
                )
            }
        }

        /// A colored circle with a dimmed border, inset within an outer circle.
        private static func drawSelectedImage(
            color: CGColor,
            outerCircleColor: CGColor,
            size: CGSize,
        ) -> UIImage {
            return UIGraphicsImageRenderer(size: size).image { uiContext in
                let rect = CGRect(origin: .zero, size: size)

                let cgContext = uiContext.cgContext

                cgContext.setStrokeColor(outerCircleColor)
                cgContext.strokeEllipse(fittingIn: rect, width: 3)

                drawColoredCircleWithBorder(
                    cgContext: cgContext,
                    color: color,
                    rect: rect.inset(by: UIEdgeInsets(margin: 7)),
                )
            }
        }

        /// Draw a colored circle with a border into the given rect in the given
        /// context.
        private static func drawColoredCircleWithBorder(
            cgContext: CGContext,
            color: CGColor,
            rect: CGRect,
        ) {
            cgContext.setFillColor(color)
            cgContext.fillEllipse(in: rect)

            cgContext.setStrokeColor(.black_alpha12)
            cgContext.strokeEllipse(fittingIn: rect, width: 2)
        }
    }
}

private extension CGContext {
    func strokeEllipse(fittingIn rect: CGRect, width: CGFloat) {
        setLineWidth(width)
        strokeEllipse(in: rect.inset(by: width / 2))
    }
}

private extension CGRect {
    func inset(by amount: CGFloat) -> CGRect {
        return insetBy(dx: amount, dy: amount)
    }
}

private extension CGColor {
    static let white: CGColor = CGColor(red: 1, green: 1, blue: 1, alpha: 1)
    static let black: CGColor = CGColor(red: 0, green: 0, blue: 0, alpha: 1)
    static let black_alpha12 = CGColor(red: 0, green: 0, blue: 0, alpha: 0.12)
}