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

import Lottie
import SignalServiceKit
import SignalUI

class MegaphoneView: UIView, ExperienceUpgradeView {
    let experienceUpgrade: ExperienceUpgrade

    var imageName: String? {
        didSet {
            if imageName != nil { image = nil }
        }
    }

    var image: UIImage? {
        didSet {
            if image != nil { imageName = nil }
        }
    }

    var imageContentMode: UIView.ContentMode = .scaleAspectFit

    var animation: Animation?
    struct Animation {
        let name: String
        let backgroundImageName: String?
        let backgroundImageInset: CGFloat
        let speed: CGFloat
        let loopMode: LottieLoopMode
        let backgroundBehavior: LottieBackgroundBehavior
        let contentMode: UIView.ContentMode

        init(
            name: String,
            backgroundImageName: String? = nil,
            backgroundImageInset: CGFloat = 0,
            speed: CGFloat = 1,
            loopMode: LottieLoopMode = .playOnce,
            backgroundBehavior: LottieBackgroundBehavior = .forceFinish,
            contentMode: UIView.ContentMode = .scaleAspectFit,
        ) {
            self.name = name
            self.speed = speed
            self.loopMode = loopMode
            self.backgroundBehavior = backgroundBehavior
            self.contentMode = contentMode
            self.backgroundImageName = backgroundImageName
            self.backgroundImageInset = backgroundImageInset
        }
    }

    enum ButtonOrientation {
        case horizontal
        case vertical
    }

    var buttonOrientation: ButtonOrientation = .horizontal {
        willSet { assert(!hasPresented) }
    }

    var titleText: String? {
        willSet { assert(!hasPresented) }
    }

    var bodyText: String? {
        willSet { assert(!hasPresented) }
    }

    struct Button {
        let title: String
        let action: () -> Void
    }

    private var buttons: [Button] = []
    func setButtons(primary: Button, secondary: Button? = nil) {
        assert(!hasPresented)

        if let secondary {
            buttons = [primary, secondary]
        } else {
            buttons = [primary]
        }
    }

    var isPresented: Bool { superview != nil }

    private let darkThemeBackgroundOverlay = UIView()
    private let stackView = UIStackView()
    init(experienceUpgrade: ExperienceUpgrade) {
        self.experienceUpgrade = experienceUpgrade

        super.init(frame: .zero)

        layer.cornerRadius = 12
        clipsToBounds = true

        let blurEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .dark))
        addSubview(blurEffectView)
        blurEffectView.autoPinEdgesToSuperviewEdges()

        addSubview(darkThemeBackgroundOverlay)
        darkThemeBackgroundOverlay.autoPinEdgesToSuperviewEdges()
        darkThemeBackgroundOverlay.backgroundColor = UIColor.white.withAlphaComponent(0.10)

        stackView.axis = .vertical
        addSubview(stackView)
        stackView.autoPinEdgesToSuperviewEdges()

        NotificationCenter.default.addObserver(self, selector: #selector(applyTheme), name: .themeDidChange, object: nil)
        applyTheme()
    }

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

    private var hasPresented = false
    func present(fromViewController: UIViewController) {
        AssertIsOnMainThread()

        guard !hasPresented else { return owsFailDebug("can only present once") }

        guard titleText != nil, bodyText != nil else {
            return owsFailDebug("megaphone is not prepared for presentation")
        }

        // Top section

        let labelStack = createLabelStack()

        let topStackSubviews: [UIView]
        if imageName != nil || image != nil || animation != nil {
            topStackSubviews = [createImageContainer(), labelStack]
        } else {
            topStackSubviews = [labelStack]
        }

        let topStackView = UIStackView(arrangedSubviews: topStackSubviews)
        topStackView.axis = .horizontal
        topStackView.spacing = 8
        topStackView.isLayoutMarginsRelativeArrangement = true
        topStackView.layoutMargins = UIEdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12)

        stackView.addArrangedSubview(topStackView)

        // Buttons

        if buttons.count > 0 {
            stackView.addArrangedSubview(createButtonsStack())
        } else {
            assert(buttons.isEmpty)
            addDismissButton()
        }

        fromViewController.view.addSubview(self)
        autoPinEdge(toSuperviewSafeArea: .leading, withInset: 8)
        autoPinEdge(toSuperviewSafeArea: .trailing, withInset: 8)
        autoPinEdge(toSuperviewSafeArea: .bottom, withInset: 8)

        animationView?.play()

        alpha = 0
        UIView.animate(withDuration: 0.2) {
            self.alpha = 1
        }

        hasPresented = true
    }

    @objc
    private func applyTheme() {
        darkThemeBackgroundOverlay.isHidden = !Theme.isDarkThemeEnabled
    }

    @objc
    private func tappedDismiss() {
        dismiss()
    }

    func dismiss(animated: Bool = true, completion: (() -> Void)? = nil) {
        UIView.animate(withDuration: animated ? 0.2 : 0, animations: {
            self.alpha = 0
        }) { _ in
            self.removeFromSuperview()
            completion?()
        }
    }

    func createLabelStack() -> UIStackView {
        let titleLabel = UILabel()
        titleLabel.numberOfLines = 0
        titleLabel.lineBreakMode = .byWordWrapping
        titleLabel.font = UIFont.semiboldFont(ofSize: 17)
        titleLabel.textColor = Theme.darkThemePrimaryColor
        titleLabel.text = titleText

        let bodyLabel = UILabel()
        bodyLabel.numberOfLines = 0
        bodyLabel.lineBreakMode = .byWordWrapping
        bodyLabel.font = UIFont.systemFont(ofSize: 15)
        bodyLabel.textColor = Theme.darkThemeSecondaryTextAndIconColor
        bodyLabel.text = bodyText

        let topSpacer = UIView()
        let bottomSpacer = UIView()

        let labelStack = UIStackView(arrangedSubviews: [topSpacer, titleLabel, bodyLabel, bottomSpacer])
        labelStack.axis = .vertical

        topSpacer.autoMatch(.height, to: .height, of: bottomSpacer)

        return labelStack
    }

    private var animationView: LottieAnimationView?
    func createImageContainer() -> UIView {
        let container: UIView

        if let image = { () -> UIImage? in
            if let imageName { return UIImage(named: imageName) }
            return image
        }() {
            container = UIView()
            let imageView = UIImageView()
            imageView.image = image
            imageView.contentMode = self.imageContentMode
            container.addSubview(imageView)
            imageView.autoPinWidthToSuperview()
            imageView.autoPinToSquareAspectRatio()
            imageView.autoVCenterInSuperview()
        } else if let animation {
            container = UIView()

            if let backgroundImageName = animation.backgroundImageName {
                let backgroundImageView = UIImageView()
                backgroundImageView.image = UIImage(named: backgroundImageName)
                backgroundImageView.contentMode = .scaleAspectFill
                container.addSubview(backgroundImageView)
                backgroundImageView.autoPinWidthToSuperview(withMargin: animation.backgroundImageInset)
                backgroundImageView.autoVCenterInSuperview()
            }

            let animationView = LottieAnimationView(name: animation.name)
            self.animationView = animationView
            animationView.contentMode = animation.contentMode
            animationView.animationSpeed = animation.speed
            animationView.loopMode = animation.loopMode
            animationView.backgroundBehavior = animation.backgroundBehavior

            container.addSubview(animationView)
            animationView.autoPinEdgesToSuperviewEdges()
        } else {
            owsFailDebug("unexpectedly missing animation and image")
            container = UIView()
        }

        container.autoSetDimension(.width, toSize: 64)
        container.autoSetDimension(.height, toSize: 64, relation: .greaterThanOrEqual)

        return container
    }

    func createButtonView(_ button: Button, font: UIFont = .regularFont(ofSize: 15)) -> OWSFlatButton {
        let buttonView = OWSFlatButton()

        buttonView.setTitle(title: button.title, font: font, titleColor: Theme.darkThemePrimaryColor)
        buttonView.setPressedBlock { button.action() }

        buttonView.autoSetDimension(.height, toSize: 44)

        return buttonView
    }

    func createButtonsStack() -> UIStackView {
        let buttonsStack = UIStackView()
        buttonsStack.addBackgroundView(withBackgroundColor: .ows_blackAlpha20)

        switch buttons.count {
        case 1:
            buttonsStack.addArrangedSubview(createButtonView(buttons[0]))
        case 2:
            var previousButton: UIView?
            for button in buttons {
                let buttonView = createButtonView(
                    button,
                    font: previousButton == nil ? UIFont.semiboldFont(ofSize: 15) : .regularFont(ofSize: 15),
                )

                switch buttonOrientation {
                case .vertical:
                    buttonsStack.addArrangedSubview(buttonView)
                case .horizontal:
                    buttonsStack.insertArrangedSubview(buttonView, at: 0)
                }

                previousButton?.autoMatch(.width, to: .width, of: buttonView)

                previousButton = buttonView
            }

            let dividerContainer = UIView()
            let divider = UIView()
            divider.backgroundColor = .ows_whiteAlpha20
            dividerContainer.addSubview(divider)
            buttonsStack.insertArrangedSubview(dividerContainer, at: 1)

            switch buttonOrientation {
            case .vertical:
                buttonsStack.axis = .vertical
                divider.autoSetDimension(.height, toSize: 1)
                divider.autoPinHeightToSuperview()
                divider.autoPinWidthToSuperview(withMargin: 12)
            case .horizontal:
                buttonsStack.axis = .horizontal
                divider.autoSetDimension(.width, toSize: 1)
                divider.autoPinWidthToSuperview()
                divider.autoPinHeightToSuperview(withMargin: 8)
            }
        default:
            owsFailDebug("only supports 1 or 2 buttons")
        }

        return buttonsStack
    }

    func addDismissButton() {
        let dismissButton = UIButton()
        dismissButton.setTemplateImage(Theme.iconImage(.buttonX), tintColor: Theme.darkThemePrimaryColor)
        dismissButton.addTarget(self, action: #selector(tappedDismiss), for: .touchUpInside)

        addSubview(dismissButton)

        dismissButton.autoSetDimensions(to: CGSize(square: 40))
        dismissButton.autoPinEdge(toSuperviewEdge: .trailing)
        dismissButton.autoPinEdge(toSuperviewEdge: .top)
    }

    func snoozeButton(fromViewController: UIViewController, snoozeTitle: String = MegaphoneStrings.remindMeLater) -> Button {
        return Button(title: snoozeTitle) { [weak self] in
            self?.markAsSnoozedWithSneakyTransaction()
            self?.dismiss {
                self?.presentToast(text: MegaphoneStrings.weWillRemindYouLater, fromViewController: fromViewController)
            }
        }
    }
}