From c96d72ecd4068a466b8cebe0d6090ab90683777e Mon Sep 17 00:00:00 2001 From: Amy Date: Thu, 2 Apr 2026 09:51:21 -0400 Subject: [PATCH 1/2] Refactor PaymentMethodsUseCase to use a stateful state, instead of be totally reactive. --- .../ViewModels/PaymentMethodsUseCase.swift | 170 +++++++++++------- 1 file changed, 103 insertions(+), 67 deletions(-) diff --git a/Library/Sources/Library/Library/ViewModels/PaymentMethodsUseCase.swift b/Library/Sources/Library/Library/ViewModels/PaymentMethodsUseCase.swift index 121759d577..9d869d7701 100644 --- a/Library/Sources/Library/Library/ViewModels/PaymentMethodsUseCase.swift +++ b/Library/Sources/Library/Library/ViewModels/PaymentMethodsUseCase.swift @@ -39,61 +39,44 @@ public protocol PaymentMethodsUseCaseDataOutputs { * `selectedPaymentSource` - The currently selected credit card, or `nil` if no card is selected. Sent at least once after `initialData` is sent. * `paymentMethodChangedAndValid` - Whether or not the payment method is valid for the current pledge type. Sends an event after `initialData` and potentially more after `creditCardSelected(with:)` has happened. */ + public final class PaymentMethodsUseCase: PaymentMethodsUseCaseType, PaymentMethodsUseCaseUIInputs, PaymentMethodsUseCaseUIOutputs, PaymentMethodsUseCaseDataOutputs { - init(initialData: Signal, isLoggedIn isLoggedInChanged: Signal) { - let project = initialData.map(\.project) - let baseReward = initialData.map(\.rewards).map(\.first).skipNil() - let refTag = initialData.map(\.refTag) - let context = initialData.map(\.context) - - let initialDataUnpacked = Signal.zip(project, baseReward, refTag, context) - let initialLoggedIn = initialData.map { _ in AppEnvironment.current.currentUser != nil } - - let isLoggedIn = Signal.merge( - initialLoggedIn, - isLoggedInChanged - ).skipRepeats() - - let configurePaymentMethodsViewController = Signal.merge( - initialDataUnpacked, - initialDataUnpacked.takeWhen(isLoggedIn.filter { $0 == true }) - ) + private var state: MutableProperty - self.configurePaymentMethodsViewControllerWithValue = configurePaymentMethodsViewController - .filter { !$3.paymentMethodsViewHidden } - .compactMap { project, reward, refTag, context -> PledgePaymentMethodsValue? in - guard let user = AppEnvironment.current.currentUser else { return nil } - return (user, project, "", reward, context, refTag) - } - - self.paymentMethodsViewHidden = Signal.combineLatest(isLoggedIn, context) - .map { !$0 || $1.paymentMethodsViewHidden } + init(initialData: Signal, isLoggedIn isLoggedInChanged: Signal) { + self.state = MutableProperty(nil) - self.selectedPaymentSource = Signal.merge( - initialData.mapConst(nil), - self.creditCardSelectedSignal.wrapInOptional() + self.state <~ Signal.combineLatest( + initialData, + Signal.merge(initialData.map { _ in AppEnvironment.current.currentUser != nil }, isLoggedInChanged), + Signal.merge(initialData.mapConst(nil), self.paymentSourceProperty.signal) ) + .map { data, loggedIn, paymentSource in + PaymentMethodsUseCaseState( + data: data, + isLoggedIn: loggedIn, + paymentSourceSelected: paymentSource + ) + } - let notChangingPaymentMethod = context.map { context in - if context.isUpdating { - return context == .updateReward || context == .editPledgeOverTime - } + self.paymentMethodsViewHidden = self.state.signal + .skipNil() + .map { $0.isPaymentMethodViewHidden } + .skipRepeats() - return false - } + self.configurePaymentMethodsViewControllerWithValue = self.state.signal + .map { $0?.configurePaymentMethodsViewControllerValue } + .skipNil() + .take(first: 1) - /// The `paymentMethodChangedAndValid` compares against the existing backing payment source id. - self.paymentMethodChangedAndValid = Signal.merge( - notChangingPaymentMethod, - Signal.combineLatest( - project, - baseReward, - self.creditCardSelectedSignal, - context - ) - .map(paymentMethodValid) - ) + self.selectedPaymentSource = self.state.signal + .map { $0?.paymentSourceSelected } + .skipRepeats() + + self.paymentMethodChangedAndValid = self.state.signal + .map { $0?.paymentMethodChangedAndValid } + .skipNil() } public let paymentMethodsViewHidden: Signal @@ -101,10 +84,9 @@ public final class PaymentMethodsUseCase: PaymentMethodsUseCaseType, PaymentMeth public let selectedPaymentSource: Signal public let paymentMethodChangedAndValid: Signal - private let (creditCardSelectedSignal, creditCardSelectedObserver) = Signal - .pipe() + private let paymentSourceProperty = MutableProperty(nil) public func creditCardSelected(with paymentSourceData: PaymentSourceSelected) { - self.creditCardSelectedObserver.send(value: paymentSourceData) + self.paymentSourceProperty.value = paymentSourceData } public var uiInputs: PaymentMethodsUseCaseUIInputs { return self } @@ -112,25 +94,79 @@ public final class PaymentMethodsUseCase: PaymentMethodsUseCaseType, PaymentMeth public var dataOutputs: PaymentMethodsUseCaseDataOutputs { return self } } -private func paymentMethodValid( - project: Project, - reward: Reward, - paymentSource: PaymentSourceSelected, - context: PledgeViewContext -) -> Bool { - guard - let backedPaymentSourceId = project.personalization.backing?.paymentSource?.id, - context.isUpdating, - userIsBacking(reward: reward, inProject: project) - else { - return true +private struct PaymentMethodsUseCaseState { + let data: PledgeViewData + var isLoggedIn: Bool + var paymentSourceSelected: PaymentSourceSelected? + + var isPaymentMethodViewHidden: Bool { + if !self.isLoggedIn { + return true + } + + return self.data.context.paymentMethodsViewHidden + } + + var baseReward: Reward? { + return self.data.rewards.first + } + + var configurePaymentMethodsViewControllerValue: PledgePaymentMethodsValue? { + if !self.isLoggedIn || self.isPaymentMethodViewHidden { + return nil + } + + guard let user = AppEnvironment.current.currentUser, + let reward = self.baseReward else { + return nil + } + + return (user, self.data.project, "", reward, self.data.context, self.data.refTag) } - if project.personalization.backing?.status == .errored { - return true - } else if backedPaymentSourceId != paymentSource.savedCreditCardId { - return true + var notChangingPaymentMethod: Bool { + let context = self.data.context + + if context.isUpdating { + return context == .updateReward || context == .editPledgeOverTime + } + + return false } - return false + /// The `paymentMethodChangedAndValid` compares against the existing backing payment source id. + var paymentMethodChangedAndValid: Bool { + guard let reward = self.baseReward, + let source = self.paymentSourceSelected else { return self.notChangingPaymentMethod } + + return self.paymentMethodValid( + project: self.data.project, + reward: reward, + paymentSource: source, + context: self.data.context + ) + } + + private func paymentMethodValid( + project: Project, + reward: Reward, + paymentSource: PaymentSourceSelected, + context: PledgeViewContext + ) -> Bool { + guard + let backedPaymentSourceId = project.personalization.backing?.paymentSource?.id, + context.isUpdating, + userIsBacking(reward: reward, inProject: project) + else { + return true + } + + if project.personalization.backing?.status == .errored { + return true + } else if backedPaymentSourceId != paymentSource.savedCreditCardId { + return true + } + + return false + } } From a78f0dd0178630d6ad64c3e4ab995e3b1ca88127 Mon Sep 17 00:00:00 2001 From: Amy Date: Thu, 2 Apr 2026 11:03:03 -0400 Subject: [PATCH 2/2] Tweak --- .../Library/Library/ViewModels/PaymentMethodsUseCase.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Library/Sources/Library/Library/ViewModels/PaymentMethodsUseCase.swift b/Library/Sources/Library/Library/ViewModels/PaymentMethodsUseCase.swift index 9d869d7701..809d434d15 100644 --- a/Library/Sources/Library/Library/ViewModels/PaymentMethodsUseCase.swift +++ b/Library/Sources/Library/Library/ViewModels/PaymentMethodsUseCase.swift @@ -60,11 +60,14 @@ public final class PaymentMethodsUseCase: PaymentMethodsUseCaseType, PaymentMeth ) } + // This should really be bumped out elsewhere self.paymentMethodsViewHidden = self.state.signal .skipNil() .map { $0.isPaymentMethodViewHidden } .skipRepeats() + // This isn't quite right with regards to logging in/logging out but + // it works in practice (since you can only log in once without destroying the screen) self.configurePaymentMethodsViewControllerWithValue = self.state.signal .map { $0?.configurePaymentMethodsViewControllerValue } .skipNil()