Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[MBL-1210] Fix logged out and login flow for post campaign checkout #1991

Merged
merged 10 commits into from
Mar 21, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,12 @@ final class PledgePaymentMethodsViewController: UIViewController {
self.tableView.reloadSections([PaymentMethodsTableViewSection.addNewCard.rawValue], with: .none)
}
}

// MARK: - Public functions

func cancelModalPresentation(_ hidden: Bool) {
self.viewModel.inputs.shouldCancelPaymentSheetAppearance(state: hidden)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: This method name isn't totally clear to me on first read. So it means the payment sheet normally would pop up, but now it won't? Appearance makes me assume it's something like a modal.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, or is this more like a dismissal?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this name is more clear than it was before but you're right - when I named it, I didn't know what it did either 😭. You're pretty much correct, though. Basically when you add a new card to the payment sheet, there's a loading indicator for a bit before the sheet pops up. Setting this to false (the only way it's used outside of tests) cancels that pop up (usually because we've presented a different modal instead). I think it might also dismiss the sheet itself if it's already presented by the time this is called, but I'm not sure about that one since that would only happen as a race condition. If you have any ideas for a different name, though, let me know!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm...what about something like dontShowPaymentSheet?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm just going to name it cancelModalPresentation I think. That's clear enough while still being general enough that you don't need to understand the paymentMethodsVC to understand what the goal is.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good to me!

}
}

// MARK: - UITableViewDelegate
Expand All @@ -231,11 +237,3 @@ extension PledgePaymentMethodsViewController: UITableViewDelegate {
self.viewModel.inputs.didSelectRowAtIndexPath(indexPath)
}
}

// MARK: - PaymentSheetAppearanceDelegate

extension PledgePaymentMethodsViewController: PaymentSheetAppearanceDelegate {
func pledgeViewControllerPaymentSheet(_: PledgeViewController, hidden: Bool) {
self.viewModel.inputs.shouldCancelPaymentSheetAppearance(state: hidden)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ final class PledgePaymentMethodsViewControllerTests: TestCase {

self.scheduler.advance(by: .seconds(1))

controller.pledgeViewControllerPaymentSheet(PledgeViewController(), hidden: false)
controller.cancelModalPresentation(false)

self.scheduler.advance(by: .seconds(1))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,6 @@ protocol PledgeViewControllerDelegate: AnyObject {
func pledgeViewControllerDidUpdatePledge(_ viewController: PledgeViewController, message: String)
}

protocol PaymentSheetAppearanceDelegate: AnyObject {
func pledgeViewControllerPaymentSheet(_ viewController: PledgeViewController, hidden: Bool)
}

final class PledgeViewController: UIViewController,
MessageBannerViewControllerPresenting, ProcessingViewPresenting {
// MARK: - Properties
Expand All @@ -34,7 +30,6 @@ final class PledgeViewController: UIViewController,
}()

public weak var delegate: PledgeViewControllerDelegate?
public weak var paymentSheetAppearanceDelegate: PaymentSheetAppearanceDelegate?

private lazy var descriptionSectionSeparator: UIView = {
UIView(frame: .zero)
Expand Down Expand Up @@ -362,7 +357,6 @@ final class PledgeViewController: UIViewController,
.observeForUI()
.observeValues { [weak self] value in
self?.paymentMethodsViewController.configure(with: value)
self?.paymentSheetAppearanceDelegate = self?.paymentMethodsViewController
}

self.viewModel.outputs.goToLoginSignup
Expand Down Expand Up @@ -460,7 +454,7 @@ final class PledgeViewController: UIViewController,
.observeForControllerAction()
.observeValues { [weak self] helpType in
guard let self = self else { return }
self.paymentSheetAppearanceDelegate?.pledgeViewControllerPaymentSheet(self, hidden: true)
self.paymentMethodsViewController.cancelModalPresentation(true)
self.presentHelpWebViewController(with: helpType, presentationStyle: .formSheet)
}

Expand Down Expand Up @@ -601,22 +595,22 @@ extension PledgeViewController: PKPaymentAuthorizationViewControllerDelegate {

extension PledgeViewController: PledgeViewCTAContainerViewDelegate {
func goToLoginSignup() {
self.paymentSheetAppearanceDelegate?.pledgeViewControllerPaymentSheet(self, hidden: true)
self.paymentMethodsViewController.cancelModalPresentation(true)
self.viewModel.inputs.goToLoginSignupTapped()
}

func applePayButtonTapped() {
self.paymentSheetAppearanceDelegate?.pledgeViewControllerPaymentSheet(self, hidden: true)
self.paymentMethodsViewController.cancelModalPresentation(true)
self.viewModel.inputs.applePayButtonTapped()
}

func submitButtonTapped() {
self.paymentSheetAppearanceDelegate?.pledgeViewControllerPaymentSheet(self, hidden: true)
self.paymentMethodsViewController.cancelModalPresentation(true)
self.viewModel.inputs.submitButtonTapped()
}

func termsOfUseTapped(with helpType: HelpType) {
self.paymentSheetAppearanceDelegate?.pledgeViewControllerPaymentSheet(self, hidden: true)
self.paymentMethodsViewController.cancelModalPresentation(true)
self.viewModel.inputs.termsOfUseTapped(with: helpType)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ final class PostCampaignCheckoutViewController: UIViewController {
private lazy var pledgeCTAContainerView: PledgeViewCTAContainerView = {
PledgeViewCTAContainerView(frame: .zero)
|> \.translatesAutoresizingMaskIntoConstraints .~ false
// TODO: Add self as delegate and add support for delegate methods.
|> \.delegate .~ self
}()

private lazy var pledgeDisclaimerView: PledgeDisclaimerView = {
Expand Down Expand Up @@ -50,6 +50,8 @@ final class PostCampaignCheckoutViewController: UIViewController {
|> \.translatesAutoresizingMaskIntoConstraints .~ false
}()

private var sessionStartedObserver: Any?

private let viewModel: PostCampaignCheckoutViewModelType = PostCampaignCheckoutViewModel()

// MARK: - Lifecycle
Expand Down Expand Up @@ -79,6 +81,10 @@ final class PostCampaignCheckoutViewController: UIViewController {
self.viewModel.inputs.viewDidLoad()
}

deinit {
self.sessionStartedObserver.doIfSome(NotificationCenter.default.removeObserver)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nifty, first time I've seen this in this codebase. Some kind of fancy RAC unsubscribe?

}

// MARK: - Configuration

private func configureChildViewControllers() {
Expand Down Expand Up @@ -179,12 +185,40 @@ final class PostCampaignCheckoutViewController: UIViewController {
self?.paymentMethodsViewController.configure(with: value)
}

self.viewModel.outputs.goToLoginSignup
.observeForControllerAction()
.observeValues { [weak self] intent, project, reward in
self?.goToLoginSignup(with: intent, project: project, reward: reward)
}

self.viewModel.outputs.showWebHelp
.observeForControllerAction()
.observeValues { [weak self] helpType in
guard let self = self else { return }
self.presentHelpWebViewController(with: helpType, presentationStyle: .formSheet)
}

self.sessionStartedObserver = NotificationCenter.default
.addObserver(forName: .ksr_sessionStarted, object: nil, queue: nil) { [weak self] _ in
self?.viewModel.inputs.userSessionStarted()
}

self.paymentMethodsViewController.view.rac.hidden = self.viewModel.outputs.paymentMethodsViewHidden
}

// MARK: - Functions

private func goToLoginSignup(with intent: LoginIntent, project: Project, reward: Reward?) {
let loginSignupViewController = LoginToutViewController.configuredWith(
loginIntent: intent,
project: project,
reward: reward
)

let navigationController = UINavigationController(rootViewController: loginSignupViewController)
let navigationBarHeight = navigationController.navigationBar.bounds.height

self.present(navigationController, animated: true)
}
}

Expand All @@ -195,3 +229,27 @@ extension PostCampaignCheckoutViewController: PledgeDisclaimerViewDelegate {
self.viewModel.inputs.pledgeDisclaimerViewDidTapLearnMore()
}
}

// MARK: - PledgeViewCTAContainerViewDelegate

extension PostCampaignCheckoutViewController: PledgeViewCTAContainerViewDelegate {
func goToLoginSignup() {
self.paymentMethodsViewController.cancelModalPresentation(true)
self.viewModel.inputs.goToLoginSignupTapped()
}

func applePayButtonTapped() {
self.paymentMethodsViewController.cancelModalPresentation(true)
// TODO: Respond to button tap
}

func submitButtonTapped() {
self.paymentMethodsViewController.cancelModalPresentation(true)
// TODO: Respond to button tap
}

func termsOfUseTapped(with helpType: HelpType) {
self.paymentMethodsViewController.cancelModalPresentation(true)
self.viewModel.inputs.termsOfUseTapped(with: helpType)
}
}
13 changes: 11 additions & 2 deletions Library/ViewModels/PledgePaymentMethodsViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,15 @@ public final class PledgePaymentMethodsViewModel: PledgePaymentMethodsViewModelT
self.shouldCancelPaymentSheetAppearance <~ updatedCards.signal
.mapConst(true)

// Only ever use the value if the view model is configured and the payment sheet could exist.
// In the logged out state, the payment sheet is part of the view without being configured,
// so this is a real risk.
let safeShouldCancelPaymentSheet = Signal.combineLatest(
self.shouldCancelPaymentSheetAppearance.signal,
configureWithValue
)
.map(first)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Most patterns I've seen in our codebase call configureWithValue very early, like in onAppear. When does this issue happen? Just seems a bit odd to need the protection.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It happens in the logged out state! If the user is logged out, the payment sheet doesn't actually get configured, but we still say "hey, please don't show that extra modal" if the user interacts with the pledge cta (ex: if they want to log in). Leaving it as it was causes the app to crash when that happens.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aha, that makes sense! This would be a good comment, actually.


let createSetupIntentEvent = Signal.combineLatest(
project,
paymentSheetOnPledgeContext.filter(isTrue)
Expand Down Expand Up @@ -310,7 +319,7 @@ public final class PledgePaymentMethodsViewModel: PledgePaymentMethodsViewModelT
}

self.goToAddCardViaStripeScreen = createSetupIntentEvent.values()
.withLatestFrom(self.shouldCancelPaymentSheetAppearance.signal)
.withLatestFrom(safeShouldCancelPaymentSheet)
.map { (data, shouldCancel) -> PaymentSheetSetupData? in
shouldCancel ? nil : data
}
Expand All @@ -322,7 +331,7 @@ public final class PledgePaymentMethodsViewModel: PledgePaymentMethodsViewModelT

self.updateAddNewCardLoading = Signal.merge(
createSetupIntentEvent.errors().mapConst(false),
self.shouldCancelPaymentSheetAppearance.signal.negate()
safeShouldCancelPaymentSheet.negate()
)

self.willSelectRowAtIndexPathReturnProperty <~ self.reloadPaymentMethods
Expand Down
53 changes: 47 additions & 6 deletions Library/ViewModels/PostCampaignCheckoutViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ public struct PostCampaignCheckoutData: Equatable {

public protocol PostCampaignCheckoutViewModelInputs {
func configure(with data: PostCampaignCheckoutData)
func goToLoginSignupTapped()
func pledgeDisclaimerViewDidTapLearnMore()
func termsOfUseTapped(with: HelpType)
func userSessionStarted()
func viewDidLoad()
}

Expand All @@ -31,6 +34,8 @@ public protocol PostCampaignCheckoutViewModelOutputs {
Never
> { get }
var configurePledgeViewCTAContainerView: Signal<PledgeViewCTAContainerViewData, Never> { get }
var goToLoginSignup: Signal<(LoginIntent, Project, Reward?), Never> { get }
var paymentMethodsViewHidden: Signal<Bool, Never> { get }
var showWebHelp: Signal<HelpType, Never> { get }
}

Expand All @@ -52,16 +57,23 @@ public class PostCampaignCheckoutViewModel: PostCampaignCheckoutViewModelType,

let context = initialData.map(\.context)

self.configurePaymentMethodsViewControllerWithValue = initialData
let configurePaymentMethodsData = Signal.merge(
initialData,
initialData.takeWhen(self.userSessionStartedSignal)
)

self.configurePaymentMethodsViewControllerWithValue = configurePaymentMethodsData
.compactMap { data -> PledgePaymentMethodsValue? in
guard let user = AppEnvironment.current.currentUser else { return nil }
guard let reward = data.rewards.first else { return nil }

return (user, data.project, reward, data.context, data.refTag)
}

// TODO: Respond to login flow.
let isLoggedIn = initialData.ignoreValues()
self.goToLoginSignup = initialData.takeWhen(self.goToLoginSignupSignal)
.map { (LoginIntent.backProject, $0.project, $0.rewards.first) }

let isLoggedIn = Signal.merge(initialData.ignoreValues(), self.userSessionStartedSignal)
.map { _ in AppEnvironment.current.currentUser }
.map(isNotNil)

Expand All @@ -70,11 +82,23 @@ public class PostCampaignCheckoutViewModel: PostCampaignCheckoutViewModelType,
context
)
.map { isLoggedIn, context in
// TODO: Calculate isEnabled and willRetryPaymentMethod fields instead of defaulting to true.
PledgeViewCTAContainerViewData(isLoggedIn, true, context, true)
PledgeViewCTAContainerViewData(
isLoggedIn: isLoggedIn,
isEnabled: true, // Pledge button never needs to be disabled on checkout page.
context: context,
willRetryPaymentMethod: false // Only retry in the `fixPaymentMethod` context.
)
}

self.showWebHelp = self.pledgeDisclaimerViewDidTapLearnMoreSignal.mapConst(.trust)
self.paymentMethodsViewHidden = Signal.combineLatest(isLoggedIn, context)
.map { isLoggedIn, context in
!isLoggedIn || context.paymentMethodsViewHidden
}

self.showWebHelp = Signal.merge(
self.termsOfUseTappedSignal,
self.pledgeDisclaimerViewDidTapLearnMoreSignal.mapConst(.trust)
)

self.configurePledgeRewardsSummaryViewWithData = initialData
.compactMap { data in
Expand All @@ -101,12 +125,27 @@ public class PostCampaignCheckoutViewModel: PostCampaignCheckoutViewModelType,
self.configureWithDataProperty.value = data
}

private let (goToLoginSignupSignal, goToLoginSignupObserver) = Signal<Void, Never>.pipe()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First time I've seen this, what's the goal of using Signal<Void, Never>.pipe()?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, to be honest, this is also just copy-pasted from the standard pledge version of this VC/VM. But I think the point is that we want to react to button clicks - so we want to track whenever new events happen but we don't actually need it to pass along any data.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aah, gotcha. I think we mix Void and () throughout the codebase. Seems fine.

public func goToLoginSignupTapped() {
self.goToLoginSignupObserver.send(value: ())
}

private let (pledgeDisclaimerViewDidTapLearnMoreSignal, pledgeDisclaimerViewDidTapLearnMoreObserver)
= Signal<Void, Never>.pipe()
public func pledgeDisclaimerViewDidTapLearnMore() {
self.pledgeDisclaimerViewDidTapLearnMoreObserver.send(value: ())
}

private let (termsOfUseTappedSignal, termsOfUseTappedObserver) = Signal<HelpType, Never>.pipe()
public func termsOfUseTapped(with helpType: HelpType) {
self.termsOfUseTappedObserver.send(value: helpType)
}

private let (userSessionStartedSignal, userSessionStartedObserver) = Signal<Void, Never>.pipe()
public func userSessionStarted() {
self.userSessionStartedObserver.send(value: ())
}

private let viewDidLoadProperty = MutableProperty(())
public func viewDidLoad() {
self.viewDidLoadProperty.value = ()
Expand All @@ -121,6 +160,8 @@ public class PostCampaignCheckoutViewModel: PostCampaignCheckoutViewModelType,
PledgeSummaryViewData
), Never>
public let configurePledgeViewCTAContainerView: Signal<PledgeViewCTAContainerViewData, Never>
public let goToLoginSignup: Signal<(LoginIntent, Project, Reward?), Never>
public let paymentMethodsViewHidden: Signal<Bool, Never>
public let showWebHelp: Signal<HelpType, Never>

public var inputs: PostCampaignCheckoutViewModelInputs { return self }
Expand Down