-
Notifications
You must be signed in to change notification settings - Fork 99
[PM-33849] feat: Add Premium Upgrade view #2523
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
base: main
Are you sure you want to change the base?
Changes from 5 commits
c1b5805
89ba31a
6ad2e9c
a124c37
f323ebf
8ce065b
447f1bd
e7252dc
04a0092
278ba64
9fc9aee
fb89588
688eaac
15a12c4
7fa8ff8
e100ee0
f5f0a5b
e52e970
d913840
7f43801
a0fcfba
408ecd6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| // MARK: - PremiumUpgradeAction | ||
|
|
||
| /// Actions handled by the `PremiumUpgradeProcessor`. | ||
| /// | ||
| enum PremiumUpgradeAction: Equatable { | ||
| /// The cancel button was tapped. | ||
| case cancelTapped | ||
|
|
||
| /// Clear the checkout URL after it has been opened. | ||
| case clearURL | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| import BitwardenKit | ||
| import SwiftUI | ||
|
|
||
| // MARK: - PremiumUpgradeCoordinator | ||
|
|
||
| /// A coordinator that manages navigation for the premium upgrade view. | ||
| /// | ||
| class PremiumUpgradeCoordinator: Coordinator, HasStackNavigator { | ||
| // MARK: Types | ||
|
|
||
| typealias Services = HasBillingAPIService | ||
| & HasErrorAlertServices.ErrorAlertServices | ||
| & HasErrorReporter | ||
|
|
||
| // MARK: Properties | ||
|
|
||
| /// The services used by this coordinator. | ||
| let services: Services | ||
|
|
||
| /// The stack navigator that is managed by this coordinator. | ||
| private(set) weak var stackNavigator: StackNavigator? | ||
|
|
||
| // MARK: Initialization | ||
|
|
||
| /// Creates a new `PremiumUpgradeCoordinator`. | ||
| /// | ||
| /// - Parameters: | ||
| /// - services: The services used by this coordinator. | ||
| /// - stackNavigator: The stack navigator that is managed by this coordinator. | ||
| /// | ||
| init( | ||
| services: Services, | ||
| stackNavigator: StackNavigator, | ||
| ) { | ||
| self.services = services | ||
| self.stackNavigator = stackNavigator | ||
| } | ||
|
|
||
| // MARK: Methods | ||
|
|
||
| func navigate(to route: PremiumUpgradeRoute, context: AnyObject?) { | ||
| switch route { | ||
| case .dismiss: | ||
| stackNavigator?.dismiss() | ||
| } | ||
| } | ||
|
|
||
| func start() { | ||
| showPremiumUpgrade() | ||
| } | ||
|
|
||
| // MARK: Private Methods | ||
|
|
||
| /// Shows the premium upgrade screen. | ||
| /// | ||
| private func showPremiumUpgrade() { | ||
| let processor = PremiumUpgradeProcessor( | ||
| coordinator: asAnyCoordinator(), | ||
| services: services, | ||
| state: PremiumUpgradeState(), | ||
| ) | ||
| let view = PremiumUpgradeView(store: Store(processor: processor)) | ||
| stackNavigator?.replace(view) | ||
| } | ||
| } | ||
|
|
||
| // MARK: - HasErrorAlertServices | ||
|
|
||
| extension PremiumUpgradeCoordinator: HasErrorAlertServices { | ||
| var errorAlertServices: ErrorAlertServices { services } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| import BitwardenKitMocks | ||
| import SwiftUI | ||
| import XCTest | ||
|
|
||
| @testable import BitwardenShared | ||
| @testable import BitwardenSharedMocks | ||
|
|
||
| class PremiumUpgradeCoordinatorTests: BitwardenTestCase { | ||
| // MARK: Properties | ||
|
|
||
| var stackNavigator: MockStackNavigator! | ||
| var subject: PremiumUpgradeCoordinator! | ||
|
|
||
| // MARK: Setup & Teardown | ||
|
|
||
| override func setUp() { | ||
| super.setUp() | ||
|
|
||
| stackNavigator = MockStackNavigator() | ||
|
|
||
| subject = PremiumUpgradeCoordinator( | ||
| services: ServiceContainer.withMocks(), | ||
| stackNavigator: stackNavigator, | ||
| ) | ||
| } | ||
|
|
||
| override func tearDown() { | ||
| super.tearDown() | ||
|
|
||
| stackNavigator = nil | ||
| subject = nil | ||
| } | ||
|
|
||
| // MARK: Tests | ||
|
|
||
| /// `start()` replaces the stack navigator's stack with the premium upgrade view. | ||
| @MainActor | ||
| func test_start() throws { | ||
| subject.start() | ||
|
|
||
| XCTAssertEqual(stackNavigator.actions.count, 1) | ||
| let action = try XCTUnwrap(stackNavigator.actions.last) | ||
| XCTAssertEqual(action.type, .replaced) | ||
| XCTAssertTrue(action.view is PremiumUpgradeView) | ||
| } | ||
|
|
||
| /// `navigate(to:)` with `.dismiss` dismisses the view. | ||
| @MainActor | ||
| func test_navigate_dismiss() throws { | ||
| subject.navigate(to: .dismiss) | ||
|
|
||
| let action = try XCTUnwrap(stackNavigator.actions.last) | ||
| XCTAssertEqual(action.type, .dismissed) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| // MARK: - PremiumUpgradeEffect | ||
|
|
||
| /// Effects that can be processed by a `PremiumUpgradeProcessor`. | ||
| /// | ||
| enum PremiumUpgradeEffect { | ||
| /// The upgrade now button was tapped. | ||
| case upgradeNowTapped | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| import BitwardenKit | ||
|
|
||
| // MARK: - PremiumUpgradeModule | ||
|
|
||
| /// An object that builds coordinators for the premium upgrade view. | ||
| /// | ||
| @MainActor | ||
| protocol PremiumUpgradeModule { | ||
| /// Initializes a coordinator for navigating between `PremiumUpgradeRoute`s. | ||
| /// | ||
| /// - Parameter stackNavigator: The stack navigator that will be used to navigate between routes. | ||
| /// - Returns: A coordinator that can navigate to `PremiumUpgradeRoute`s. | ||
| /// | ||
| func makePremiumUpgradeCoordinator( | ||
| stackNavigator: StackNavigator, | ||
| ) -> AnyCoordinator<PremiumUpgradeRoute, Void> | ||
| } | ||
|
|
||
| extension DefaultAppModule: PremiumUpgradeModule { | ||
| func makePremiumUpgradeCoordinator( | ||
| stackNavigator: StackNavigator, | ||
| ) -> AnyCoordinator<PremiumUpgradeRoute, Void> { | ||
| PremiumUpgradeCoordinator( | ||
| services: services, | ||
| stackNavigator: stackNavigator, | ||
| ).asAnyCoordinator() | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,77 @@ | ||||||||||||||||||||||||||||||||
| import BitwardenKit | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| // MARK: - PremiumUpgradeProcessor | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| /// The processor used to manage state and handle actions for the `PremiumUpgradeView`. | ||||||||||||||||||||||||||||||||
| /// | ||||||||||||||||||||||||||||||||
| final class PremiumUpgradeProcessor: StateProcessor< | ||||||||||||||||||||||||||||||||
| PremiumUpgradeState, | ||||||||||||||||||||||||||||||||
| PremiumUpgradeAction, | ||||||||||||||||||||||||||||||||
| PremiumUpgradeEffect, | ||||||||||||||||||||||||||||||||
| > { | ||||||||||||||||||||||||||||||||
| // MARK: Types | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| typealias Services = HasBillingAPIService | ||||||||||||||||||||||||||||||||
| & HasErrorReporter | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| // MARK: Properties | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| /// The coordinator used to manage navigation. | ||||||||||||||||||||||||||||||||
| private let coordinator: AnyCoordinator<PremiumUpgradeRoute, Void> | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| /// The services used by this processor. | ||||||||||||||||||||||||||||||||
| private let services: Services | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| // MARK: Initialization | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| /// Initializes a `PremiumUpgradeProcessor`. | ||||||||||||||||||||||||||||||||
| /// | ||||||||||||||||||||||||||||||||
| /// - Parameters: | ||||||||||||||||||||||||||||||||
| /// - coordinator: The coordinator used for navigation. | ||||||||||||||||||||||||||||||||
| /// - services: The services used by this processor. | ||||||||||||||||||||||||||||||||
| /// - state: The initial state of the processor. | ||||||||||||||||||||||||||||||||
| /// | ||||||||||||||||||||||||||||||||
| init( | ||||||||||||||||||||||||||||||||
| coordinator: AnyCoordinator<PremiumUpgradeRoute, Void>, | ||||||||||||||||||||||||||||||||
| services: Services, | ||||||||||||||||||||||||||||||||
| state: PremiumUpgradeState, | ||||||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||||||
| self.coordinator = coordinator | ||||||||||||||||||||||||||||||||
| self.services = services | ||||||||||||||||||||||||||||||||
| super.init(state: state) | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| // MARK: Methods | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| override func perform(_ effect: PremiumUpgradeEffect) async { | ||||||||||||||||||||||||||||||||
| switch effect { | ||||||||||||||||||||||||||||||||
| case .upgradeNowTapped: | ||||||||||||||||||||||||||||||||
| await createCheckoutSession() | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| override func receive(_ action: PremiumUpgradeAction) { | ||||||||||||||||||||||||||||||||
| switch action { | ||||||||||||||||||||||||||||||||
| case .cancelTapped: | ||||||||||||||||||||||||||||||||
| coordinator.navigate(to: .dismiss) | ||||||||||||||||||||||||||||||||
| case .clearURL: | ||||||||||||||||||||||||||||||||
| state.checkoutURL = nil | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| // MARK: Private Methods | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| /// Creates a checkout session by calling the billing API. | ||||||||||||||||||||||||||||||||
| /// | ||||||||||||||||||||||||||||||||
| private func createCheckoutSession() async { | ||||||||||||||||||||||||||||||||
| defer { state.isLoading = false } | ||||||||||||||||||||||||||||||||
| do { | ||||||||||||||||||||||||||||||||
| state.isLoading = true | ||||||||||||||||||||||||||||||||
| let response = try await services.billingAPIService.createCheckoutSession() | ||||||||||||||||||||||||||||||||
| state.checkoutURL = response.checkoutSessionUrl | ||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||
| services.errorReporter.log(error: error) | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
| defer { state.isLoading = false } | |
| do { | |
| state.isLoading = true | |
| let response = try await services.billingAPIService.createCheckoutSession() | |
| state.checkoutURL = response.checkoutSessionUrl | |
| } catch { | |
| services.errorReporter.log(error: error) | |
| do { | |
| state.isLoading = true | |
| let response = try await services.billingAPIService.createCheckoutSession() | |
| state.isLoading = false | |
| state.checkoutURL = response.checkoutSessionUrl | |
| } catch { | |
| services.errorReporter.log(error: error) | |
| state.isLoading = false |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
billingAPIServiceOverrideintroduces a mutable test-only escape hatch on the productionServiceContainer. To keep the override from being modified outside test helpers, consider tightening access (e.g.,private(set)/private) and injecting the override via an initializer or a dedicated test-only API (possibly behind#if DEBUG).There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks forgot to delete this.