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

import Combine
import SignalServiceKit
import SignalUI

// MARK: - CallQualitySurveyIssuesViewController

final class CallQualitySurveyIssuesViewController: CallQualitySurveySheetViewController {
    private var sizeChangeSubscription: AnyCancellable?

    private let headerContainer = UIView()
    private let bottomStackView = UIStackView()
    private lazy var continueButton = UIButton(
        configuration: .largePrimary(title: CommonStrings.continueButton),
        primaryAction: .init { [weak self] _ in
            self?.submit()
        },
    )
    private lazy var customIssueEntry = UIButton(
        configuration: customIssueButtonConfig(customText: nil),
        primaryAction: .init { [weak self] _ in
            self?.didTapCustomIssue()
        },
    )

    private let collectionView: UICollectionView = {
        let layout = CenteredFlowLayout()
        layout.scrollDirection = .vertical
        layout.minimumLineSpacing = 10
        layout.minimumInteritemSpacing = 10
        layout.estimatedItemSize = UICollectionViewFlowLayout.automaticSize
        return UICollectionView(frame: .zero, collectionViewLayout: layout)
    }()

    private var dataSource: DataSource?

    private var selectedItems = Set<Item>()
    private var customIssue: String? {
        didSet {
            updateViewState()
        }
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        title = OWSLocalizedString(
            "CALL_QUALITY_SURVEY_ISSUES_SHEET_TITLE",
            comment: "Title for the sheet in the call quality survey where issues with the call can be selected",
        )

        let headerLabel = UILabel()
        headerLabel.text = OWSLocalizedString(
            "CALL_QUALITY_SURVEY_ISSUES_HEADER",
            comment: "Header text on the call quality survey issues screen",
        )
        headerLabel.font = .dynamicTypeSubheadline
        headerLabel.textColor = .Signal.secondaryLabel
        headerLabel.textAlignment = .center
        headerContainer.addSubview(headerLabel)
        headerLabel.autoPinEdgesToSuperviewMargins(with: .init(
            top: 0,
            leading: 36,
            bottom: 24,
            trailing: 36,
        ))
        view.addSubview(headerContainer)
        headerContainer.autoPinEdges(toSuperviewEdgesExcludingEdge: .bottom)
        headerContainer.layoutMargins = .zero
        headerContainer.preservesSuperviewLayoutMargins = true

        view.addSubview(collectionView)

        collectionView.backgroundColor = nil
        collectionView.autoPinWidthToSuperview()
        collectionView.autoPinEdge(.top, to: .bottom, of: headerContainer)
        collectionView.contentInset = .init(
            top: 0,
            leading: 8,
            bottom: 24,
            trailing: 8,
        )
        collectionView.allowsMultipleSelection = true
        collectionView.delegate = self

        bottomStackView.axis = .vertical
        bottomStackView.spacing = 24
        bottomStackView.isLayoutMarginsRelativeArrangement = true
        bottomStackView.directionalLayoutMargins = .init(hMargin: 12, vMargin: 0)
        view.addSubview(bottomStackView)
        bottomStackView.autoPinEdge(.top, to: .bottom, of: collectionView)
        bottomStackView.autoPinEdges(toSuperviewMarginsExcludingEdge: .top)

        bottomStackView.addArrangedSubview(customIssueEntry)
        customIssueEntry.isHiddenInStackView = true
        customIssueEntry.contentHorizontalAlignment = .leading

        bottomStackView.addArrangedSubview(continueButton)

        if #available(iOS 16.0, *) {
            sizeChangeSubscription = collectionView
                .publisher(for: \.contentSize)
                .removeDuplicates()
                .sink { [weak self] contentSize in
                    // idk why, but without the dispatch, expansion happens
                    // without an animation, but shrinking does
                    DispatchQueue.main.async {
                        self?.reloadHeight()
                    }
                }
        }

        let cellRegistration = UICollectionView.CellRegistration<CapsuleCell, Item> { cell, _, item in
            cell.configure(title: item.title, image: item.image)
        }

        dataSource = DataSource(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
            collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: itemIdentifier)
        }

        loadInitialSnapshot()
        updateViewState()
    }

    override func customSheetHeight() -> CGFloat? {
        let headerHeight = headerContainer.height
        let collectionViewHeight = collectionView.contentSize.height + collectionView.contentInset.totalHeight
        let bottomStackHeight = bottomStackView.height
        return headerHeight + collectionViewHeight + bottomStackHeight
    }

    private func loadInitialSnapshot() {
        var snapshot = Snapshot()
        snapshot.appendSections([.main])
        snapshot.appendItems([.audio, .video, .callDropped, .other])
        dataSource?.apply(snapshot, animatingDifferences: false)
    }

    private func updateSnapshot() {
        var snapshot = Snapshot()
        snapshot.appendSections([.main])

        // Audio
        snapshot.appendItems([.audio])
        let audioSubItems: [Item] = [.audioStuttering, .audioLocalEcho, .audioRemoteEcho, .audioDrop]
        if self.selectedItems.contains(.audio) {
            snapshot.appendItems(audioSubItems)
        } else {
            audioSubItems.forEach { self.selectedItems.remove($0) }
        }

        // Video
        snapshot.appendItems([.video])
        let videoSubItems: [Item] = [.videoNoCamera, .videoLowQuality, .videoLowResolution]
        if self.selectedItems.contains(.video) {
            snapshot.appendItems(videoSubItems)
        } else {
            videoSubItems.forEach { self.selectedItems.remove($0) }
        }

        snapshot.appendItems([.callDropped, .other])

        let oldSnapshot = self.dataSource?.snapshot()

        self.dataSource?.apply(snapshot, animatingDifferences: true)

        if #available(iOS 16, *) {
            // Sheets self-size on iOS 16+
        } else if
            let oldSnapshot,
            let sheet = sheetNav?.sheetPresentationController,
            snapshot.itemIdentifiers.count > oldSnapshot.itemIdentifiers.count
        {
            // Audio or video was selected, expanding the content with more.
            // Expand the sheet to accommodate.
            sheet.animateChanges {
                sheet.selectedDetentIdentifier = .large
            }
        }
    }

    private func updateViewState() {
        customIssueEntry.configuration = customIssueButtonConfig(customText: customIssue)

        let hasSelectedCustomIssue = self.selectedItems.contains(.other)
        let emptyCustomIssueText = self.customIssue.isEmptyOrNil
        let missingCustomIssue = hasSelectedCustomIssue && emptyCustomIssueText

        let noIssuesSelected = self.selectedItems.isEmpty

        let disableContinueButton = missingCustomIssue || noIssuesSelected
        continueButton.isEnabled = !disableContinueButton

        let customIssueEntryShouldBeHidden = !self.selectedItems.contains(.other)
        if customIssueEntryShouldBeHidden != customIssueEntry.isHiddenInStackView {
            UIView.animate(withDuration: 0.3) {
                self.customIssueEntry.isHiddenInStackView = customIssueEntryShouldBeHidden
                DispatchQueue.main.async {
                    self.reloadHeight()
                }
            }
        }
    }

    private func customIssueButtonConfig(customText: String?) -> UIButton.Configuration {
        var config = UIButton.Configuration.filled()
        config.baseBackgroundColor = .Signal.secondaryGroupedBackground

        if let customText {
            config.title = customText
            config.baseForegroundColor = .Signal.label
        } else {
            config.title = CallQualitySurveyCustomIssueViewController.placeholderText
            config.baseForegroundColor = .Signal.secondaryLabel
        }

        config.cornerStyle = .capsule
        config.titleAlignment = .leading
        config.contentInsets = .init(hMargin: 16, vMargin: 13)
        config.titleLineBreakMode = .byTruncatingTail
        return config
    }

    private func didTapCustomIssue() {
        let vc = CallQualitySurveyCustomIssueViewController(issue: self.customIssue)
        vc.surveyDelegate = self
        present(OWSNavigationController(rootViewController: vc), animated: true)
    }

    private func submit() {
        sheetNav?.doneSelectingIssues(
            rating: .hadIssues(selectedItems, customIssue: customIssue),
        )
    }

    private typealias DataSource = UICollectionViewDiffableDataSource<Section, Item>
    private typealias Snapshot = NSDiffableDataSourceSnapshot<Section, Item>

    private typealias Item = CallQualitySurvey.Issue

    private enum Section: Hashable {
        case main
    }
}

private extension CallQualitySurvey.Issue {
    var title: String {
        switch self {
        case .audio:
            OWSLocalizedString(
                "CALL_QUALITY_SURVEY_ISSUE_AUDIO",
                comment: "Label for audio issue option in call quality survey",
            )
        case .audioStuttering:
            OWSLocalizedString(
                "CALL_QUALITY_SURVEY_ISSUE_AUDIO_STUTTERING",
                comment: "Label for audio stuttering issue option in call quality survey",
            )
        case .audioLocalEcho:
            OWSLocalizedString(
                "CALL_QUALITY_SURVEY_ISSUE_AUDIO_LOCAL_ECHO",
                comment: "Label for local echo issue option in call quality survey, indicating the user heard an echo",
            )
        case .audioRemoteEcho:
            OWSLocalizedString(
                "CALL_QUALITY_SURVEY_ISSUE_AUDIO_REMOTE_ECHO",
                comment: "Label for remote echo issue option in call quality survey, indicating other participants heard an echo",
            )
        case .audioDrop:
            OWSLocalizedString(
                "CALL_QUALITY_SURVEY_ISSUE_AUDIO_DROP",
                comment: "Label for audio dropout issue option in call quality survey",
            )
        case .video:
            OWSLocalizedString(
                "CALL_QUALITY_SURVEY_ISSUE_VIDEO",
                comment: "Label for video issue option in call quality survey",
            )
        case .videoNoCamera:
            OWSLocalizedString(
                "CALL_QUALITY_SURVEY_ISSUE_VIDEO_NO_CAMERA",
                comment: "Label for camera not working issue option in call quality survey",
            )
        case .videoLowQuality:
            OWSLocalizedString(
                "CALL_QUALITY_SURVEY_ISSUE_VIDEO_LOW_QUALITY",
                comment: "Label for poor video quality issue option in call quality survey",
            )
        case .videoLowResolution:
            OWSLocalizedString(
                "CALL_QUALITY_SURVEY_ISSUE_VIDEO_LOW_RESOLUTION",
                comment: "Label for low resolution video issue option in call quality survey",
            )
        case .callDropped:
            OWSLocalizedString(
                "CALL_QUALITY_SURVEY_ISSUE_CALL_DROPPED",
                comment: "Label for call dropped issue option in call quality survey",
            )
        case .other:
            OWSLocalizedString(
                "CALL_QUALITY_SURVEY_ISSUE_OTHER",
                comment: "Label for custom issue option in call quality survey",
            )
        }
    }

    var image: ImageResource {
        switch self {
        case .audio, .audioStuttering, .audioLocalEcho, .audioRemoteEcho, .audioDrop:
            .speaker
        case .video, .videoNoCamera, .videoLowQuality, .videoLowResolution:
            .video
        case .callDropped:
            .xCircle
        case .other:
            .errorCircle
        }
    }
}

// MARK: - CallQualitySurveyCustomIssueViewController.Delegate

extension CallQualitySurveyIssuesViewController: CallQualitySurveyCustomIssueViewController.Delegate {
    func didEnterCustomIssue(_ issue: String) {
        self.customIssue = issue
    }
}

// MARK: - UICollectionViewDelegate

extension CallQualitySurveyIssuesViewController: UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        guard let item = dataSource?.itemIdentifier(for: indexPath) else { return }
        self.selectedItems.insert(item)
        self.updateSnapshot()
        self.updateViewState()
    }

    func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) {
        guard let item = dataSource?.itemIdentifier(for: indexPath) else { return }
        self.selectedItems.remove(item)
        self.updateSnapshot()
        self.updateViewState()
    }
}

// MARK: - CapsuleCell

private final class CapsuleCell: UICollectionViewCell {

    private let hStack = UIStackView()
    private let iconView = UIImageView()
    private let titleLabel = UILabel()

    private var icon: ImageResource?

    override var isHighlighted: Bool {
        didSet { updateAppearance() }
    }

    override var isSelected: Bool {
        didSet { updateAppearance() }
    }

    override init(frame: CGRect) {
        super.init(frame: frame)

        hStack.axis = .horizontal
        hStack.alignment = .center
        hStack.spacing = 6
        hStack.isLayoutMarginsRelativeArrangement = true
        hStack.layoutMargins = .init(hMargin: 10, vMargin: 6)

        iconView.contentMode = .scaleAspectFit
        iconView.autoSetDimensions(to: .square(20))

        titleLabel.font = .dynamicTypeSubheadline

        contentView.addSubview(hStack)
        hStack.autoPinEdgesToSuperviewEdges()
        hStack.addArrangedSubviews([iconView, titleLabel])

        updateAppearance()
    }

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

    func configure(title: String, image: ImageResource) {
        titleLabel.text = title
        icon = image
        updateAppearance()
    }

    private func updateAppearance() {
        if isSelected {
            iconView.image = UIImage(resource: .check20)
        } else {
            iconView.image = self.icon.map(UIImage.init(resource:))
        }

        if isHighlighted {
            contentView.backgroundColor = .tertiarySystemFill
            titleLabel.textColor = .Signal.label
            iconView.tintColor = .Signal.label
        } else if isSelected {
            contentView.backgroundColor = .Signal.accent
            titleLabel.textColor = .white
            iconView.tintColor = .white
        } else {
            contentView.backgroundColor = .Signal.secondaryGroupedBackground
            titleLabel.textColor = .Signal.label
            iconView.tintColor = .Signal.label
        }
    }

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

// MARK: - CenteredFlowLayout

private final class CenteredFlowLayout: UICollectionViewFlowLayout {
    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        guard
            let attributes = super.layoutAttributesForElements(in: rect)?
                .map({ $0.copy() as! UICollectionViewLayoutAttributes }),
            let collectionView
        else {
            return super.layoutAttributesForElements(in: rect)
        }

        // UICollectionViewFlowLayout is already figuring out what items can fit
        // on each row in the above super call, but it places them on the outer
        // edges. We need to place the items horizontally-centered.

        let rows = groupByRow(attributes: attributes)

        let availableWidth = collectionView.bounds.width - collectionView.contentInset.totalWidth

        for row in rows {
            let totalItemsWidth = row.map(\.frame.width).reduce(0, +)
            let totalSpacing = CGFloat(max(row.count - 1, 0)) * minimumInteritemSpacing
            let rowWidth = totalItemsWidth + totalSpacing

            let inset = max((availableWidth - rowWidth) / 2, 0)

            var x = inset
            for attribute in row {
                attribute.frame.x = x
                x += attribute.frame.width + minimumInteritemSpacing
            }
        }

        return attributes
    }

    private func groupByRow(attributes: [UICollectionViewLayoutAttributes]) -> [[UICollectionViewLayoutAttributes]] {
        // Sort by y then x
        let sorted = attributes
            .filter { $0.representedElementCategory == .cell }
            .sorted {
                if abs($0.frame.minY - $1.frame.minY) > 1 {
                    return $0.frame.minY < $1.frame.minY
                } else {
                    return $0.frame.minX < $1.frame.minX
                }
            }

        var rows: [[UICollectionViewLayoutAttributes]] = []
        var currentRow: [UICollectionViewLayoutAttributes] = []
        var currentY = sorted.first?.frame.minY ?? -CGFloat.greatestFiniteMagnitude

        for attribute in sorted {
            if attribute.frame.minY.fuzzyEquals(currentY) {
                currentRow.append(attribute)
            } else {
                // New row
                rows.append(currentRow)
                currentRow = [attribute]
                currentY = attribute.frame.minY
            }
        }
        if !currentRow.isEmpty { rows.append(currentRow) }
        return rows
    }

    override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
        return true
    }
}