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

import Foundation
import Lottie
import SignalServiceKit

open class HeroSheetViewController: StackSheetViewController {
    public enum Hero {
        /// Scaled image to display at the top of the sheet
        case image(UIImage, tintColor: UIColor? = nil)
        /// Lottie name and height to display at the top of the sheet
        case animation(named: String, height: CGFloat)
        case circleIcon(
            icon: UIImage,
            iconSize: CGFloat,
            tintColor: UIColor,
            backgroundColor: UIColor,
        )
    }

    public struct Body {
        public struct BulletPoint {
            public let icon: UIImage
            public let text: String

            public init(icon: UIImage, text: String) {
                self.icon = icon
                self.text = text
            }
        }

        public struct Toggle {
            public let text: String
            public let isOn: Bool
            public let onValueChanged: (_ isEnabled: Bool) -> Void

            public init(
                text: String,
                isOn: Bool,
                onValueChanged: @escaping (_ isEnabled: Bool) -> Void,
            ) {
                self.text = text
                self.isOn = isOn
                self.onValueChanged = onValueChanged
            }
        }

        public let text: String
        public let textAlignment: NSTextAlignment
        public let textColor: UIColor
        public let bulletPoints: [BulletPoint]
        public let toggle: Toggle?

        public init(
            text: String,
            textAlignment: NSTextAlignment = .center,
            textColor: UIColor = .Signal.secondaryLabel,
            bulletPoints: [BulletPoint] = [],
            toggle: Toggle? = nil,
        ) {
            self.text = text
            self.textAlignment = textAlignment
            self.textColor = textColor
            self.bulletPoints = bulletPoints
            self.toggle = toggle
        }
    }

    public enum Element {
        case button(Button)
        case hero(Hero)
    }

    public struct Button {
        public enum Action {
            case dismiss
            case custom((HeroSheetViewController) -> Void)
        }

        public enum Style {
            case primary
            case secondary
            case secondaryDestructive
        }

        fileprivate let title: String
        fileprivate let action: Action
        fileprivate let style: Style

        public init(title: String, style: Style = .primary, action: Action) {
            self.title = title
            self.style = style
            self.action = action
        }

        public init(title: String, action: @escaping (_: HeroSheetViewController) -> Void) {
            self.init(title: title, action: .custom(action))
        }

        public static func dismissing(title: String, style: Style = .primary) -> Button {
            Button(title: title, style: style, action: .dismiss)
        }

        fileprivate var configuration: UIButton.Configuration {
            switch style {
            case .primary:
                return .largePrimary(title: title)

            case .secondary:
                return .largeSecondary(title: title)

            case .secondaryDestructive:
                var config: UIButton.Configuration = .largeSecondary(title: title)
                config.baseForegroundColor = .Signal.red
                return config
            }
        }
    }

    // MARK: -

    private let hero: Hero
    private let titleText: String?
    private let body: Body
    private let primary: Element?
    private let secondary: Element?

    public init(
        hero: Hero,
        title: String?,
        body: String,
        primaryButton: Button?,
        secondaryButton: Button? = nil,
    ) {
        self.hero = hero
        self.titleText = title
        self.body = Body(text: body)
        self.primary = primaryButton.map { .button($0) }
        self.secondary = secondaryButton.map { .button($0) }
        super.init()
    }

    public init(
        hero: Hero,
        title: String?,
        body: Body,
        primary: Element?,
        secondary: Element?,
    ) {
        self.hero = hero
        self.titleText = title
        self.body = body
        self.primary = primary
        self.secondary = secondary
        super.init()
    }

    // MARK: -

    // .formSheet makes a blank sheet appear behind it
    override public var modalPresentationStyle: UIModalPresentationStyle {
        willSet {
            if newValue == .formSheet {
                owsFailDebug("Can't use formSheet for interactive sheets")
            }
        }
    }

    override public var stackViewInsets: UIEdgeInsets {
        .init(top: 8, leading: 24, bottom: 32, trailing: 24)
    }

    override public func viewDidLoad() {
        super.viewDidLoad()

        let heroView = viewForHero(hero)
        self.stackView.addArrangedSubview(heroView)
        self.stackView.setCustomSpacing(16, after: heroView)

        if let titleText {
            let titleLabel = UILabel()
            self.stackView.addArrangedSubview(titleLabel)
            self.stackView.setCustomSpacing(12, after: titleLabel)
            titleLabel.text = titleText
            titleLabel.font = .dynamicTypeTitle2.bold()
            titleLabel.numberOfLines = 0
            titleLabel.textAlignment = .center
        }

        let bodyLabel = UILabel()
        self.stackView.addArrangedSubview(bodyLabel)
        self.stackView.setCustomSpacing(32, after: bodyLabel)
        bodyLabel.text = body.text
        bodyLabel.textColor = body.textColor
        bodyLabel.textAlignment = body.textAlignment
        bodyLabel.font = .dynamicTypeSubheadline
        bodyLabel.numberOfLines = 0

        for bodyBullet in body.bulletPoints {
            let bulletView = viewForBulletPoint(
                bodyBullet,
                textColor: body.textColor,
            )
            self.stackView.addArrangedSubview(bulletView)
            self.stackView.setCustomSpacing(32, after: bulletView)
        }

        if let toggle = body.toggle {
            let toggleView = viewForToggle(toggle)
            self.stackView.addArrangedSubview(toggleView)
            self.stackView.setCustomSpacing(32, after: toggleView)
        }

        if let primary {
            let primaryButtonView = viewForElement(primary)
            self.stackView.addArrangedSubview(primaryButtonView)
            self.stackView.setCustomSpacing(20, after: primaryButtonView)
        }

        if let secondary {
            let secondaryButtonView = viewForElement(secondary)
            self.stackView.addArrangedSubview(secondaryButtonView)
        }
    }

    private func viewForHero(_ hero: Hero) -> UIView {
        let heroView: UIView
        switch hero {
        case let .image(image, tintColor):
            heroView = UIImageView(image: image)
            heroView.contentMode = .center
            if let tintColor {
                heroView.tintColor = tintColor
            }
        case let .animation(lottieName, height):
            let lottieView = LottieAnimationView(name: lottieName)
            lottieView.autoSetDimension(.height, toSize: height)
            lottieView.contentMode = .scaleAspectFit
            lottieView.loopMode = .loop
            lottieView.play()

            heroView = lottieView
        case let .circleIcon(icon, iconSize, tintColor, backgroundColor):
            let iconView = UIImageView(image: icon)
            iconView.tintColor = tintColor
            heroView = UIView()
            let backgroundView = UIView()
            heroView.addSubview(backgroundView)
            backgroundView.autoPinHeightToSuperview()
            backgroundView.autoHCenterInSuperview()
            backgroundView.contentMode = .center
            backgroundView.autoSetDimensions(to: .square(64))
            backgroundView.layer.cornerRadius = 32
            backgroundView.backgroundColor = backgroundColor
            backgroundView.addSubview(iconView)
            iconView.autoCenterInSuperview()
            iconView.autoSetDimensions(to: .square(iconSize))
        }
        return heroView
    }

    private func viewForBulletPoint(
        _ bulletPoint: Body.BulletPoint,
        textColor: UIColor,
    ) -> UIView {
        let bulletContainer = UIView()
        bulletContainer.layoutMargins = UIEdgeInsets(hMargin: 24, vMargin: 0)

        let iconImageView = UIImageView()
        bulletContainer.addSubview(iconImageView)
        iconImageView.image = bulletPoint.icon
        iconImageView.tintColor = .Signal.secondaryLabel

        let bulletLabel = UILabel()
        bulletContainer.addSubview(bulletLabel)
        bulletLabel.font = .dynamicTypeSubheadline
        bulletLabel.textColor = textColor
        bulletLabel.numberOfLines = 0
        bulletLabel.textAlignment = .left
        bulletLabel.text = bulletPoint.text

        iconImageView.autoSetDimensions(to: .square(24))
        iconImageView.autoPinEdge(toSuperviewMargin: .leading)
        iconImageView.autoVCenterInSuperview()

        iconImageView.autoPinEdge(.trailing, to: .leading, of: bulletLabel, withOffset: -12)

        bulletLabel.autoPinEdges(toSuperviewMarginsExcludingEdge: .leading)

        return bulletContainer
    }

    private func viewForToggle(_ toggle: Body.Toggle) -> UIView {
        let label = UILabel()
        label.text = toggle.text
        label.font = .dynamicTypeSubheadline
        label.textAlignment = .natural
        label.numberOfLines = 0
        label.textColor = .Signal.label

        let toggleSwitch = UISwitch()
        toggleSwitch.isOn = toggle.isOn
        toggleSwitch.addAction(
            UIAction { [weak self] action in
                guard
                    let toggle = self?.body.toggle,
                    let toggleSwitch = action.sender as? UISwitch
                else {
                    return
                }
                toggle.onValueChanged(toggleSwitch.isOn)
            },
            for: .valueChanged,
        )

        let containerView = PillView()
        containerView.backgroundColor = .Signal.tertiaryBackground
        containerView.layoutMargins = UIEdgeInsets(hMargin: 20, vMargin: 16)
        containerView.addSubview(label)
        containerView.addSubview(toggleSwitch)

        label.autoPinEdges(toSuperviewMarginsExcludingEdge: .trailing)

        toggleSwitch.autoPinEdge(.leading, to: .trailing, of: label, withOffset: 16, relation: .greaterThanOrEqual)
        toggleSwitch.autoPinEdge(toSuperviewMargin: .trailing)
        toggleSwitch.autoVCenterInSuperview()

        return containerView
    }

    private func viewForElement(_ element: Element) -> UIView {
        switch element {
        case .button(let button):
            let buttonView = self.buttonView(for: button)
            buttonView.configuration = button.configuration
            return buttonView
        case .hero(let hero):
            return viewForHero(hero)
        }
    }

    private func buttonView(for button: Button) -> UIButton {
        UIButton(
            type: .system,
            primaryAction: UIAction { [weak self] _ in
                guard let self else { return }
                switch button.action {
                case .dismiss:
                    self.dismiss(animated: true)
                case .custom(let closure):
                    closure(self)
                }
            },
        )
    }
}

// MARK: -

#if DEBUG

@available(iOS 17, *)
#Preview("Image") {
    SheetPreviewViewController(sheet: HeroSheetViewController(
        hero: .image(UIImage(named: "linked-devices")!),
        title: LocalizationNotNeeded("Finish linking on your other device"),
        body: LocalizationNotNeeded("Finish linking Signal on your other device."),
        primaryButton: .dismissing(title: CommonStrings.continueButton),
    ))
}

@available(iOS 17, *)
#Preview("Body w/ bullets") {
    SheetPreviewViewController(sheet: HeroSheetViewController(
        hero: .image(UIImage(named: "sustainer-heart")!),
        title: nil,
        body: HeroSheetViewController.Body(
            text: "As an independent nonprofit, Signal is committed to private messaging and calls. No ads, no trackers, no surveillance. Donate today to support Signal.",
            textAlignment: .left,
            textColor: .Signal.label,
            bulletPoints: [
                HeroSheetViewController.Body.BulletPoint(
                    icon: UIImage(named: "badge-multi")!,
                    text: "Get an optional badge on your profile when you donate",
                ),
                HeroSheetViewController.Body.BulletPoint(
                    icon: UIImage(named: "lock")!,
                    text: "Your privacy is our mission",
                ),
                HeroSheetViewController.Body.BulletPoint(
                    icon: UIImage(named: "heart")!,
                    text: "Signal is a 501c3 nonprofit. US donations are tax deductible.",
                ),
            ],
        ),
        primary: nil,
        secondary: nil,
    ))
}

@available(iOS 17, *)
#Preview("Body w/toggle") {
    SheetPreviewViewController(sheet: HeroSheetViewController(
        hero: .image(UIImage(named: "toggle-32")!),
        title: nil,
        body: HeroSheetViewController.Body(
            text: #"Give Boots extra dinner? He'd like you to know he's "extra hungry" tonight."#,
            toggle: HeroSheetViewController.Body.Toggle(
                text: "Extra Food?",
                isOn: true,
                onValueChanged: { enabled in
                    print(enabled ? "😸" : "😾")
                },
            ),
        ),
        primary: .button(.dismissing(title: "Order Up")),
        secondary: nil,
    ))
}

@available(iOS 17, *)
#Preview("Animated") {
    SheetPreviewViewController(sheet: HeroSheetViewController(
        hero: .animation(named: "linking-device-light", height: 192),
        title: LocalizationNotNeeded("Scan QR Code"),
        body: LocalizationNotNeeded("Use this device to scan the QR code displayed on the device you want to link"),
        primaryButton: .dismissing(title: CommonStrings.okayButton),
    ))
}

@available(iOS 17, *)
#Preview("Circle icon") {
    SheetPreviewViewController(sheet: HeroSheetViewController(
        hero: .circleIcon(
            icon: UIImage(named: "key")!,
            iconSize: 35,
            tintColor: UIColor.Signal.label,
            backgroundColor: UIColor.Signal.background,
        ),
        title: LocalizationNotNeeded("No Recovery Key?"),
        body: LocalizationNotNeeded("Backups can’t be recovered without their 64-digit recovery code. If you’ve lost your recovery key Signal can’t help restore your backup.\n\nIf you have your old device you can view your recovery key in Settings > Chats > Signal Backups. Then tap View recovery key."),
        primaryButton: .dismissing(title: LocalizationNotNeeded("Skip & Don’t Restore")),
        secondaryButton: .dismissing(title: CommonStrings.learnMore),
    ))
}

@available(iOS 17, *)
#Preview("Footer animation") {
    SheetPreviewViewController(sheet: HeroSheetViewController(
        hero: .image(UIImage(named: "transfer_complete")!),
        title: LocalizationNotNeeded("Continue on your other device"),
        body: HeroSheetViewController.Body(text: LocalizationNotNeeded("Continue transferring your account on your other device.")),
        primary: .hero(.animation(named: "circular_indeterminate", height: 60)),
        secondary: nil,
    ))
}

#endif