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

import Foundation
import Lottie
import SignalServiceKit
public import SignalUI

public protocol SendPaymentCompletionDelegate: AnyObject {
    func didSendPayment(success: Bool)
}

// MARK: -

public class SendPaymentCompletionActionSheet: ActionSheetController {

    public typealias PaymentInfo = SendPaymentInfo
    public typealias RequestInfo = SendRequestInfo

    public weak var delegate: SendPaymentCompletionDelegate?

    public enum Mode {
        case payment(paymentInfo: PaymentInfo)
        // TODO: Add support for requests.
        // case request(requestInfo: RequestInfo)

        var paymentInfo: PaymentInfo? {
            switch self {
            case .payment(let paymentInfo):
                return paymentInfo
            }
        }
    }

    private let mode: Mode

    private enum Step {
        case confirmPay(paymentInfo: PaymentInfo)
        case progressPay(paymentInfo: PaymentInfo)
        case successPay(paymentInfo: PaymentInfo)
        case failurePay(paymentInfo: PaymentInfo, error: Error)
    }

    private var currentStep: Step {
        didSet {
            if self.isViewLoaded {
                updateContentsForMode()
            }
        }
    }

    private let outerStack = UIStackView()

    private let innerStack = UIStackView()

    private let headerStack = UIStackView()

    private let balanceLabel = SendPaymentHelper.buildBottomLabel()

    private var helper: SendPaymentHelper?

    private var currentCurrencyConversion: CurrencyConversionInfo? { helper?.currentCurrencyConversion }

    public init(mode: Mode, delegate: SendPaymentCompletionDelegate) {
        self.mode = mode
        self.delegate = delegate

        // TODO: Add support for requests.
        switch mode {
        case .payment(let paymentInfo):
            currentStep = .confirmPay(paymentInfo: paymentInfo)
        }

        super.init()

        helper = SendPaymentHelper(delegate: self)
    }

    public func present(fromViewController: UIViewController) {
        self.customHeader = outerStack
        self.isCancelable = true
        fromViewController.presentFormSheet(self, animated: true)
    }

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

        createSubviews()

        // Try to optimistically prepare a payment before
        // user approves it to reduce perceived latency
        // when sending outgoing payments.
        if let paymentInfo = mode.paymentInfo {
            tryToPreparePayment(paymentInfo: paymentInfo)
        } else {
            owsFailDebug("Missing paymentInfo.")
        }
    }

    override public func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        updateContentsForMode()
    }

    override public func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)

        // For now, the design only allows for portrait layout on non-iPads
        if !UIDevice.current.isIPad, view.window?.windowScene?.interfaceOrientation != .portrait {
            UIDevice.current.ows_setOrientation(.portrait)
        }
    }

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

        updateContentsForMode()
    }

    override public var supportedInterfaceOrientations: UIInterfaceOrientationMask {
        UIDevice.current.isIPad ? .all : .portrait
    }

    private func createSubviews() {

        outerStack.axis = .vertical
        outerStack.alignment = .fill

        innerStack.axis = .vertical
        innerStack.alignment = .fill
        innerStack.layoutMargins = UIEdgeInsets(top: 32, leading: 20, bottom: 22, trailing: 20)
        innerStack.isLayoutMarginsRelativeArrangement = true

        headerStack.axis = .horizontal
        headerStack.alignment = .center
        headerStack.distribution = .equalSpacing
        headerStack.layoutMargins = UIEdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16)
        headerStack.isLayoutMarginsRelativeArrangement = true

        outerStack.addArrangedSubview(headerStack)
        outerStack.addArrangedSubview(innerStack)
    }

    private func updateContentsForMode() {
        switch currentStep {
        case .confirmPay(let paymentInfo):
            updateContentsForConfirmPay(paymentInfo: paymentInfo)
        case .progressPay(let paymentInfo):
            updateContentsForProgressPay(paymentInfo: paymentInfo)
        case .successPay(let paymentInfo):
            updateContentsForSuccessPay(paymentInfo: paymentInfo)
        case .failurePay(let paymentInfo, let error):
            updateContentsForFailurePay(paymentInfo: paymentInfo, error: error)
        }
    }

    private func setContents(_ subviews: [UIView]) {
        AssertIsOnMainThread()

        innerStack.removeAllSubviews()
        for subview in subviews {
            innerStack.addArrangedSubview(subview)
        }
    }

    private func updateHeader(canCancel: Bool) {
        AssertIsOnMainThread()

        headerStack.removeAllSubviews()

        let cancelLabel = UILabel()
        cancelLabel.text = CommonStrings.cancelButton
        cancelLabel.font = UIFont.dynamicTypeBodyClamped
        if canCancel {
            cancelLabel.textColor = Theme.primaryTextColor
            cancelLabel.isUserInteractionEnabled = true
            cancelLabel.addGestureRecognizer(UITapGestureRecognizer(
                target: self,
                action: #selector(didTapCancel),
            ))
        } else {
            cancelLabel.textColor = Theme.secondaryTextAndIconColor
        }
        cancelLabel.setCompressionResistanceHigh()
        cancelLabel.setContentHuggingHigh()

        let titleLabel = UILabel()
        titleLabel.text = OWSLocalizedString(
            "PAYMENTS_NEW_PAYMENT_CONFIRM_PAYMENT_TITLE",
            comment: "Title for the 'confirm payment' ui in the 'send payment' UI.",
        )
        titleLabel.font = UIFont.dynamicTypeHeadlineClamped
        titleLabel.textColor = Theme.primaryTextColor
        titleLabel.textAlignment = .center
        titleLabel.lineBreakMode = .byTruncatingTail

        let spacer = UIView.container()
        spacer.setCompressionResistanceHigh()
        spacer.setContentHuggingHigh()

        headerStack.addArrangedSubview(cancelLabel)
        headerStack.addArrangedSubview(titleLabel)
        headerStack.addArrangedSubview(spacer)

        // We use the spacer to balance the layout.
        spacer.autoMatch(.width, to: .width, of: cancelLabel)
    }

    private func updateContentsForConfirmPay(paymentInfo: PaymentInfo) {
        AssertIsOnMainThread()

        updateHeader(canCancel: true)

        updateBalanceLabel()

        setContents([
            buildConfirmPaymentRows(paymentInfo: paymentInfo),
            UIView.spacer(withHeight: 32),
            buildConfirmPaymentButtons(),
            UIView.spacer(withHeight: vSpacingAboveBalance),
            balanceLabel,
        ])
    }

    private func updateContentsForProgressPay(paymentInfo: PaymentInfo) {
        AssertIsOnMainThread()

        updateHeader(canCancel: false)

        let animationName = (
            Theme.isDarkThemeEnabled
                ? "payments_spinner_dark"
                : "payments_spinner",
        )
        let animationView = LottieAnimationView(name: animationName)
        animationView.backgroundBehavior = .pauseAndRestore
        animationView.loopMode = .loop
        animationView.contentMode = .scaleAspectFit
        animationView.play()
        animationView.autoSetDimensions(to: .square(48))

        // To void layout jitter, we use a label
        // that occupies exactly the same height.
        let bottomLabel = buildBottomLabel()
        bottomLabel.text = OWSLocalizedString(
            "PAYMENTS_NEW_PAYMENT_PROCESSING",
            comment: "Indicator that a new payment is being processed in the 'send payment' UI.",
        )

        setContents([
            buildConfirmPaymentRows(paymentInfo: paymentInfo),
            UIView.spacer(withHeight: 32),
            // To void layout jitter, this view replaces the "bottom button"
            // in the layout, exactly matching its height.
            wrapBottomControl(animationView),
            UIView.spacer(withHeight: vSpacingAboveBalance),
            bottomLabel,
        ])
    }

    private func updateContentsForSuccessPay(paymentInfo: PaymentInfo) {
        AssertIsOnMainThread()

        updateHeader(canCancel: false)

        let animationView = LottieAnimationView(name: "payments_spinner_success")
        animationView.backgroundBehavior = .pauseAndRestore
        animationView.loopMode = .playOnce
        animationView.contentMode = .scaleAspectFit
        animationView.play()
        animationView.autoSetDimensions(to: .square(48))

        // To void layout jitter, we use a label
        // that occupies exactly the same height.
        let bottomLabel = buildBottomLabel()
        bottomLabel.text = CommonStrings.doneButton

        setContents([
            buildConfirmPaymentRows(paymentInfo: paymentInfo),
            UIView.spacer(withHeight: 32),
            // To void layout jitter, this view replaces the "bottom button"
            // in the layout, exactly matching its height.
            wrapBottomControl(animationView),
            UIView.spacer(withHeight: vSpacingAboveBalance),
            bottomLabel,
        ])
    }

    private func wrapBottomControl(_ bottomControl: UIView) -> UIView {
        let bottomStack = UIStackView(arrangedSubviews: [bottomControl])
        bottomStack.axis = .vertical
        bottomStack.alignment = .center
        bottomStack.distribution = .equalCentering
        // To void layout jitter, this view replaces the "bottom button"
        // in the layout, exactly matching its height.
        bottomStack.autoSetDimension(.height, toSize: bottomControlHeight)
        return bottomStack
    }

    private func updateContentsForFailurePay(paymentInfo: PaymentInfo, error: Error) {
        AssertIsOnMainThread()

        updateHeader(canCancel: false)

        let animationView = LottieAnimationView(name: "payments_spinner_fail")
        animationView.backgroundBehavior = .pauseAndRestore
        animationView.loopMode = .playOnce
        animationView.contentMode = .scaleAspectFit
        animationView.play()
        animationView.autoSetDimensions(to: .square(48))

        // To void layout jitter, we use an empty placeholder label
        // that occupies the exact same height
        let bottomLabel = buildBottomLabel()
        bottomLabel.text = Self.formatPaymentFailure(error, withErrorPrefix: true)

        setContents([
            buildConfirmPaymentRows(paymentInfo: paymentInfo),
            UIView.spacer(withHeight: 32),
            // To void layout jitter, this view replaces the "bottom button"
            // in the layout, exactly matching its height.
            wrapBottomControl(animationView),
            UIView.spacer(withHeight: vSpacingAboveBalance),
            bottomLabel,
        ])
    }

    private func buildConfirmPaymentRows(paymentInfo: PaymentInfo) -> UIView {

        var topGroup = [UIView]()
        var bottomGroup = [UIView]()

        @discardableResult
        func addRow(
            to group: inout [UIView],
            titleView: UILabel,
            valueView: UILabel,
            titleIconView: UIView? = nil,
            addSeparator: Bool = false,
        ) -> UIView {

            valueView.setCompressionResistanceHorizontalHigh()
            valueView.setContentHuggingHorizontalHigh()

            let subviews: [UIView]
            if let titleIconView {
                subviews = [titleView, titleIconView, UIView.hStretchingSpacer(), valueView]
            } else {
                subviews = [titleView, valueView]
            }

            let row = UIStackView(arrangedSubviews: subviews)
            row.axis = .horizontal
            row.alignment = .center
            row.spacing = 8
            row.backgroundColor = Theme.tableCell2BackgroundColor

            let margin: CGFloat = 18
            row.translatesAutoresizingMaskIntoConstraints = false
            row.isLayoutMarginsRelativeArrangement = true
            row.directionalLayoutMargins = NSDirectionalEdgeInsets(
                top: margin,
                leading: margin,
                bottom: margin,
                trailing: margin,
            )

            group.append(row)
            return row
        }

        @discardableResult
        func addRow(
            to group: inout [UIView],
            title: String,
            value: String,
            titleIconView: UIView? = nil,
            isTotal: Bool = false,
            addSeparator: Bool = false,
        ) -> UIView {

            let titleLabel = UILabel()
            titleLabel.text = title
            titleLabel.font = .dynamicTypeBodyClamped
            titleLabel.textColor = Theme.primaryTextColor
            titleLabel.lineBreakMode = .byTruncatingTail

            let valueLabel = UILabel()
            valueLabel.text = value
            if isTotal {
                valueLabel.font = .dynamicTypeTitle2Clamped
                valueLabel.textColor = Theme.primaryTextColor
            } else {
                valueLabel.font = .dynamicTypeBodyClamped
                valueLabel.textColor = Theme.secondaryTextAndIconColor
            }

            return addRow(
                to: &group,
                titleView: titleLabel,
                valueView: valueLabel,
                titleIconView: titleIconView,
                addSeparator: addSeparator,
            )
        }

        let recipientDescription = recipientDescriptionWithSneakyTransaction(paymentInfo: paymentInfo)
        addRow(
            to: &topGroup,
            title: recipientDescription,
            value: formatMobileCoinAmount(paymentInfo.paymentAmount),
            addSeparator: true,
        )

        if let currencyConversion = paymentInfo.currencyConversion {
            if
                let fiatAmountString = PaymentsFormat.formatAsFiatCurrency(
                    paymentAmount: paymentInfo.paymentAmount,
                    currencyConversionInfo: currencyConversion,
                )
            {
                let fiatFormat = OWSLocalizedString(
                    "PAYMENTS_NEW_PAYMENT_FIAT_CONVERSION_FORMAT",
                    comment: "Format for the 'fiat currency conversion estimate' indicator. Embeds {{ the fiat currency code }}.",
                )

                let currencyConversionInfoView = UIImageView.withTemplateImageName("info-compact", tintColor: Theme.secondaryTextAndIconColor)
                currencyConversionInfoView.autoSetDimensions(to: .square(16))
                currencyConversionInfoView.setCompressionResistanceHigh()

                let row = addRow(
                    to: &topGroup,
                    title: String.nonPluralLocalizedStringWithFormat(fiatFormat, currencyConversion.currencyCode),
                    value: fiatAmountString,
                    titleIconView: currencyConversionInfoView,
                    addSeparator: true,
                )

                row.isUserInteractionEnabled = true
                row.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTapCurrencyConversionInfo)))
            } else {
                owsFailDebug("Could not convert to fiat.")
            }
        }

        addRow(
            to: &topGroup,
            title: OWSLocalizedString(
                "PAYMENTS_NEW_PAYMENT_ESTIMATED_FEE",
                comment: "Label for the 'payment estimated fee' indicator.",
            ),
            value: formatMobileCoinAmount(paymentInfo.estimatedFeeAmount),
            addSeparator: false,
        )

        let totalAmount = paymentInfo.paymentAmount.plus(paymentInfo.estimatedFeeAmount)
        addRow(
            to: &bottomGroup,
            title: OWSLocalizedString(
                "PAYMENTS_NEW_PAYMENT_PAYMENT_TOTAL",
                comment: "Label for the 'total payment amount' indicator.",
            ),
            value: formatMobileCoinAmount(totalAmount),
            isTotal: true,
        )

        let groups: [UIStackView] = [topGroup, bottomGroup].map { subviews in
            UIStackView.makeGroupedStyle(views: subviews)
        }

        let stack = UIStackView(arrangedSubviews: groups)
        stack.axis = .vertical
        stack.alignment = .fill
        stack.spacing = 24

        return stack
    }

    private func recipientDescriptionWithSneakyTransaction(paymentInfo: PaymentInfo) -> String {
        guard let recipient = paymentInfo.recipient as? SendPaymentRecipientImpl else {
            owsFailDebug("Invalid recipient.")
            return ""
        }
        let otherUserName: String
        switch recipient {
        case .address(let recipientAddress):
            otherUserName = SSKEnvironment.shared.databaseStorageRef.read { transaction in
                SSKEnvironment.shared.contactManagerRef.displayName(for: recipientAddress, tx: transaction).resolvedValue()
            }
        case .publicAddress(let recipientPublicAddress):
            otherUserName = PaymentsImpl.formatAsBase58(publicAddress: recipientPublicAddress)
        }
        let userFormat = OWSLocalizedString(
            "PAYMENTS_NEW_PAYMENT_RECIPIENT_AMOUNT_FORMAT",
            comment: "Format for the 'payment recipient amount' indicator. Embeds {{ the name of the recipient of the payment }}.",
        )
        return String.nonPluralLocalizedStringWithFormat(userFormat, otherUserName)
    }

    public static func formatPaymentFailure(_ error: Error, withErrorPrefix: Bool) -> String {
        let errorDescription: String = {
            switch error {
            case let paymentsError as PaymentsError:
                switch paymentsError {
                case .insufficientFunds:
                    if let paymentBalance = SUIEnvironment.shared.paymentsSwiftRef.currentPaymentBalance {
                        let formattedBalance = PaymentsFormat.format(
                            paymentAmount: paymentBalance.amount,
                            isShortForm: false,
                        )
                        let format = OWSLocalizedString(
                            "PAYMENTS_NEW_PAYMENT_ERROR_INSUFFICIENT_FUNDS_FORMAT",
                            comment: "Indicates that a payment failed due to insufficient funds. Embeds {{ current balance }}.",
                        )
                        return String.nonPluralLocalizedStringWithFormat(format, formattedBalance)
                    } else {
                        return OWSLocalizedString(
                            "PAYMENTS_NEW_PAYMENT_ERROR_INSUFFICIENT_FUNDS",
                            comment: "Indicates that a payment failed due to insufficient funds.",
                        )
                    }
                case .outgoingVerificationTakingTooLong:
                    return OWSLocalizedString(
                        "PAYMENTS_NEW_PAYMENT_ERROR_OUTGOING_VERIFICATION_TAKING_TOO_LONG",
                        comment: "Indicates that an outgoing payment could not be verified in a timely way.",
                    )
                case .timeout,
                     .connectionFailure,
                     .serverRateLimited,
                     .authorizationFailure,
                     .invalidServerResponse,
                     .attestationVerificationFailed:
                    return OWSLocalizedString(
                        "PAYMENTS_NEW_PAYMENT_ERROR_CONNECTIVITY_FAILURE",
                        comment: "Indicates that a payment failed due to a connectivity failure.",
                    )
                case .outdatedClient:
                    return OWSLocalizedString(
                        "PAYMENTS_NEW_PAYMENT_ERROR_OUTDATED_CLIENT",
                        comment: "Indicates that a payment failed due to an outdated client.",
                    )
                case .userHasNoPublicAddress,
                     .invalidCurrency,
                     .invalidAmount,
                     .invalidFee,
                     .invalidModel,
                     .invalidInput:
                    return OWSLocalizedString(
                        "PAYMENTS_NEW_PAYMENT_ERROR_INVALID_TRANSACTION",
                        comment: "Indicates that a payment failed due to being invalid.",
                    )
                default:
                    return OWSLocalizedString(
                        "PAYMENTS_NEW_PAYMENT_ERROR_UNKNOWN",
                        comment: "Indicates that an unknown error occurred while sending a payment or payment request.",
                    )
                }
            case let paymentsError as PaymentsUIError:
                switch paymentsError {
                case .paymentsLockFailed:
                    return OWSLocalizedString(
                        "PAYMENTS_NEW_PAYMENT_ERROR_PAYMENTS_LOCK_AUTH_FAILURE",
                        comment: "Indicates that a payment failed because the payments lock failed to authenticate.",
                    )
                case .paymentsLockCancelled:
                    return OWSLocalizedString(
                        "PAYMENTS_NEW_PAYMENT_ERROR_PAYMENTS_LOCK_AUTH_CANCELLED",
                        comment: "Indicates that a payment failed because the payments lock attempt was cancelled.",
                    )
                }
            default:
                return OWSLocalizedString(
                    "PAYMENTS_NEW_PAYMENT_ERROR_UNKNOWN",
                    comment: "Indicates that an unknown error occurred while sending a payment or payment request.",
                )
            }
        }()

        guard withErrorPrefix else {
            return errorDescription
        }
        // We don't use error prefixes for now.
        return errorDescription
    }

    private func buildConfirmPaymentButtons() -> UIView {
        buildBottomButtonStack([
            buildBottomButton(
                title: OWSLocalizedString(
                    "PAYMENTS_NEW_PAYMENT_CONFIRM_PAYMENT_BUTTON",
                    comment: "Label for the 'confirm payment' button.",
                ),
                target: self,
                selector: #selector(didTapConfirmButton),
            ),
        ])
    }

    public func updateBalanceLabel() {
        guard let helper else {
            return
        }
        helper.updateBalanceLabel(balanceLabel)
    }

    private let preparedPaymentTask = AtomicOptional<Task<PreparedPayment, any Error>>(nil, lock: .init())

    private func tryToPreparePayment(paymentInfo: PaymentInfo) {
        let preparePaymentTask = Task {
            // NOTE: We should not pre-prepare a payment if defragmentation
            // is required.
            return try await SUIEnvironment.shared.paymentsSwiftRef.prepareOutgoingPayment(
                recipient: paymentInfo.recipient,
                paymentAmount: paymentInfo.paymentAmount,
                memoMessage: paymentInfo.memoMessage,
                isOutgoingTransfer: paymentInfo.isOutgoingTransfer,
                canDefragment: false,
            )
        }
        preparedPaymentTask.set(preparePaymentTask)
        Task {
            do {
                _ = try await preparePaymentTask.value
                Logger.info("Pre-prepared payment ready.")
            } catch {
                if case PaymentsError.defragmentationRequired = error {
                    Logger.warn("Error: \(error)")
                } else {
                    owsFailDebugUnlessMCNetworkFailure(error)
                }
            }
        }
    }

    private func tryToSendPayment(paymentInfo: PaymentInfo) {
        self.currentStep = .progressPay(paymentInfo: paymentInfo)

        ModalActivityIndicatorViewController.present(fromViewController: self, isInvisible: true, asyncBlock: { modalActivityIndicator in
            do {
                let authOutcome = await SSKEnvironment.shared.owsPaymentsLockRef.tryToUnlock()
                switch authOutcome {
                case .failure(let error):
                    throw PaymentsUIError.paymentsLockFailed(reason: "local authentication failed with error: \(error)")
                case .unexpectedFailure(let error):
                    throw PaymentsUIError.paymentsLockFailed(reason: "local authentication failed with unexpected error: \(error)")
                case .success:
                    break
                case .cancel:
                    throw PaymentsUIError.paymentsLockCancelled(reason: "local authentication cancelled")
                case .disabled:
                    break
                }

                guard let task = self.preparedPaymentTask.get() else {
                    throw OWSAssertionError("Missing preparedPaymentTask.")
                }
                let preparedPayment: PreparedPayment
                do {
                    preparedPayment = try await task.value
                } catch PaymentsError.defragmentationRequired {
                    // NOTE: We will always follow this code path if defragmentation
                    // is required.
                    Logger.info("Defragmentation required.")
                    preparedPayment = try await SUIEnvironment.shared.paymentsSwiftRef.prepareOutgoingPayment(
                        recipient: paymentInfo.recipient,
                        paymentAmount: paymentInfo.paymentAmount,
                        memoMessage: paymentInfo.memoMessage,
                        isOutgoingTransfer: paymentInfo.isOutgoingTransfer,
                        canDefragment: true,
                    )
                }

                let paymentModel = try await SUIEnvironment.shared.paymentsSwiftRef.initiateOutgoingPayment(preparedPayment: preparedPayment)

                // Try to wait (with a timeout) for submission and verification to complete.
                let blockInterval: TimeInterval = .minute
                do {
                    try await withCooperativeTimeout(seconds: blockInterval) {
                        _ = try await SUIEnvironment.shared.paymentsSwiftRef.blockOnOutgoingVerification(paymentModel: paymentModel)
                    }
                } catch is CooperativeTimeoutError {
                    throw PaymentsError.outgoingVerificationTakingTooLong
                } catch let error as PaymentsError where error.isNetworkFailureOrTimeout {
                    Logger.warn("Could not verify outgoing payment: \(error).")
                    // This is fine.
                }

                self.didSucceedPayment(paymentInfo: paymentInfo)
                modalActivityIndicator.dismiss()
            } catch {
                owsFailDebugUnlessMCNetworkFailure(error)
                modalActivityIndicator.dismiss()
                self.didFailPayment(paymentInfo: paymentInfo, error: error)
            }
        })
    }

    private static let autoDismissDelay: TimeInterval = 2.5

    private func didSucceedPayment(paymentInfo: PaymentInfo) {
        self.currentStep = .successPay(paymentInfo: paymentInfo)

        let delegate = self.delegate
        DispatchQueue.main.asyncAfter(deadline: .now() + Self.autoDismissDelay) { [weak self] in
            guard let self else { return }
            self.dismiss(animated: true) {
                delegate?.didSendPayment(success: true)
            }
        }
    }

    private func didFailPayment(paymentInfo: PaymentInfo, error: Error) {
        self.currentStep = .failurePay(paymentInfo: paymentInfo, error: error)

        let delegate = self.delegate
        DispatchQueue.main.asyncAfter(deadline: .now() + Self.autoDismissDelay) { [weak self] in
            guard let self else { return }
            self.dismiss(animated: true) {
                PaymentActionSheets.showBiometryAuthFailedActionSheet { _ in
                    delegate?.didSendPayment(success: false)
                }
            }
        }
    }

    // MARK: - Events

    @objc
    private func didTapCancel() {
        dismiss(animated: true, completion: nil)
    }

    @objc
    private func didTapConfirmButton(_ sender: UIButton) {
        switch currentStep {
        case .confirmPay(let paymentInfo):
            tryToSendPayment(paymentInfo: paymentInfo)
        default:
            owsFailDebug("Invalid step.")
        }
    }

    @objc
    private func didTapCurrencyConversionInfo() {
        PaymentsSettingsViewController.showCurrencyConversionInfoAlert(fromViewController: self)
    }
}

// MARK: -

extension SendPaymentCompletionActionSheet: SendPaymentHelperDelegate {
    public func balanceDidChange() {
        updateBalanceLabel()
    }

    public func currencyConversionDidChange() {}
}

private extension UIStackView {
    static func makeGroupedStyle(views: [UIView]) -> UIStackView {
        // Add separators to all except the last view
        views.enumerated().forEach { offset, value in
            guard offset != views.count - 1 else { return }
            let separator = UIView()
            separator.backgroundColor = UIColor.Signal.opaqueSeparator
            separator.autoSetDimension(.height, toSize: 0.5)
            value.addSubview(separator)

            NSLayoutConstraint.activate([
                separator.leadingAnchor.constraint(equalTo: value.leadingAnchor, constant: CGFloat(16)),
                separator.trailingAnchor.constraint(equalTo: value.trailingAnchor),
                separator.bottomAnchor.constraint(equalTo: value.bottomAnchor),
            ])

        }

        let group = UIStackView(arrangedSubviews: views)
        group.axis = .vertical
        group.alignment = .fill
        group.spacing = 0
        group.layer.cornerRadius = 10
        group.clipsToBounds = true
        return group
    }
}