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

import SignalServiceKit
import SignalUI

protocol MediaGalleryPrimaryViewController: UIViewController {
    var scrollView: UIScrollView { get }
    var mediaGalleryFilterMenuItems: [MediaGalleryAccessoriesHelper.MenuItem] { get }
    var isEmpty: Bool { get }
    var hasSelection: Bool { get }
    func selectionInfo() -> (count: Int, totalSize: Int64)?
    func disableFiltering()
    func batchSelectionModeDidChange(isInBatchSelectMode: Bool)
    func selectAll()
    func didEndSelectMode()
    func deleteSelectedItems()
    func shareSelectedItems(_ sender: Any)
    var mediaCategory: AllMediaCategory { get }
    func set(mediaCategory: AllMediaCategory, isGridLayout: Bool)
}

@MainActor
class MediaGalleryAccessoriesHelper {
    private var footerBarBottomConstraint: NSLayoutConstraint?
    weak var viewController: MediaGalleryPrimaryViewController?

    private enum Layout {
        case list
        case grid

        var titleString: String {
            switch self {
            case .list:
                return OWSLocalizedString(
                    "ALL_MEDIA_LIST_MODE",
                    comment: "Menu option to show All Media items in a single-column list",
                )

            case .grid:
                return OWSLocalizedString(
                    "ALL_MEDIA_GRID_MODE",
                    comment: "Menu option to show All Media items in a grid of square thumbnails",
                )
            }
        }
    }

    private var lastUsedLayoutMap = [AllMediaCategory: Layout]()
    private var _layout = Layout.grid
    private var layout: Layout {
        get {
            _layout
        }
        set {
            guard newValue != _layout else { return }
            _layout = newValue
            updateBottomToolbarControls()
            guard let viewController else { return }

            switch layout {
            case .list:
                viewController.set(mediaCategory: viewController.mediaCategory, isGridLayout: false)
            case .grid:
                viewController.set(mediaCategory: viewController.mediaCategory, isGridLayout: true)
            }
        }
    }

    private lazy var headerView: UISegmentedControl = {
        let items = [
            AllMediaCategory.photoVideo,
            AllMediaCategory.audio,
            AllMediaCategory.otherFiles,
        ].map { $0.titleString }
        let segmentedControl = UISegmentedControl(items: items)
        segmentedControl.backgroundColor = .clear
        segmentedControl.selectedSegmentIndex = 0
        segmentedControl.addAction(
            UIAction { [weak self] action in
                self?.segmentedControlValueChanged(action.sender as! UISegmentedControl)
            },
            for: .valueChanged,
        )
        return segmentedControl
    }()

    func installViews() {
        guard let viewController, let view = viewController.view else { return }

        headerView.sizeToFit()
        var frame = headerView.frame
        frame.size.width += CGFloat(AllMediaCategory.allCases.count) * 20.0
        headerView.frame = frame
        viewController.navigationItem.titleView = headerView

        view.addSubview(footerBar)
        footerBar.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            footerBar.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
            footerBar.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
        ])
        if #available(iOS 26, *) {
            let interaction = UIScrollEdgeElementContainerInteraction()
            interaction.scrollView = viewController.scrollView
            interaction.edge = .bottom
            footerBar.addInteraction(interaction)
        }
        if #available(iOS 17, *) {
            footerBar.registerForTraitChanges([UITraitPreferredContentSizeCategory.self]) { [weak self] (_: UIView, _) in
                self?.updateFilterButton()
            }
        }

        updateDeleteButton()
        updateSelectionModeControls()
    }

    @available(iOS, obsoleted: 26)
    func applyTheme() {
        guard #unavailable(iOS 26) else { return }
        footerBar.barTintColor = Theme.navbarBackgroundColor
        footerBar.tintColor = Theme.primaryIconColor
        deleteButton.tintColor = Theme.primaryIconColor
        shareButton.tintColor = Theme.primaryIconColor
    }

    // MARK: - Menu

    struct MenuItem {
        var title: String
        var icon: UIImage?
        var isChecked = false
        var handler: () -> Void

        private var state: UIMenuElement.State {
            return isChecked ? .on : .off
        }

        @MainActor
        var uiAction: UIAction {
            return UIAction(title: title, image: icon, state: state) { _ in handler() }
        }
    }

    // MARK: - Batch Selection

    private lazy var selectButton = UIBarButtonItem(
        title: CommonStrings.selectButton,
        style: .plain,
        target: self,
        action: #selector(didTapSelect),
    )

    var isInBatchSelectMode = false {
        didSet {
            guard isInBatchSelectMode != oldValue else { return }

            viewController?.batchSelectionModeDidChange(isInBatchSelectMode: isInBatchSelectMode)
            updateFooterBarState()
            updateSelectionInfoLabel()
            updateSelectionModeControls()
            updateDeleteButton()
            updateShareButton()
        }
    }

    // Call this when an item is selected or deselected.
    func didModifySelection() {
        guard isInBatchSelectMode else {
            return
        }
        updateSelectionInfoLabel()
        updateDeleteButton()
        updateShareButton()
    }

    private var previousLeftBarButtonItem: UIBarButtonItem?

    private func updateSelectionModeControls() {
        guard let viewController else {
            return
        }
        if isInBatchSelectMode {
            viewController.navigationItem.rightBarButtonItem = .cancelButton { [weak self] in
                self?.didCancelSelect()
            }
            previousLeftBarButtonItem = viewController.navigationItem.leftBarButtonItem
            viewController.navigationItem.leftBarButtonItem = .button(
                title: OWSLocalizedString(
                    "SELECT_ALL",
                    comment: "Button text to select all in any list selection mode",
                ),
                style: .plain,
                action: { [weak self] in
                    self?.didSelectAll()
                },
            )
        } else {
            viewController.navigationItem.rightBarButtonItem = nil // TODO: Search
            viewController.navigationItem.leftBarButtonItem = previousLeftBarButtonItem
            previousLeftBarButtonItem = nil
        }

        headerView.isHidden = isInBatchSelectMode

        // Don't allow the user to leave mid-selection, so they realized they have
        // to cancel (lose) their selection if they leave.
        viewController.navigationItem.hidesBackButton = isInBatchSelectMode
    }

    @objc
    private func didTapSelect(_ sender: Any) {
        isInBatchSelectMode = true
    }

    private func didCancelSelect() {
        endSelectMode()
    }

    private func didSelectAll() {
        self.viewController?.selectAll()
        self.didModifySelection()
    }

    // Call this to exit select mode, for example after completing a deletion.
    func endSelectMode() {
        isInBatchSelectMode = false
        viewController?.didEndSelectMode()
    }

    // MARK: - Filter

    private func filterMenuItemsAndCurrentValue() -> (title: String, items: [MenuItem]) {
        guard let items = viewController?.mediaGalleryFilterMenuItems, !items.isEmpty else {
            return ("", [])
        }
        let currentTitle = items.first(where: { $0.isChecked })?.title ?? ""
        return (currentTitle, items)
    }

    private lazy var filterButton: UIBarButtonItem = {
        var configuration = UIButton.Configuration.plain()
        configuration.imagePlacement = .trailing
        configuration.image = UIImage(imageLiteralResourceName: "chevron-down-compact-bold")
        configuration.imagePadding = 4
        // For iOS 15-16.
        configuration.titleTextAttributesTransformer = .defaultFont(.systemFont(ofSize: 17, weight: .semibold))

        let button = UIButton(configuration: configuration, primaryAction: nil)
        button.showsMenuAsPrimaryAction = true
        if #available(iOS 26, *) {
            // Otherwise title will be blue when the button is pressed.
            // On earlier iOS version color is applied in `applyTheme`.
            button.tintColor = .Signal.label
        }
        return UIBarButtonItem(customView: button)
    }()

    func updateFilterButton() {
        let (buttonTitle, menuItems) = filterMenuItemsAndCurrentValue()
        if let button = filterButton.customView as? UIButton {
            button.configuration?.title = buttonTitle
            button.menu = menuItems.menu()
            button.sizeToFit()
            button.isHidden = menuItems.isEmpty

            if #available(iOS 17, *) {
                // UIKit uses three different font sizes for text buttons in the toolbar.
                // Reverse engineered logic is below.
                let preferredContentSizeCategory = UIApplication.shared.preferredContentSizeCategory
                let fontSize: CGFloat = switch preferredContentSizeCategory {
                case .extraSmall, .small, .medium, .large: 17
                case .extraLarge: 19
                default: 21
                }
                button.configuration?.titleTextAttributesTransformer = .defaultFont(.systemFont(ofSize: fontSize, weight: .semibold))
            }
        }
        if #available(iOS 26, *) {
            // I tried just setting .isHidden on the bar button item here, but
            // for some reason it would never reappear when I do that. But if
            // the content is blank and the shared background is hidden, then it
            // appears completely invisible.
            filterButton.hidesSharedBackground = menuItems.isEmpty
        }
    }

    // MARK: - List/Grid

    private func listMenuItem(isChecked: Bool) -> MenuItem {
        return MenuItem(
            title: Layout.list.titleString,
            icon: UIImage(named: "list-bullet-light"),
            isChecked: isChecked,
            handler: { [weak self] in
                self?.layout = .list
            },
        )
    }

    private func gridMenuItem(isChecked: Bool) -> MenuItem {
        return MenuItem(
            title: Layout.grid.titleString,
            icon: UIImage(named: "grid-square-light"),
            isChecked: isChecked,
            handler: { [weak self] in
                self?.layout = .grid
            },
        )
    }

    private func createLayoutPickerMenu(checkedLayout: Layout) -> UIMenu {
        let menuItems = [
            gridMenuItem(isChecked: checkedLayout == .grid),
            listMenuItem(isChecked: checkedLayout == .list),
        ]
        return menuItems.menu(with: .singleSelection)
    }

    private lazy var listViewButton: UIBarButtonItem = UIBarButtonItem(
        title: nil,
        image: UIImage(imageLiteralResourceName: "list-bullet"),
        primaryAction: nil,
        menu: createLayoutPickerMenu(checkedLayout: .list),
    )

    private lazy var gridViewButton: UIBarButtonItem = UIBarButtonItem(
        title: nil,
        image: UIImage(imageLiteralResourceName: "grid-square"),
        primaryAction: nil,
        menu: createLayoutPickerMenu(checkedLayout: .grid),
    )

    // MARK: - Footer

    private lazy var footerBar = UIToolbar()

    enum FooterBarState {
        // No footer bar.
        case hidden

        // Regular mode, no multi-selection possible.
        case regular

        // User can select one or more items.
        case selection
    }

    // You should assign to this when you begin filtering.
    var footerBarState = FooterBarState.hidden {
        willSet {
            let wasHidden = footerBarState == .hidden
            let willBeHidden = newValue == .hidden
            if wasHidden, !willBeHidden {
                showToolbar(animated: footerBar.window != nil)
            } else if !wasHidden, willBeHidden {
                hideToolbar(animated: footerBar.window != nil)
            }
        }
        didSet {
            updateBottomToolbarControls()
        }
    }

    private func updateBottomToolbarControls() {
        guard footerBarState != .hidden else { return }

        let fixedSpace = { return UIBarButtonItem(barButtonSystemItem: .fixedSpace, target: nil, action: nil) }
        let footerBarItems: [UIBarButtonItem]? = {
            switch footerBarState {
            case .hidden:
                return nil
            case .selection:
                return [shareButton, .flexibleSpace(), selectionInfoButton, .flexibleSpace(), deleteButton]
            case .regular:
                let firstItem: UIBarButtonItem
                if mediaCategory.supportsGridView {
                    firstItem = layout == .list ? listViewButton : gridViewButton
                } else {
                    firstItem = fixedSpace()
                }

                updateFilterButton()

                return [
                    firstItem,
                    .flexibleSpace(),
                    filterButton,
                    .flexibleSpace(),
                    selectButton,
                ]
            }
        }()
        footerBar.setItems(footerBarItems, animated: false)
    }

    // You must call this if you transition between having and not having items.
    func updateFooterBarState() {
        guard let viewController else { return }

        footerBarState = {
            if isInBatchSelectMode {
                return .selection
            }
            if viewController.isEmpty {
                return .hidden
            }
            return .regular
        }()
    }

    // MARK: - Toolbar

    private func showToolbar(animated: Bool) {
        guard animated else {
            showToolbar()
            return
        }
        UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseInOut) {
            self.showToolbar()
        }
    }

    private func showToolbar() {
        guard let viewController else { return }

        if let footerBarBottomConstraint {
            NSLayoutConstraint.deactivate([footerBarBottomConstraint])
        }

        footerBar.translatesAutoresizingMaskIntoConstraints = false
        let constraint = footerBar.bottomAnchor.constraint(
            equalTo: viewController.view.layoutMarginsGuide.bottomAnchor,
        )
        NSLayoutConstraint.activate([constraint])
        footerBarBottomConstraint = constraint

        viewController.view.layoutIfNeeded()
        let bottomInset = viewController.view.bounds.maxY - footerBar.frame.minY
        viewController.scrollView.contentInset.bottom = bottomInset
        viewController.scrollView.verticalScrollIndicatorInsets.bottom = bottomInset

    }

    private func hideToolbar(animated: Bool) {
        guard animated else {
            hideToolbar()
            return
        }
        UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseInOut) {
            self.hideToolbar()
        }
    }

    private func hideToolbar() {
        guard let viewController else { return }

        if let footerBarBottomConstraint {
            NSLayoutConstraint.deactivate([footerBarBottomConstraint])
        }

        footerBarBottomConstraint = footerBar.autoPinEdge(.top, to: .bottom, of: viewController.view)

        viewController.view.layoutIfNeeded()

        viewController.scrollView.contentInset.bottom = 0
        viewController.scrollView.verticalScrollIndicatorInsets.bottom = 0
    }

    // MARK: - Delete

    private lazy var deleteButton = UIBarButtonItem.button(
        icon: .buttonDelete,
        style: .plain,
        action: { [weak self] in
            self?.didPressDelete()
        },
    )

    private func updateDeleteButton() {
        guard let viewController else { return }
        deleteButton.isEnabled = viewController.hasSelection
    }

    private func didPressDelete() {
        Logger.debug("")
        viewController?.deleteSelectedItems()
    }

    // MARK: - Share

    private lazy var shareButton = UIBarButtonItem(
        image: Theme.iconImage(.buttonShare),
        primaryAction: UIAction { [weak self] action in
            self?.didPressShare(action.sender!)
        },
    )

    private func updateShareButton() {
        guard let viewController else { return }

        shareButton.isEnabled = viewController.hasSelection
    }

    private func didPressShare(_ sender: Any) {
        Logger.debug("")
        viewController?.shareSelectedItems(sender)
    }

    // MARK: - Selection Info

    private lazy var selectionCountLabel: UILabel = {
        let label = UILabel()
        label.textAlignment = .center
        label.textColor = .Signal.label
        label.font = .dynamicTypeSubheadlineClamped.semibold()
        label.adjustsFontForContentSizeCategory = true
        return label
    }()

    private lazy var selectionSizeLabel: UILabel = {
        let label = UILabel()
        label.textAlignment = .center
        label.textColor = .Signal.label
        label.font = .dynamicTypeSubheadlineClamped
        label.adjustsFontForContentSizeCategory = true
        return label
    }()

    private lazy var selectionInfoButton = UIBarButtonItem(customView: {
        let stackView = UIStackView(arrangedSubviews: [selectionCountLabel, selectionSizeLabel])
        stackView.axis = .vertical
        stackView.spacing = 0
        let container = UIView()
        container.addSubview(stackView)
        stackView.autoVCenterInSuperview()
        stackView.autoPinWidthToSuperviewMargins(withInset: 12)
        return container
    }())

    private func updateSelectionInfoLabel() {
        guard isInBatchSelectMode, let (selectionCount, totalSize) = viewController?.selectionInfo() else {
            selectionCountLabel.text = ""
            selectionSizeLabel.text = ""
            selectionInfoButton.customView?.sizeToFit()
            if #available(iOS 26, *) {
                selectionInfoButton.hidesSharedBackground = true
            }
            return
        }
        if #available(iOS 26, *) {
            selectionInfoButton.hidesSharedBackground = false
        }
        selectionCountLabel.text = String.localizedStringWithFormat(
            OWSLocalizedString("MESSAGE_ACTIONS_TOOLBAR_CAPTION_%d", tableName: "PluralAware", comment: ""),
            selectionCount,
        )
        selectionSizeLabel.text = OWSFormat.localizedFileSizeString(from: totalSize)

        selectionInfoButton.customView?.sizeToFit()
    }

    private var mediaCategory: AllMediaCategory {
        return AllMediaCategory(rawValue: headerView.selectedSegmentIndex) ?? .defaultValue
    }

    private func segmentedControlValueChanged(_ sender: UISegmentedControl) {
        guard let mediaCategory = AllMediaCategory(rawValue: sender.selectedSegmentIndex) else {
            owsFailDebug("Invalid segment index")
            return
        }

        if let previousMediaCategory = viewController?.mediaCategory {
            lastUsedLayoutMap[previousMediaCategory] = layout
        }
        if mediaCategory.supportsGridView {
            // Return to the previous mode
            _layout = lastUsedLayoutMap[mediaCategory, default: .grid]
        } else if layout == .grid {
            // This file type requires a switch to list mode
            _layout = .list
        }
        updateBottomToolbarControls()
        viewController?.set(mediaCategory: mediaCategory, isGridLayout: layout == .grid)
    }
}

extension AllMediaCategory {
    static var defaultValue = AllMediaCategory.photoVideo

    var supportsGridView: Bool {
        switch self {
        case .photoVideo:
            return true
        case .audio:
            return false
        case .otherFiles:
            return false
        }
    }

    var titleString: String {
        switch self {
        case .photoVideo:
            return OWSLocalizedString(
                "ALL_MEDIA_FILE_TYPE_MEDIA",
                comment: "Media (i.e., graphical) file type in All Meda file type picker.",
            )
        case .audio:
            return OWSLocalizedString(
                "ALL_MEDIA_FILE_TYPE_AUDIO",
                comment: "Audio file type in All Meda file type picker.",
            )
        case .otherFiles:
            return OWSLocalizedString(
                "ALL_MEDIA_FILE_TYPE_FILES",
                comment: "Generic All Media file type for non-audiovisual files used in file type picker",
            )
        }
    }
}

@MainActor
private extension Array where Element == MediaGalleryAccessoriesHelper.MenuItem {
    func menu(with options: UIMenu.Options = []) -> UIMenu {
        return UIMenu(title: "", options: options, children: reversed().map({ $0.uiAction }))
    }
}