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

import SignalServiceKit
import SignalUI

protocol PreviewWallpaperDelegate: AnyObject {
    func previewWallpaperDidCancel(_ vc: PreviewWallpaperViewController)
    func previewWallpaperDidComplete(_ vc: PreviewWallpaperViewController)
}

class PreviewWallpaperViewController: UIViewController {
    enum Mode {
        case preset(selectedWallpaper: Wallpaper)
        case photo(selectedPhoto: UIImage)
    }

    private(set) var mode: Mode { didSet { modeDidChange() }}
    let thread: TSThread?
    weak var delegate: PreviewWallpaperDelegate?
    lazy var blurButton = BlurButton { [weak self] shouldBlur in self?.standalonePage?.shouldBlur = shouldBlur }

    let pageViewController = UIPageViewController(
        transitionStyle: .scroll,
        navigationOrientation: .horizontal,
        options: [:],
    )

    lazy var mockConversationView = MockConversationView(
        model: buildMockConversationModel(),
        hasWallpaper: true,
        customChatColor: nil,
    )

    init(mode: Mode, thread: TSThread? = nil, delegate: PreviewWallpaperDelegate) {
        self.mode = mode
        self.thread = thread
        self.delegate = delegate

        super.init(nibName: nil, bundle: nil)

        mockConversationView.delegate = self
    }

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

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

    override func loadView() {
        view = UIView()

        view.backgroundColor = Theme.backgroundColor

        view.addSubview(mockConversationView)
        mockConversationView.autoPinWidthToSuperview()
        mockConversationView.autoPinEdge(toSuperviewSafeArea: .top, withInset: 20)
        mockConversationView.isUserInteractionEnabled = false

        modeDidChange()

        let buttonStack = UIStackView()
        buttonStack.addBackgroundView(withBackgroundColor: Theme.backgroundColor)
        buttonStack.axis = .horizontal

        view.addSubview(buttonStack)
        buttonStack.autoPinWidthToSuperview()
        buttonStack.autoPinEdge(toSuperviewSafeArea: .bottom)
        buttonStack.autoSetDimension(.height, toSize: 48, relation: .greaterThanOrEqual)

        let cancelButton = OWSButton(title: CommonStrings.cancelButton) { [weak self] in
            guard let self else { return }
            self.delegate?.previewWallpaperDidCancel(self)
        }
        cancelButton.setTitleColor(Theme.primaryTextColor, for: .normal)
        buttonStack.addArrangedSubview(cancelButton)

        let divider = UIView()
        let dividerLine = UIView()
        dividerLine.backgroundColor = UIColor(rgbHex: 0xc4c4c4)
        divider.addSubview(dividerLine)
        dividerLine.autoPinWidthToSuperview()
        dividerLine.autoPinHeightToSuperview(withMargin: 8)
        dividerLine.autoSetDimension(.width, toSize: 1)

        buttonStack.addArrangedSubview(divider)

        let setButton = OWSButton(title: CommonStrings.setButton) { [weak self] in
            self?.setCurrentWallpaperAndDismiss()
        }
        setButton.setTitleColor(Theme.primaryTextColor, for: .normal)
        buttonStack.addArrangedSubview(setButton)

        cancelButton.autoMatch(.width, to: .width, of: setButton)

        let safeAreaCover = UIView()
        safeAreaCover.backgroundColor = Theme.backgroundColor
        view.addSubview(safeAreaCover)
        safeAreaCover.autoPinEdge(toSuperviewEdge: .bottom)
        safeAreaCover.autoPinWidthToSuperview()
        safeAreaCover.autoPinEdge(.top, to: .bottom, of: buttonStack)

        view.addSubview(blurButton)
        blurButton.autoPinEdge(.bottom, to: .top, of: buttonStack, withOffset: -24)
        blurButton.autoHCenterInSuperview()
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        navigationItem.hidesBackButton = true
        title = OWSLocalizedString("WALLPAPER_PREVIEW_TITLE", comment: "Title for the wallpaper preview view.")
    }

    func setCurrentWallpaperAndDismiss() {
        let croppedAndScaledPhoto: UIImage?
        let preset: Wallpaper?
        switch self.mode {
        case .photo:
            guard let standalonePage = self.standalonePage else {
                return owsFailDebug("Missing standalone page for photo")
            }
            croppedAndScaledPhoto = standalonePage.generateSnapshotImage()
            preset = nil
        case .preset(let selectedWallpaper):
            croppedAndScaledPhoto = nil
            preset = selectedWallpaper
        }
        Task { [weak self, thread] in
            do {
                if let croppedAndScaledPhoto {
                    try await DependenciesBridge.shared.wallpaperStore.setPhoto(croppedAndScaledPhoto, for: thread)
                } else if let preset {
                    try await DependenciesBridge.shared.wallpaperStore.setBuiltIn(preset, for: thread)
                }
            } catch {
                owsFailDebug("Failed to set wallpaper \(error)")
            }
            await MainActor.run { [weak self] in
                guard let self else { return }
                self.delegate?.previewWallpaperDidComplete(self)
            }
        }
    }

    private var standalonePage: WallpaperPage?
    func modeDidChange() {
        let resolvedWallpaper: Wallpaper
        switch mode {
        case .photo(let selectedPhoto):
            owsAssertDebug(self.standalonePage == nil)
            resolvedWallpaper = .photo
            let standalonePage = WallpaperPage(wallpaper: resolvedWallpaper, thread: thread, photo: selectedPhoto)
            self.standalonePage = standalonePage
            view.insertSubview(standalonePage.view, at: 0)
            addChild(standalonePage)
            standalonePage.view.autoPinEdgesToSuperviewEdges()
            blurButton.isHidden = false
        case .preset(let selectedWallpaper):
            resolvedWallpaper = selectedWallpaper
            if pageViewController.view.superview == nil {
                view.insertSubview(pageViewController.view, at: 0)
                addChild(pageViewController)
                pageViewController.view.autoPinEdgesToSuperviewEdges()
                pageViewController.dataSource = self
                pageViewController.delegate = self
            }
            currentPage = WallpaperPage(wallpaper: selectedWallpaper, thread: thread)
            blurButton.isHidden = true
        }
        mockConversationView.model = buildMockConversationModel()
        mockConversationView.customChatColor = SSKEnvironment.shared.databaseStorageRef.read { tx in
            DependenciesBridge.shared.chatColorSettingStore.resolvedChatColor(
                for: thread,
                previewWallpaper: resolvedWallpaper,
                tx: tx,
            )
        }
    }

    func buildMockConversationModel() -> MockConversationView.MockModel {
        let outgoingText: String = {
            guard let thread else {
                return OWSLocalizedString(
                    "WALLPAPER_PREVIEW_OUTGOING_MESSAGE_ALL_CHATS",
                    comment: "The outgoing bubble text when setting a wallpaper for all chats.",
                )
            }

            let formatString = OWSLocalizedString(
                "WALLPAPER_PREVIEW_OUTGOING_MESSAGE_FORMAT",
                comment: "The outgoing bubble text when setting a wallpaper for specific chat. Embeds {{chat name}}",
            )
            let displayName = SSKEnvironment.shared.databaseStorageRef.read { tx in SSKEnvironment.shared.contactManagerRef.displayName(for: thread, transaction: tx) }
            return String.nonPluralLocalizedStringWithFormat(formatString, displayName)
        }()

        let incomingText: String
        switch mode {
        case .photo:
            incomingText = OWSLocalizedString(
                "WALLPAPER_PREVIEW_INCOMING_MESSAGE_PHOTO",
                comment: "The incoming bubble text when setting a photo",
            )
        case .preset:
            incomingText = OWSLocalizedString(
                "WALLPAPER_PREVIEW_INCOMING_MESSAGE_PRESET",
                comment: "The incoming bubble text when setting a preset",
            )
        }

        return MockConversationView.MockModel(items: [
            .date,
            .incoming(text: incomingText),
            .outgoing(text: outgoingText),
        ])
    }
}

// MARK: -

extension PreviewWallpaperViewController: UIPageViewControllerDataSource, UIPageViewControllerDelegate {

    func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
        guard let currentPage, currentPage.wallpaper != .photo else { return nil }
        return WallpaperPage(
            wallpaper: wallpaper(before: currentPage.wallpaper),
            thread: thread,
        )
    }

    func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
        guard let currentPage, currentPage.wallpaper != .photo else { return nil }
        return WallpaperPage(
            wallpaper: wallpaper(after: currentPage.wallpaper),
            thread: thread,
        )
    }

    func pageViewController(
        _ pageViewController: UIPageViewController,
        didFinishAnimating finished: Bool,
        previousViewControllers: [UIViewController],
        transitionCompleted completed: Bool,
    ) {
        guard let currentPage else {
            return owsFailDebug("Missing current page after transition")
        }

        DispatchQueue.main.async {
            self.mode = .preset(selectedWallpaper: currentPage.wallpaper)
        }
    }

    fileprivate var currentPage: WallpaperPage? {
        get { pageViewController.viewControllers?.first as? WallpaperPage }
        set {
            let viewControllers: [UIViewController]
            if let newValue {
                viewControllers = [newValue]
            } else {
                viewControllers = []
            }
            pageViewController.setViewControllers(viewControllers, direction: .forward, animated: false)
        }
    }

    func wallpaper(after: Wallpaper) -> Wallpaper {
        guard let index = Wallpaper.defaultWallpapers.firstIndex(where: { $0 == after }) else {
            owsFailDebug("Unexpectedly missing index for wallpaper \(after)")
            return Wallpaper.defaultWallpapers.first!
        }

        if index == Wallpaper.defaultWallpapers.count - 1 {
            return Wallpaper.defaultWallpapers.first!
        } else {
            return Wallpaper.defaultWallpapers[index + 1]
        }
    }

    func wallpaper(before: Wallpaper) -> Wallpaper {
        guard let index = Wallpaper.defaultWallpapers.firstIndex(where: { $0 == before }) else {
            owsFailDebug("Unexpectedly missing index for wallpaper \(before)")
            return Wallpaper.defaultWallpapers.first!
        }

        if index == 0 {
            return Wallpaper.defaultWallpapers.last!
        } else {
            return Wallpaper.defaultWallpapers[index - 1]
        }
    }
}

private class WallpaperPage: UIViewController {
    let wallpaper: Wallpaper
    let thread: TSThread?
    let photo: UIImage?
    var shouldBlur = false { didSet { updatePhoto() } }

    init(
        wallpaper: Wallpaper,
        thread: TSThread?,
        photo: UIImage? = nil,
    ) {
        self.wallpaper = wallpaper
        self.thread = thread
        self.photo = photo

        super.init(nibName: nil, bundle: nil)

        if photo != nil { prepareBlurredPhoto() }
    }

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

    var wallpaperViewHeightPriorityConstraints = [NSLayoutConstraint]()
    var wallpaperViewWidthPriorityConstraints = [NSLayoutConstraint]()
    var wallpaperViewHeightAndWidthPriorityConstraints = [NSLayoutConstraint]()

    var wallpaperView: WallpaperView?
    var wallpaperPreviewView: UIView?
    var scrollView: UIScrollView?

    override func loadView() {
        let rootView = ManualLayoutViewWithLayer(name: "rootView")
        rootView.shouldDeactivateConstraints = false
        rootView.translatesAutoresizingMaskIntoConstraints = true
        rootView.backgroundColor = Theme.darkThemeBackgroundColor
        view = rootView

        let shouldDimInDarkTheme = SSKEnvironment.shared.databaseStorageRef.read { transaction in
            DependenciesBridge.shared.wallpaperStore.fetchDimInDarkModeForRendering(
                for: thread?.uniqueId,
                tx: transaction,
            )
        }
        let wallpaperView = Wallpaper.viewBuilder(
            for: wallpaper,
            customPhoto: { photo },
            shouldDimInDarkTheme: shouldDimInDarkTheme,
        )?.build()
        guard let wallpaperView else {
            owsFailDebug("Failed to create photo wallpaper view")
            return
        }
        self.wallpaperView = wallpaperView

        let wallpaperPreviewView = wallpaperView.asPreviewView()
        self.wallpaperPreviewView = wallpaperPreviewView

        // If this is a photo, embed it in a scrollView for pinch & zoom
        if case .photo = wallpaper, let photo {
            let scrollView = UIScrollView()
            scrollView.minimumZoomScale = 1.0
            scrollView.maximumZoomScale = 6.0
            scrollView.contentInsetAdjustmentBehavior = .never
            scrollView.delegate = self
            view.addSubview(scrollView)
            scrollView.autoPinEdgesToSuperviewEdges()
            scrollView.addSubview(wallpaperPreviewView)
            self.scrollView = scrollView

            wallpaperPreviewView.autoPinEdgesToSuperviewEdges()

            wallpaperViewWidthPriorityConstraints = [
                wallpaperPreviewView.autoMatch(
                    .width,
                    to: .width,
                    of: scrollView,
                ),
                wallpaperPreviewView.autoMatch(
                    .height,
                    to: .width,
                    of: scrollView,
                    withMultiplier: 1 / photo.size.aspectRatio,
                ),
            ]
            wallpaperViewWidthPriorityConstraints.forEach { $0.isActive = false }

            wallpaperViewHeightPriorityConstraints = [
                wallpaperPreviewView.autoMatch(
                    .height,
                    to: .height,
                    of: scrollView,
                ),
                wallpaperPreviewView.autoMatch(
                    .width,
                    to: .height,
                    of: scrollView,
                    withMultiplier: photo.size.aspectRatio,
                ),
            ]
            wallpaperViewHeightPriorityConstraints.forEach { $0.isActive = false }

            wallpaperViewHeightAndWidthPriorityConstraints = [
                wallpaperPreviewView.autoMatch(
                    .height,
                    to: .height,
                    of: scrollView,
                ),
                wallpaperPreviewView.autoMatch(
                    .width,
                    to: .width,
                    of: scrollView,
                ),
            ]
            wallpaperViewHeightAndWidthPriorityConstraints.forEach { $0.isActive = false }

            updateWallpaperConstraints(reference: view.bounds.size)
        } else {
            view.addSubview(wallpaperPreviewView)
            wallpaperPreviewView.autoPinEdgesToSuperviewEdges()
        }
    }

    private func updatePhoto() {
        guard let wallpaperImageView = wallpaperView?.contentView as? UIImageView else { return }
        UIView.transition(with: wallpaperImageView, duration: 0.2, options: .transitionCrossDissolve) {
            wallpaperImageView.image = self.shouldBlur ? self.blurredPhoto : self.photo
        } completion: { _ in }
    }

    private var blurredPhoto: UIImage?
    private func prepareBlurredPhoto() {
        Task { [weak self, photo] in
            do {
                let blurredPhoto = try await photo?.withGaussianBlurAsync(
                    radius: 10,
                    resizeToMaxPixelDimension: 1024,
                )
                self?.blurredPhoto = blurredPhoto
                self?.updatePhoto()
            } catch {
                owsFailDebug("Failed to blur image \(error)")
            }
        }
    }

    override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
        super.viewWillTransition(to: size, with: coordinator)
        coordinator.animate { _ in
            self.updateWallpaperConstraints(reference: size)
        } completion: { _ in }
    }

    private var previousReferenceSize: CGSize = .zero
    override func viewWillLayoutSubviews() {
        super.viewWillLayoutSubviews()

        let referenceSize = view.bounds.size
        guard referenceSize != previousReferenceSize else { return }
        previousReferenceSize = referenceSize
        updateWallpaperConstraints(reference: referenceSize)
    }

    func updateWallpaperConstraints(reference: CGSize) {
        guard let imageSize = photo?.size else { return }

        wallpaperViewWidthPriorityConstraints.forEach { $0.isActive = false }
        wallpaperViewHeightPriorityConstraints.forEach { $0.isActive = false }
        wallpaperViewHeightAndWidthPriorityConstraints.forEach { $0.isActive = false }

        let imageSizeMatchingReferenceHeight = CGSize(
            width: reference.height * imageSize.aspectRatio,
            height: reference.height,
        )

        let imageSizeMatchingReferenceWidth = CGSize(
            width: reference.width,
            height: reference.width / imageSize.aspectRatio,
        )

        if imageSizeMatchingReferenceHeight.width >= reference.width {
            wallpaperViewHeightPriorityConstraints.forEach { $0.isActive = true }
        } else if imageSizeMatchingReferenceWidth.height >= reference.height {
            wallpaperViewWidthPriorityConstraints.forEach { $0.isActive = true }
        } else {
            wallpaperViewHeightAndWidthPriorityConstraints.forEach { $0.isActive = true }
        }
    }

    func generateSnapshotImage() -> UIImage? {
        guard case .photo = wallpaper, let scrollView else {
            return view.renderAsImage()
        }

        let viewForSnapshotting = UIView(frame: scrollView.frame)
        viewForSnapshotting.clipsToBounds = true
        let imageView = UIImageView(image: shouldBlur ? blurredPhoto : photo)
        viewForSnapshotting.addSubview(imageView)
        imageView.frame = CGRect(
            x: -scrollView.contentOffset.x,
            y: -scrollView.contentOffset.y,
            width: scrollView.contentScaleFactor * scrollView.contentSize.width,
            height: scrollView.contentScaleFactor * scrollView.contentSize.height,
        )
        return viewForSnapshotting.renderAsImage()
    }
}

extension WallpaperPage: UIScrollViewDelegate {
    func viewForZooming(in scrollView: UIScrollView) -> UIView? {
        return wallpaperPreviewView
    }
}

class BlurButton: UIButton {
    let checkImageView = UIImageView()
    let label = UILabel()
    let action: (Bool) -> Void
    let backgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .dark))

    init(action: @escaping (Bool) -> Void) {
        self.action = action
        super.init(frame: .zero)

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

        layoutMargins = UIEdgeInsets(top: 3, leading: 10, bottom: 3, trailing: 12)
        autoSetDimension(.height, toSize: 28, relation: .greaterThanOrEqual)

        backgroundView.clipsToBounds = true
        backgroundView.isUserInteractionEnabled = false
        addSubview(backgroundView)
        backgroundView.autoPinEdgesToSuperviewEdges()

        addSubview(checkImageView)
        checkImageView.autoPinEdge(toSuperviewMargin: .leading)
        checkImageView.autoPinHeightToSuperviewMargins()
        checkImageView.autoSetDimension(.width, toSize: 16)
        checkImageView.contentMode = .scaleAspectFit
        checkImageView.isUserInteractionEnabled = false

        label.font = .semiboldFont(ofSize: 14)
        label.textColor = .white
        label.text = OWSLocalizedString(
            "WALLPAPER_PREVIEW_BLUR_BUTTON",
            comment: "Blur button on wallpaper preview.",
        )
        addSubview(label)
        label.autoPinHeightToSuperviewMargins()
        label.autoPinEdge(toSuperviewMargin: .trailing)
        label.autoPinEdge(.leading, to: .trailing, of: checkImageView, withOffset: 10)
        label.isUserInteractionEnabled = false

        isSelected = false
    }

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

    override func layoutSubviews() {
        super.layoutSubviews()
        backgroundView.layer.cornerRadius = height / 2
    }

    override var isSelected: Bool {
        didSet {
            UIView.transition(with: checkImageView, duration: 0.15, options: .transitionCrossDissolve) {
                self.checkImageView.image = self.isSelected
                    ? UIImage(imageLiteralResourceName: "check-circle-fill-compact")
                    : UIImage(imageLiteralResourceName: "circle-compact")
            } completion: { _ in }
        }
    }

    @objc
    private func didTap() {
        isSelected = !isSelected
        action(isSelected)
    }
}

// MARK: -

extension PreviewWallpaperViewController: MockConversationDelegate {
    var mockConversationViewWidth: CGFloat { self.view.width }
}