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

import SignalServiceKit
import SignalUI

@MainActor
class BankTransferMandateViewController: OWSTableViewController2 {
    override var preferredNavigationBarStyle: OWSNavigationBarStyle { .solid }
    override var navbarBackgroundColorOverride: UIColor? { self.tableBackgroundColor }

    private enum State {
        case loading
        case loaded(mandateText: String)
        case failed
    }

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

    private var bankTransferType: OWSRequestFactory.StripePaymentMethod.BankTransfer
    private var didAgree: (Stripe.PaymentMethod.Mandate) -> Void

    init(
        bankTransferType: OWSRequestFactory.StripePaymentMethod.BankTransfer,
        didAgree: @escaping (Stripe.PaymentMethod.Mandate) -> Void,
    ) {
        self.bankTransferType = bankTransferType
        self.didAgree = didAgree
        super.init()
    }

    // MARK: Table setup

    override func viewDidLoad() {
        super.viewDidLoad()

        navigationItem.leftBarButtonItem = .cancelButton(dismissingFrom: self)

        updateTableContents()
        updateBottomFooter()

        Task {
            await loadMandate()
        }
    }

    private func updateTableContents() {
        let section = OWSTableSection(header: self.makeHeaderView)

        switch state {
        case .loading, .failed:
            // TODO: Show a different state if the loading failed
            section.customFooterView = Self.makeLoadingview()
        case .loaded(let mandateText):
            section.add(Self.makeBodyLabelCell(text: mandateText))
        }

        section.shouldDisableCellSelection = true

        self.contents = OWSTableContents(sections: [section])
    }

    private static let bankIconSize: CGFloat = 40
    private static let bankIconCircleSize: CGFloat = 64
    /// This URL itself is not used. The action is overridden in the text view delegate function.
    private static let learnMoreURL = URL(string: "https://support.signal.org/")!

    private func makeHeaderView() -> UIView {
        let bankIconView = UIImageView(image: UIImage(systemName: "building.columns"))
        bankIconView.contentMode = .scaleAspectFit
        bankIconView.tintColor = .Signal.label

        let bankIconCircle = CircleView()
        bankIconCircle.backgroundColor = .Signal.secondaryGroupedBackground
        bankIconCircle.addSubview(bankIconView)

        let bankIconContainer = UIView.container()
        bankIconContainer.addSubview(bankIconCircle)

        bankIconView.translatesAutoresizingMaskIntoConstraints = false
        bankIconCircle.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            bankIconView.widthAnchor.constraint(equalToConstant: Self.bankIconSize),
            bankIconView.heightAnchor.constraint(equalToConstant: Self.bankIconSize),

            bankIconView.centerXAnchor.constraint(equalTo: bankIconCircle.centerXAnchor),
            bankIconView.centerYAnchor.constraint(equalTo: bankIconCircle.centerYAnchor),

            bankIconCircle.widthAnchor.constraint(equalToConstant: Self.bankIconCircleSize),
            bankIconCircle.heightAnchor.constraint(equalToConstant: Self.bankIconCircleSize),

            bankIconCircle.topAnchor.constraint(equalTo: bankIconContainer.topAnchor),
            bankIconCircle.leadingAnchor.constraint(greaterThanOrEqualTo: bankIconContainer.leadingAnchor),
            bankIconCircle.centerXAnchor.constraint(equalTo: bankIconContainer.centerXAnchor),
            bankIconCircle.bottomAnchor.constraint(equalTo: bankIconContainer.bottomAnchor),
        ])

        let titleLabel = UILabel.title1Label(text: OWSLocalizedString(
            "BANK_MANDATE_TITLE",
            comment: "Users can donate to Signal with a bank account. We are required to show them a mandate with information about bank transfers. This is the title above that mandate.",
        ))

        let subtitleTextView = LinkingTextView()
        subtitleTextView.delegate = self
        subtitleTextView.attributedText = .composed(of: [
            OWSLocalizedString(
                "BANK_MANDATE_SUBTITLE",
                comment: "Users can donate to Signal with a bank account. We are required to show them a mandate with information about bank transfers. This is a subtitle about the payment processor Stripe above that mandate.",
            ),
            " ",
            CommonStrings.learnMore.styled(with: .link(Self.learnMoreURL)),
        ]).styled(with: .color(.Signal.secondaryLabel), .font(.dynamicTypeSubheadline))
        subtitleTextView.textAlignment = .center

        let stackView = UIStackView(arrangedSubviews: [
            bankIconContainer,
            titleLabel,
            subtitleTextView,
        ])
        stackView.axis = .vertical
        stackView.alignment = .fill
        stackView.spacing = 8
        stackView.setCustomSpacing(12, after: bankIconContainer)

        // Use container to provide some vertical spacing below.
        let container = UIView()
        container.preservesSuperviewLayoutMargins = true
        container.directionalLayoutMargins.bottom = 24
        container.addSubview(stackView)
        stackView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            stackView.topAnchor.constraint(equalTo: container.topAnchor),
            stackView.leadingAnchor.constraint(equalTo: container.layoutMarginsGuide.leadingAnchor),
            stackView.trailingAnchor.constraint(equalTo: container.layoutMarginsGuide.trailingAnchor),
            stackView.bottomAnchor.constraint(equalTo: container.layoutMarginsGuide.bottomAnchor),
        ])

        return container
    }

    private static func makeLoadingview() -> UIActivityIndicatorView {
        let result = UIActivityIndicatorView(style: .medium)
        result.startAnimating()
        return result
    }

    private static func makeBodyLabelCell(text: String) -> OWSTableItem {
        .init(customCellBlock: {
            let cell = OWSTableItem.newCell()

            let label = UILabel()
            label.text = text
            label.font = .dynamicTypeSubheadline
            label.numberOfLines = 0

            cell.contentView.addSubview(label)
            label.autoPinEdgesToSuperviewMargins()

            return cell
        })
    }

    // MARK: Bottom footer

    /// `true` if `tableView` is within 52 points of the bottom, otherwise `false`.
    private var isScrolledCloseToBottom = true {
        didSet {
            if isScrolledCloseToBottom != oldValue {
                updateBottomFooterButtonText()
            }
        }
    }

    private func checkScrollPosition() {
        let tableViewBottom = tableView.contentSize.height - tableView.height
        isScrolledCloseToBottom = tableView.contentOffset.y >= tableViewBottom - 56
    }

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

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

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

    private func updateBottomFooter() {
        switch state {
        case .loaded:
            bottomFooterButton.isEnabled = true
        case .loading, .failed:
            bottomFooterButton.isEnabled = false
        }

        tableView.layoutIfNeeded()
        checkScrollPosition()
        updateBottomFooterButtonText()
    }

    private func updateBottomFooterButtonText() {
        let title: String
        if isScrolledCloseToBottom {
            title = OWSLocalizedString(
                "BANK_MANDATE_AGREE",
                comment: "Users can donate to Signal with a bank account. We are required to show them a mandate with information about bank transfers. This is a label for a button to agree to the mandate.",
            )
        } else {
            title = OWSLocalizedString(
                "BANK_MANDATE_READ_MORE",
                comment: "Users can donate to Signal with a bank account. We are required to show them a mandate with information about bank transfers. This is a label for a button that shows more of the mandate if it is not all visible.",
            )
        }
        bottomFooterButton.configuration?.title = title
    }

    private func didTapBottomFooterButton() {
        if isScrolledCloseToBottom {
            self.didAgree(.accept())
        } else {
            let pageHeight = tableView.bounds.height - tableView.safeAreaInsets.top - tableView.safeAreaInsets.bottom
            let yOffset = min(
                tableView.contentOffset.y + pageHeight,
                tableView.contentSize.height - tableView.bounds.height,
            )
            let newOffset = CGPoint(x: tableView.contentOffset.x, y: yOffset)
            tableView.setContentOffset(newOffset, animated: true)
        }
    }

    // MARK: Actions

    private func loadMandate() async {
        let request = OWSRequestFactory.bankMandateRequest(bankTransferType: self.bankTransferType)
        do {
            let response = try await SSKEnvironment.shared.networkManagerRef.asyncRequest(request)
            guard let parser = response.responseBodyParamParser else {
                throw OWSAssertionError("Missing or invalid JSON")
            }
            let mandateText: String = try parser.required(key: "mandate")
            self.state = .loaded(mandateText: mandateText)
        } catch {
            owsFailDebugUnlessNetworkFailure(error)
            self.state = .failed
        }
    }

    // MARK: - UIScrollViewDelegate

    override func scrollViewDidScroll(_ scrollView: UIScrollView) {
        super.scrollViewDidScroll(scrollView)
        checkScrollPosition()
    }
}

// MARK: - UITextViewDelegate

extension BankTransferMandateViewController: UITextViewDelegate {
    func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool {
        present(DonationPaymentDetailsReadMoreSheetViewController(), animated: true)
        return false
    }
}

// MARK: - Stripe Payment Method

extension Stripe.PaymentMethod.Mandate {
    fileprivate static func accept() -> Self {
        .init(mode: .online(
            userAgent: OWSURLSession.userAgentHeaderValueSignalIos,
            ipAddress: "0.0.0.0",
        ))
    }
}