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

import SignalServiceKit
public import SignalUI

public class BadgeGiftingChooseBadgeViewController: OWSTableViewController2 {
    typealias GiftConfiguration = DonationSubscriptionConfiguration.GiftConfiguration
    typealias PaymentMethodsConfiguration = DonationSubscriptionConfiguration.PaymentMethodsConfiguration

    // MARK: - State management

    enum State {
        case initializing
        case loading
        case loadFailed
        case loaded(
            selectedCurrencyCode: Currency.Code,
            giftConfiguration: GiftConfiguration,
            paymentMethodsConfiguration: PaymentMethodsConfiguration,
        )

        var canContinue: Bool {
            switch self {
            case .initializing, .loading, .loadFailed:
                return false
            case let .loaded(selectedCurrencyCode, giftConfiguration, _):
                let isValid = giftConfiguration.presetAmount[selectedCurrencyCode] != nil
                owsAssertDebug(isValid, "State was loaded but it was invalid")
                return isValid
            }
        }

        func selectCurrencyCode(_ newCurrencyCode: Currency.Code) -> State {
            switch self {
            case .initializing, .loading, .loadFailed:
                assertionFailure("Invalid state; cannot select currency code")
                return self
            case let .loaded(_, giftConfiguration, paymentMethodsConfiguration):
                guard giftConfiguration.presetAmount[newCurrencyCode] != nil else {
                    assertionFailure("Tried to select a currency code that doesn't exist")
                    return self
                }
                return .loaded(
                    selectedCurrencyCode: newCurrencyCode,
                    giftConfiguration: giftConfiguration,
                    paymentMethodsConfiguration: paymentMethodsConfiguration,
                )
            }
        }
    }

    private var state: State = .initializing {
        didSet {
            updateTableContents()
            updateBottomFooter()
        }
    }

    private func loadDataIfNecessary() {
        switch state {
        case .initializing, .loadFailed:
            state = .loading
            Task { @MainActor in
                self.state = await loadData()
            }
        case .loading, .loaded:
            break
        }
    }

    private func loadData() async -> State {
        do {
            Logger.info("[Gifting] Fetching donation configuration...")
            let donationConfiguration = try await DonationSubscriptionManager.fetchDonationConfiguration()
            Logger.info("[Gifting] Populating badge assets...")
            let giftBadge = donationConfiguration.gift.badge
            try await SSKEnvironment.shared.profileManagerRef.badgeStore.populateAssetsOnBadge(giftBadge)
            let defaultCurrencyCode = DonationUtilities.chooseDefaultCurrency(
                preferred: [
                    Locale.current.currencyCode?.uppercased(),
                    "USD",
                    donationConfiguration.gift.presetAmount.keys.first,
                ],
                supported: donationConfiguration.gift.presetAmount.keys,
            )
            guard let defaultCurrencyCode else {
                // This indicates a bug, either in the iOS app or the server.
                owsFailBeta("[Gifting] Successfully loaded data, but a preferred currency could not be found")
                return .loadFailed
            }

            return .loaded(
                selectedCurrencyCode: defaultCurrencyCode,
                giftConfiguration: donationConfiguration.gift,
                paymentMethodsConfiguration: donationConfiguration.paymentMethods,
            )
        } catch {
            Logger.warn("\(error)")
            return .loadFailed
        }
    }

    private func didTapNext() {
        switch state {
        case .initializing, .loading, .loadFailed:
            owsFailBeta("Tapped next when the state wasn't loaded")
        case let .loaded(selectedCurrencyCode, giftConfiguration, paymentMethodsConfiguration):
            guard let price = giftConfiguration.presetAmount[selectedCurrencyCode] else {
                owsFailBeta("State is invalid. We selected a currency code that we don't have a price for")
                return
            }
            let vc = BadgeGiftingChooseRecipientViewController(
                badge: giftConfiguration.badge,
                price: price,
                paymentMethodsConfiguration: paymentMethodsConfiguration,
            )
            self.navigationController?.pushViewController(vc, animated: true)
        }
    }

    // MARK: - Callbacks

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

        let isPresentedStandalone = navigationController?.viewControllers.first == self
        if isPresentedStandalone {
            navigationItem.leftBarButtonItem = .cancelButton(dismissingFrom: self)
        }

        loadDataIfNecessary()
        updateTableContents()
        updateBottomFooter()
    }

    // MARK: - Table contents

    private func updateTableContents() {
        self.contents = OWSTableContents(sections: getTableSections())
    }

    private func getTableSections() -> [OWSTableSection] {
        let introSection: OWSTableSection = {
            let section = OWSTableSection()
            section.hasBackground = false
            section.customHeaderView = {
                let imageView = UIImageView(image: UIImage(named: "badge-gifting-promo-image"))
                imageView.contentMode = .scaleAspectFit
                let imageViewContainer = UIView.container()
                imageViewContainer.addSubview(imageView)
                imageView.translatesAutoresizingMaskIntoConstraints = false
                imageViewContainer.addConstraints([
                    imageView.topAnchor.constraint(equalTo: imageViewContainer.topAnchor),
                    imageView.leadingAnchor.constraint(greaterThanOrEqualTo: imageViewContainer.leadingAnchor),
                    imageView.centerXAnchor.constraint(equalTo: imageViewContainer.centerXAnchor),
                    imageView.bottomAnchor.constraint(equalTo: imageViewContainer.bottomAnchor),
                ])

                let titleLabel = UILabel.title2Label(text: OWSLocalizedString(
                    "DONATION_ON_BEHALF_OF_A_FRIEND_CHOOSE_BADGE_TITLE",
                    comment: "Users can donate on behalf of a friend, and the friend will receive a badge. This is the title on the screen where users choose the badge their friend will receive.",
                ))
                titleLabel.setCompressionResistanceVerticalHigh()
                let subtitleLabel = UILabel.explanationTextLabel(text: OWSLocalizedString(
                    "DONATION_ON_BEHALF_OF_A_FRIEND_CHOOSE_BADGE_DESCRIPTION",
                    comment: "Users can donate on behalf of a friend, and the friend will receive a badge. This is a short paragraph on the screen where users choose the badge their friend will receive.",
                ))
                subtitleLabel.setCompressionResistanceVerticalHigh()

                let introStack = UIStackView(arrangedSubviews: [
                    imageViewContainer,
                    titleLabel,
                    subtitleLabel,
                ])
                introStack.axis = .vertical
                introStack.spacing = 12

                return introStack
            }()
            return section
        }()

        var result: [OWSTableSection] = [introSection]

        switch state {
        case .initializing, .loading:
            result += loadingSections()
        case .loadFailed:
            result += loadFailedSections()
        case let .loaded(selectedCurrencyCode, giftConfiguration, _):
            result += loadedSections(
                selectedCurrencyCode: selectedCurrencyCode,
                badge: giftConfiguration.badge,
                pricesByCurrencyCode: giftConfiguration.presetAmount,
            )
        }

        return result
    }

    private func loadingSections() -> [OWSTableSection] {
        let section = OWSTableSection()
        section.add(AppSettingsViewsUtil.loadingTableItem())
        section.hasBackground = false
        return [section]
    }

    private func loadFailedSections() -> [OWSTableSection] {
        let section = OWSTableSection()
        section.add(.init(customCellBlock: { [weak self] in
            guard let self else { return UITableViewCell() }
            let cell = AppSettingsViewsUtil.newCell()

            let textLabel = UILabel()
            textLabel.text = OWSLocalizedString(
                "DONATION_VIEW_LOAD_FAILED",
                comment: "Text that's shown when the donation view fails to load data, probably due to network failure",
            )
            textLabel.font = .dynamicTypeSubheadline
            textLabel.textAlignment = .center
            textLabel.textColor = .Signal.label
            textLabel.numberOfLines = 0

            let retryButton = UIButton(
                configuration: .mediumSecondary(title: CommonStrings.retryButton),
                primaryAction: UIAction { [weak self] _ in
                    self?.loadDataIfNecessary()
                },
            )
            // Container is needed to center button instead of stretching it horizontally.
            let retryButtonContainer = retryButton.enclosedInVerticalStackView(isFullWidthButton: false)
            retryButtonContainer.directionalLayoutMargins.bottom = 0

            let stackView = UIStackView(arrangedSubviews: [
                textLabel,
                retryButtonContainer,
            ])
            stackView.axis = .vertical
            stackView.spacing = 16
            cell.contentView.addSubview(stackView)
            stackView.autoPinEdgesToSuperviewMargins()

            return cell
        }))
        return [section]
    }

    private func loadedSections(
        selectedCurrencyCode: Currency.Code,
        badge: ProfileBadge,
        pricesByCurrencyCode: [Currency.Code: FiatMoney],
    ) -> [OWSTableSection] {
        let currencyButtonSection = OWSTableSection()
        currencyButtonSection.hasBackground = false
        currencyButtonSection.add(.init(customCellBlock: { [weak self] in
            guard let self else { return UITableViewCell() }
            let cell = AppSettingsViewsUtil.newCell()

            let currencyPickerButton = DonationCurrencyPickerButton(
                currentCurrencyCode: selectedCurrencyCode,
                hasLabel: true,
            ) { [weak self] in
                guard let self else { return }
                let vc = CurrencyPickerViewController(
                    dataSource: StripeCurrencyPickerDataSource(
                        currentCurrencyCode: selectedCurrencyCode,
                        supportedCurrencyCodes: Set(pricesByCurrencyCode.keys),
                    ),
                ) { [weak self] currencyCode in
                    guard let self else { return }
                    self.state = self.state.selectCurrencyCode(currencyCode)
                }
                self.navigationController?.pushViewController(vc, animated: true)
            }

            cell.contentView.addSubview(currencyPickerButton)
            currencyPickerButton.translatesAutoresizingMaskIntoConstraints = false
            cell.addConstraints([
                // Cell might be a little too tall - center button vertically.
                currencyPickerButton.topAnchor.constraint(greaterThanOrEqualTo: cell.contentView.topAnchor),
                currencyPickerButton.leadingAnchor.constraint(greaterThanOrEqualTo: cell.contentView.leadingAnchor),
                currencyPickerButton.centerXAnchor.constraint(equalTo: cell.contentView.centerXAnchor),
                currencyPickerButton.centerYAnchor.constraint(equalTo: cell.contentView.centerYAnchor),
            ])

            return cell
        }))

        let badgeSection = OWSTableSection()
        badgeSection.add(.init(customCellBlock: {
            let cell = AppSettingsViewsUtil.newCell()

            guard let price = pricesByCurrencyCode[selectedCurrencyCode] else {
                owsFailBeta("State is invalid. We selected a currency code that we don't have a price for.")
                return cell
            }
            let badgeCellView = GiftBadgeCellView(badge: badge, price: price)
            cell.contentView.addSubview(badgeCellView)
            badgeCellView.autoPinEdgesToSuperviewMargins()

            return cell
        }))

        return [currencyButtonSection, badgeSection]
    }

    // MARK: - Footer contents

    override open var bottomFooter: UIView? {
        get { bottomFooterContainer }
        set {}
    }

    private lazy var nextButton = UIButton(
        configuration: .largePrimary(title: CommonStrings.nextButton),
        primaryAction: UIAction { [weak self] _ in
            self?.didTapNext()
        },
    )

    private lazy var bottomFooterContainer: UIView = {
        let stackView = UIStackView.verticalButtonStack(buttons: [nextButton])
        let containerView = UIView()
        containerView.preservesSuperviewLayoutMargins = true
        containerView.addSubview(stackView)
        stackView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            stackView.topAnchor.constraint(equalTo: containerView.topAnchor),
            stackView.leadingAnchor.constraint(equalTo: containerView.layoutMarginsGuide.leadingAnchor),
            stackView.trailingAnchor.constraint(equalTo: containerView.layoutMarginsGuide.trailingAnchor),
            stackView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
        ])
        return containerView
    }()

    private func updateBottomFooter() {
        nextButton.isEnabled = state.canContinue
    }
}