diff --git a/BitwardenShared/UI/Billing/BillingCoordinator.swift b/BitwardenShared/UI/Billing/BillingCoordinator.swift index 7b07a319b4..a1326476c8 100644 --- a/BitwardenShared/UI/Billing/BillingCoordinator.swift +++ b/BitwardenShared/UI/Billing/BillingCoordinator.swift @@ -64,6 +64,8 @@ class BillingCoordinator: NSObject, Coordinator, HasStackNavigator { } case .premiumUpgradeComplete: showPremiumUpgradeComplete() + case .premiumUpgradeCompleteStandalone: + showPremiumUpgradeCompleteStandalone() case let .premiumPlan(subscription): showPremiumPlan(subscription: subscription) case .premiumUpgrade: @@ -75,7 +77,10 @@ class BillingCoordinator: NSObject, Coordinator, HasStackNavigator { // MARK: Private Methods - /// Shows the Premium upgrade complete screen. + /// Shows the Premium upgrade complete screen on top of the currently-visible + /// `PremiumUpgradeView`, for the synchronous-confirm-while-upgrade-screen-is-open path. Use + /// `showPremiumUpgradeCompleteStandalone()` instead when no `PremiumUpgradeView` has been + /// shown in this coordinator instance. /// private func showPremiumUpgradeComplete() { premiumUpgradeCompleteOnClose = isUpgradeAsModalRoot @@ -94,6 +99,21 @@ class BillingCoordinator: NSObject, Coordinator, HasStackNavigator { stackNavigator?.present(view) } + /// Shows the Premium upgrade complete screen as the sole content of this coordinator's + /// stack, with no `PremiumUpgradeView` shown first. Use this when a Premium upgrade + /// resolves outside of the upgrade screen itself — e.g. a "Sync Now" retry succeeding after + /// the upgrade screen has already been dismissed — so `.dismiss` can simply close the whole + /// modal without any `isUpgradeAsModalRoot`/Settings-plan branching. + /// + private func showPremiumUpgradeCompleteStandalone() { + let processor = PremiumUpgradeCompleteProcessor( + coordinator: asAnyCoordinator(), + services: services, + ) + let view = PremiumUpgradeCompleteView(store: Store(processor: processor)) + stackNavigator?.replace(view) + } + /// Shows the Premium plan screen. /// /// - Parameter subscription: An already-fetched subscription; pass `nil` to let the plan screen fetch it. diff --git a/BitwardenShared/UI/Billing/BillingCoordinatorTests.swift b/BitwardenShared/UI/Billing/BillingCoordinatorTests.swift index 4dab363ad0..4665cd7d30 100644 --- a/BitwardenShared/UI/Billing/BillingCoordinatorTests.swift +++ b/BitwardenShared/UI/Billing/BillingCoordinatorTests.swift @@ -117,6 +117,34 @@ struct BillingCoordinatorTests { #expect(action.view is PremiumUpgradeCompleteView) } + /// `navigate(to:)` with `.premiumUpgradeCompleteStandalone` replaces the stack's root with the + /// Premium upgrade complete view, rather than presenting it as a child of an existing screen. + @Test + func navigate_premiumUpgradeCompleteStandalone() throws { + subject.navigate(to: .premiumUpgradeCompleteStandalone) + + #expect(stackNavigator.actions.count == 1) + let action = try #require(stackNavigator.actions.last) + #expect(action.type == .replaced) + #expect(action.view is PremiumUpgradeCompleteView) + } + + /// `navigate(to:)` with `.dismiss` after `.premiumUpgradeCompleteStandalone` dismisses the + /// entire modal directly, without any of the modal-root/settings-context branching that + /// `.premiumUpgradeComplete` needs. + @Test + func navigate_dismiss_afterPremiumUpgradeCompleteStandalone() throws { + subject.navigate(to: .premiumUpgradeCompleteStandalone) + stackNavigator.actions.removeAll() + + stackNavigator.isPresenting = false + // viewControllersToPop is empty by default, so pop() returns nil. + subject.navigate(to: .dismiss) + + let action = try #require(stackNavigator.actions.last) + #expect(action.type == .dismissed) + } + /// `navigate(to:)` with `.premiumPlan` pushes the Premium plan view. @Test func navigate_premiumPlan() throws { diff --git a/BitwardenShared/UI/Billing/BillingRoute.swift b/BitwardenShared/UI/Billing/BillingRoute.swift index afb0cbc5fb..fff343b93f 100644 --- a/BitwardenShared/UI/Billing/BillingRoute.swift +++ b/BitwardenShared/UI/Billing/BillingRoute.swift @@ -14,4 +14,10 @@ enum BillingRoute: Equatable { /// A route to the Premium upgrade complete screen. case premiumUpgradeComplete + + /// A route to the Premium upgrade complete screen, presented as the sole content of a + /// freshly-created modal (no `PremiumUpgradeView` shown first in this coordinator instance). + /// Used when a Premium upgrade resolves outside of the upgrade screen itself — e.g. a + /// "Sync Now" retry succeeding after the upgrade screen has already been dismissed. + case premiumUpgradeCompleteStandalone } diff --git a/BitwardenShared/UI/Billing/PremiumUpgradeHelper.swift b/BitwardenShared/UI/Billing/PremiumUpgradeHelper.swift index b0d6a72d11..82943feb17 100644 --- a/BitwardenShared/UI/Billing/PremiumUpgradeHelper.swift +++ b/BitwardenShared/UI/Billing/PremiumUpgradeHelper.swift @@ -11,6 +11,11 @@ protocol PremiumUpgradeRoute { /// The route to the Premium upgrade screen. static var premiumUpgrade: Self { get } + /// The route to a standalone Premium upgrade complete screen, shown when an upgrade + /// resolves outside of the upgrade screen itself (e.g. a "Sync Now" retry succeeding after + /// the upgrade screen has already been dismissed). + static var premiumUpgradeComplete: Self { get } + /// The route to dismiss the current screen with an optional action. /// /// - Parameter action: The action to perform on dismiss. @@ -24,6 +29,39 @@ extension SettingsRoute: PremiumUpgradeRoute {} extension VaultItemRoute: PremiumUpgradeRoute {} extension VaultRoute: PremiumUpgradeRoute {} +// MARK: - PremiumUpgradeRetry + +/// Shared logic for the two explicit, user-initiated "retry the pending upgrade's sync" entry +/// points — the "Sync Now" button on the upgrade pending alert, and "Try Again" on the "Sync +/// unsuccessful" alert — so both react identically to the retry actually resolving the upgrade. +/// +enum PremiumUpgradeRetry { + /// Calls `retry`, then, if the pending upgrade resolved successfully (no longer pending, and + /// the retry itself didn't fail), navigates to the standalone Premium upgrade complete + /// screen. A still-pending (not yet Premium) or still-failed outcome gets no further UI + /// here — the CTA and the "Sync unsuccessful" alert already react to those independently via + /// the durable `premiumUpgradePendingStatePublisher()` signal. + /// + /// - Parameters: + /// - billingService: The service used to check the resulting state after `retry` runs. + /// - coordinator: The coordinator to navigate on success. + /// - retry: The retry action to perform — `reconcileCheckoutSuccess()` for the "Sync Now" + /// alert, `premiumStatusChanged()` for the "Sync unsuccessful" alert's "Try Again". + /// + @MainActor + static func retryAndShowCompleteIfResolved( + billingService: BillingService, + coordinator: any Coordinator, + retry: () async -> Void, + ) async { + await retry() + let state = await billingService.premiumUpgradePendingState() + if !state.isPending, !state.lastAttemptFailed { + coordinator.navigate(to: .premiumUpgradeComplete) + } + } +} + // MARK: - PremiumUpgradeHelper /// A helper that centralizes the Premium upgrade navigation flow. @@ -157,12 +195,22 @@ class DefaultPremiumUpgradeHelper: PremiumUpg // MARK: Private Methods /// Calls `onPendingDismiss`, then shows the upgrade pending alert with "Sync Now" wired to - /// `reconcileCheckoutSuccess()`. + /// `reconcileCheckoutSuccess()` — navigating to the standalone Premium upgrade complete + /// screen if the retry actually resolves the upgrade. A still-pending (not yet Premium) or + /// still-failed outcome gets no further UI here: the CTA and the "Sync unsuccessful" alert + /// already react to those independently via the durable `premiumUpgradePendingStatePublisher()` + /// signal. /// private func showUpgradePendingAlert() { onPendingDismiss?() coordinator.showAlert(.upgradePending { [weak self] in - await self?.services.billingService.reconcileCheckoutSuccess() + guard let self else { return } + await PremiumUpgradeRetry.retryAndShowCompleteIfResolved( + billingService: services.billingService, + coordinator: coordinator, + ) { + await self.services.billingService.reconcileCheckoutSuccess() + } }) } diff --git a/BitwardenShared/UI/Billing/PremiumUpgradeHelperTests.swift b/BitwardenShared/UI/Billing/PremiumUpgradeHelperTests.swift index bb699d77e2..d977448e8f 100644 --- a/BitwardenShared/UI/Billing/PremiumUpgradeHelperTests.swift +++ b/BitwardenShared/UI/Billing/PremiumUpgradeHelperTests.swift @@ -370,6 +370,54 @@ struct PremiumUpgradeHelperTests { // swiftlint:disable:this type_body_length }) == 1) } + /// Tapping "Sync Now" on the upgrade pending alert navigates to the standalone Premium + /// upgrade complete screen only when the retry actually resolves the pending upgrade — not + /// when it's still pending (not yet Premium) or the retry itself failed, both of which are + /// covered by PR2's CTA and PR3's "Sync unsuccessful" alert independently, via the durable + /// `premiumUpgradePendingStatePublisher()` signal. + @Test(arguments: [ + (PremiumUpgradePendingState(isPending: false, lastAttemptFailed: false), true), + (PremiumUpgradePendingState(isPending: true, lastAttemptFailed: false), false), + (PremiumUpgradePendingState(isPending: true, lastAttemptFailed: true), false), + (PremiumUpgradePendingState(isPending: false, lastAttemptFailed: true), false), + ]) + func subscribeToPremiumCheckoutStatus_pending_syncNow( + pendingState: PremiumUpgradePendingState, + expectedNavigatesToComplete: Bool, + ) async throws { + billingRepository.isInAppUpgradeAvailableReturnValue = true + let statusSubject = PassthroughSubject() + billingService.premiumCheckoutStatusPublisherReturnValue = statusSubject.eraseToAnyPublisher() + // Not pending yet for `startInAppPremiumUpgrade`'s own already-pending check, so it + // navigates to `.premiumUpgrade` normally; `pendingState` only reflects the result of the + // "Sync Now" retry itself, checked afterward. + billingService.premiumUpgradePendingStateReturnValue = PremiumUpgradePendingState( + isPending: false, + lastAttemptFailed: false, + ) + let subject = makeSubject() + await subject.navigateToPremiumUpgrade() + try await waitForAsync { coordinator.routes.last == .premiumUpgrade } + + statusSubject.send(.pending) + try await waitForAsync { + guard case let .dismiss(action) = coordinator.routes.last else { return false } + return action != nil + } + guard case let .dismiss(action) = coordinator.routes.last else { + Issue.record("Expected .dismiss route") + return + } + billingService.premiumUpgradePendingStateReturnValue = pendingState + action?.action() + + let alert = try #require(coordinator.alertShown.last) + try await alert.tapAction(title: Localizations.syncNow) + + #expect(billingService.reconcileCheckoutSuccessCalled) + #expect((coordinator.routes.last == .premiumUpgradeComplete) == expectedNavigatesToComplete) + } + /// When the billing service emits `.syncing`, nothing happens (the loading overlay is shown /// by `PremiumUpgradeProcessor`). @Test @@ -396,4 +444,4 @@ struct PremiumUpgradeHelperTests { // swiftlint:disable:this type_body_length #expect(coordinator.routes.count == routeCountBeforeSend) } -} +} // swiftlint:disable:this file_length diff --git a/BitwardenShared/UI/Platform/Settings/SettingsCoordinator.swift b/BitwardenShared/UI/Platform/Settings/SettingsCoordinator.swift index ef71195967..b1276f4087 100644 --- a/BitwardenShared/UI/Platform/Settings/SettingsCoordinator.swift +++ b/BitwardenShared/UI/Platform/Settings/SettingsCoordinator.swift @@ -210,6 +210,8 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator { // swiftlint:d showPremiumPlan(subscription: subscription) case .premiumUpgrade: showPremiumUpgrade() + case .premiumUpgradeComplete: + showPremiumUpgradeCompleteScreen() case let .selectLanguage(currentLanguage: currentLanguage): showSelectLanguage(currentLanguage: currentLanguage, delegate: context as? SelectLanguageDelegate) case let .settings(presentationMode): @@ -518,6 +520,25 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator { // swiftlint:d coordinator.navigate(to: .premiumUpgrade) } + /// Shows a standalone Premium upgrade complete screen, for when an upgrade resolves outside + /// of the upgrade screen itself (e.g. a "Sync Now" retry succeeding after the upgrade screen + /// has already been dismissed). Unlike `showPremiumUpgrade()`, this presents its own fresh + /// modal rather than pushing onto Settings' existing stack, matching every other origin + /// screen's treatment of this same screen. + /// + private func showPremiumUpgradeCompleteScreen() { + // Unlike every other origin, Settings pushes `PremiumUpgradeView` (rather than + // presenting it as a fresh modal root) — reaching this method always means it's still + // the top of this stack, since nothing else pops it. Pop it before presenting the + // celebration so closing the celebration reveals the real Settings screen underneath, + // not a now-stale "Upgrade now" screen that has no way to notice premium was granted. + stackNavigator?.pop(animated: false) + let navigationController = module.makeNavigationController() + let coordinator = module.makeBillingCoordinator(stackNavigator: navigationController) + coordinator.navigate(to: .premiumUpgradeCompleteStandalone) + stackNavigator?.present(navigationController) + } + /// Shows the select language screen. /// private func showSelectLanguage( diff --git a/BitwardenShared/UI/Platform/Settings/SettingsCoordinatorTests.swift b/BitwardenShared/UI/Platform/Settings/SettingsCoordinatorTests.swift index 52582e9976..6b88fdf904 100644 --- a/BitwardenShared/UI/Platform/Settings/SettingsCoordinatorTests.swift +++ b/BitwardenShared/UI/Platform/Settings/SettingsCoordinatorTests.swift @@ -400,6 +400,20 @@ class SettingsCoordinatorTests: BitwardenTestCase { // swiftlint:disable:this ty XCTAssertEqual(module.billingCoordinator.routes, [.premiumUpgrade]) } + /// `navigate(to:)` with `.premiumUpgradeComplete` pops the pushed Premium upgrade screen — + /// unlike every other origin, Settings pushes rather than presents it, so it's otherwise + /// still visible underneath the celebration and never re-checks premium status on its own — + /// then presents a standalone Premium upgrade complete screen via the billing coordinator. + @MainActor + func test_navigateTo_premiumUpgradeComplete() throws { + subject.navigate(to: .premiumUpgradeComplete) + + XCTAssertEqual(stackNavigator.actions.count, 2) + XCTAssertEqual(stackNavigator.actions[0].type, .popped) + XCTAssertEqual(stackNavigator.actions[1].type, .presented) + XCTAssertEqual(module.billingCoordinator.routes, [.premiumUpgradeCompleteStandalone]) + } + /// `navigate(to:)` with `.selectLanguage()` presents the select language view. @MainActor func test_navigateTo_selectLanguage() throws { diff --git a/BitwardenShared/UI/Platform/Settings/SettingsRoute.swift b/BitwardenShared/UI/Platform/Settings/SettingsRoute.swift index 2a54a51feb..713fadbed3 100644 --- a/BitwardenShared/UI/Platform/Settings/SettingsRoute.swift +++ b/BitwardenShared/UI/Platform/Settings/SettingsRoute.swift @@ -80,6 +80,11 @@ public enum SettingsRoute: Equatable, Hashable { /// A route to the Premium upgrade screen. case premiumUpgrade + /// A route to a standalone Premium upgrade complete screen, shown when an upgrade resolves + /// outside of the upgrade screen itself (e.g. a "Sync Now" retry succeeding after the + /// upgrade screen has already been dismissed). + case premiumUpgradeComplete + /// A route to view the select language view. /// /// - Parameter currentLanguage: The currently selected language option. diff --git a/BitwardenShared/UI/Tools/Send/Send/SendCoordinator.swift b/BitwardenShared/UI/Tools/Send/Send/SendCoordinator.swift index 2749978b4f..c9843c2829 100644 --- a/BitwardenShared/UI/Tools/Send/Send/SendCoordinator.swift +++ b/BitwardenShared/UI/Tools/Send/Send/SendCoordinator.swift @@ -87,6 +87,8 @@ final class SendCoordinator: Coordinator, HasStackNavigator { showList() case .premiumUpgrade: showPremiumUpgrade() + case .premiumUpgradeComplete: + showPremiumUpgradeCompleteScreen() case let .share(url): showShareSheet(for: [url]) case let .viewItem(sendView): @@ -169,6 +171,17 @@ final class SendCoordinator: Coordinator, HasStackNavigator { stackNavigator?.present(navigationController) } + /// Shows a standalone Premium upgrade complete screen, for when an upgrade resolves outside + /// of the upgrade screen itself (e.g. a "Sync Now" retry succeeding after the upgrade screen + /// has already been dismissed). + /// + private func showPremiumUpgradeCompleteScreen() { + let navigationController = module.makeNavigationController() + let coordinator = module.makeBillingCoordinator(stackNavigator: navigationController) + coordinator.navigate(to: .premiumUpgradeCompleteStandalone) + stackNavigator?.present(navigationController) + } + /// Presents the system share sheet for the specified items. /// /// - Parameter items: The items to share using the system share sheet. diff --git a/BitwardenShared/UI/Tools/Send/Send/SendCoordinatorTests.swift b/BitwardenShared/UI/Tools/Send/Send/SendCoordinatorTests.swift index 7428319896..672e2d5153 100644 --- a/BitwardenShared/UI/Tools/Send/Send/SendCoordinatorTests.swift +++ b/BitwardenShared/UI/Tools/Send/Send/SendCoordinatorTests.swift @@ -166,6 +166,18 @@ class SendCoordinatorTests: BitwardenTestCase { XCTAssertEqual(module.billingCoordinator.routes, [.premiumUpgrade]) } + /// `navigate(to:)` with `.premiumUpgradeComplete` presents a standalone Premium upgrade + /// complete screen via the billing coordinator. + @MainActor + func test_navigateTo_premiumUpgradeComplete() throws { + subject.navigate(to: .premiumUpgradeComplete) + + let action = try XCTUnwrap(stackNavigator.actions.last) + XCTAssertEqual(action.type, .presented) + XCTAssertTrue(action.view is UINavigationController) + XCTAssertEqual(module.billingCoordinator.routes, [.premiumUpgradeCompleteStandalone]) + } + /// `navigate(to:)` with `.share` presents the share sheet. @MainActor func test_navigateTo_share() throws { diff --git a/BitwardenShared/UI/Tools/Send/Send/SendRoute.swift b/BitwardenShared/UI/Tools/Send/Send/SendRoute.swift index bfa1cd96a1..beb6592e0d 100644 --- a/BitwardenShared/UI/Tools/Send/Send/SendRoute.swift +++ b/BitwardenShared/UI/Tools/Send/Send/SendRoute.swift @@ -31,6 +31,11 @@ public enum SendRoute: Equatable { /// A route to the Premium upgrade screen. case premiumUpgrade + /// A route to a standalone Premium upgrade complete screen, shown when an upgrade resolves + /// outside of the upgrade screen itself (e.g. a "Sync Now" retry succeeding after the + /// upgrade screen has already been dismissed). + case premiumUpgradeComplete + /// A route to share the provided URL. /// /// - Parameter url: The `URL` to share. diff --git a/BitwardenShared/UI/Tools/Send/SendItem/SendItemCoordinator.swift b/BitwardenShared/UI/Tools/Send/SendItem/SendItemCoordinator.swift index b2abb0bdb9..dd78ce5f6d 100644 --- a/BitwardenShared/UI/Tools/Send/SendItem/SendItemCoordinator.swift +++ b/BitwardenShared/UI/Tools/Send/SendItem/SendItemCoordinator.swift @@ -106,6 +106,8 @@ final class SendItemCoordinator: Coordinator, HasStackNavigator, ProfileSwitcher showGenerator(delegate: delegate) case .premiumUpgrade: showPremiumUpgrade() + case .premiumUpgradeComplete: + showPremiumUpgradeCompleteScreen() case let .share(url): showShareSheet(for: [url]) case let .view(sendView): @@ -236,6 +238,17 @@ final class SendItemCoordinator: Coordinator, HasStackNavigator, ProfileSwitcher stackNavigator?.present(navigationController) } + /// Shows a standalone Premium upgrade complete screen, for when an upgrade resolves outside + /// of the upgrade screen itself (e.g. a "Sync Now" retry succeeding after the upgrade screen + /// has already been dismissed). + /// + private func showPremiumUpgradeCompleteScreen() { + let navigationController = module.makeNavigationController() + let coordinator = module.makeBillingCoordinator(stackNavigator: navigationController) + coordinator.navigate(to: .premiumUpgradeCompleteStandalone) + stackNavigator?.present(navigationController) + } + /// Presents the system share sheet for the specified items. /// /// - Parameter items: The items to share using the system share sheet. diff --git a/BitwardenShared/UI/Tools/Send/SendItem/SendItemCoordinatorTests.swift b/BitwardenShared/UI/Tools/Send/SendItem/SendItemCoordinatorTests.swift index 37e49815b3..e0396ecf5a 100644 --- a/BitwardenShared/UI/Tools/Send/SendItem/SendItemCoordinatorTests.swift +++ b/BitwardenShared/UI/Tools/Send/SendItem/SendItemCoordinatorTests.swift @@ -209,6 +209,17 @@ class SendItemCoordinatorTests: BitwardenTestCase { XCTAssertNil(stackNavigator.actions.last) } + /// `navigate(to:)` with `.premiumUpgradeComplete` presents a standalone Premium upgrade + /// complete screen via the billing coordinator. + @MainActor + func test_navigateTo_premiumUpgradeComplete() throws { + subject.navigate(to: .premiumUpgradeComplete) + + let action = try XCTUnwrap(stackNavigator.actions.last) + XCTAssertEqual(action.type, .presented) + XCTAssertEqual(module.billingCoordinator.routes, [.premiumUpgradeCompleteStandalone]) + } + /// `navigate(to:)` with `.view` shows the view send screen. @MainActor func test_navigateTo_view() throws { diff --git a/BitwardenShared/UI/Tools/Send/SendItem/SendItemRoute.swift b/BitwardenShared/UI/Tools/Send/SendItem/SendItemRoute.swift index a29c8c7c6c..8e4e0cb97c 100644 --- a/BitwardenShared/UI/Tools/Send/SendItem/SendItemRoute.swift +++ b/BitwardenShared/UI/Tools/Send/SendItem/SendItemRoute.swift @@ -65,6 +65,11 @@ public enum SendItemRoute: Equatable, Hashable { /// A route to the Premium upgrade screen. case premiumUpgrade + /// A route to a standalone Premium upgrade complete screen, shown when an upgrade resolves + /// outside of the upgrade screen itself (e.g. a "Sync Now" retry succeeding after the + /// upgrade screen has already been dismissed). + case premiumUpgradeComplete + /// A route to share the provided URL. /// /// - Parameter url: The `URL` to share. diff --git a/BitwardenShared/UI/Vault/Vault/VaultCoordinator.swift b/BitwardenShared/UI/Vault/Vault/VaultCoordinator.swift index c554d04890..8f9b5b6b9b 100644 --- a/BitwardenShared/UI/Vault/Vault/VaultCoordinator.swift +++ b/BitwardenShared/UI/Vault/Vault/VaultCoordinator.swift @@ -269,6 +269,8 @@ final class VaultCoordinator: Coordinator, HasStackNavigator { // swiftlint:disa delegate?.switchToSettingsTab(route: .premiumPlan(nil)) case .premiumUpgrade: showPremiumUpgrade() + case .premiumUpgradeComplete: + showPremiumUpgradeCompleteScreen() case let .vaultItemSelection(totpKeyModel): showVaultItemSelection(totpKeyModel: totpKeyModel) case let .viewItem(id, masterPasswordRepromptCheckCompleted): @@ -431,6 +433,17 @@ final class VaultCoordinator: Coordinator, HasStackNavigator { // swiftlint:disa stackNavigator?.present(navigationController) } + /// Shows a standalone Premium upgrade complete screen, for when an upgrade resolves outside + /// of the upgrade screen itself (e.g. a "Sync Now" retry succeeding after the upgrade screen + /// has already been dismissed). + /// + private func showPremiumUpgradeCompleteScreen() { + let navigationController = module.makeNavigationController() + let coordinator = module.makeBillingCoordinator(stackNavigator: navigationController) + coordinator.navigate(to: .premiumUpgradeCompleteStandalone) + stackNavigator?.present(navigationController) + } + /// Shows the vault list screen. /// private func showList() { diff --git a/BitwardenShared/UI/Vault/Vault/VaultCoordinatorTests.swift b/BitwardenShared/UI/Vault/Vault/VaultCoordinatorTests.swift index 23dceef66c..c06e012b46 100644 --- a/BitwardenShared/UI/Vault/Vault/VaultCoordinatorTests.swift +++ b/BitwardenShared/UI/Vault/Vault/VaultCoordinatorTests.swift @@ -413,6 +413,17 @@ class VaultCoordinatorTests: BitwardenTestCase { // swiftlint:disable:this type_ XCTAssertEqual(module.billingCoordinator.routes, [.premiumUpgrade]) } + /// `navigate(to:)` with `.premiumUpgradeComplete` presents a standalone Premium upgrade + /// complete screen via the billing coordinator. + @MainActor + func test_navigateTo_premiumUpgradeComplete() throws { + subject.navigate(to: .premiumUpgradeComplete) + + let action = try XCTUnwrap(stackNavigator.actions.last) + XCTAssertEqual(action.type, .presented) + XCTAssertEqual(module.billingCoordinator.routes, [.premiumUpgradeCompleteStandalone]) + } + /// `navigate(to:)` with `.switchAccount(userId:, isUnlocked: isUnlocked)`calls the associated delegate method. @MainActor func test_navigateTo_switchAccount() throws { diff --git a/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessor.swift b/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessor.swift index a78f74af0d..5211a16f69 100644 --- a/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessor.swift +++ b/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessor.swift @@ -763,7 +763,13 @@ extension VaultListProcessor { // transition rather than a duplicate, or this explicit, user-initiated // retry would fail with no feedback at all. lastAttemptFailed = false - await self?.services.billingService.premiumStatusChanged() + guard let self else { return } + await PremiumUpgradeRetry.retryAndShowCompleteIfResolved( + billingService: services.billingService, + coordinator: coordinator, + ) { + await self.services.billingService.premiumStatusChanged() + } }) } lastAttemptFailed = pendingState.lastAttemptFailed diff --git a/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessorTests.swift b/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessorTests.swift index dab1da89ae..865efc71b0 100644 --- a/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessorTests.swift +++ b/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessorTests.swift @@ -1321,6 +1321,10 @@ class VaultListProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ PremiumUpgradePendingState(isPending: true, lastAttemptFailed: true), ) billingService.premiumUpgradePendingStatePublisherReturnValue = pendingStateSubject.eraseToAnyPublisher() + billingService.premiumUpgradePendingStateReturnValue = PremiumUpgradePendingState( + isPending: true, + lastAttemptFailed: false, + ) let task = Task { await subject.perform(.streamPremiumUpgradePendingState) @@ -1334,6 +1338,41 @@ class VaultListProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ try await alert.tapAction(title: Localizations.tryAgain) } waitFor(billingService.premiumStatusChangedCallsCount == 1) + + // Still pending (not yet Premium) — no celebration screen, matching "Sync Now"'s own + // still-pending boundary. + XCTAssertNotEqual(coordinator.routes.last, .premiumUpgradeComplete) + } + + /// `perform(_:)` with `.streamPremiumUpgradePendingState` navigates to the standalone Premium + /// upgrade complete screen when the user's own "Try again" retry actually resolves the + /// pending upgrade — matching "Sync Now"'s own behavior on the pending alert, so the two + /// explicit retry entry points behave identically. + @MainActor + func test_perform_streamPremiumUpgradePendingState_syncUnsuccessfulAlert_tryAgainResolves() throws { + let pendingStateSubject = CurrentValueSubject( + PremiumUpgradePendingState(isPending: true, lastAttemptFailed: true), + ) + billingService.premiumUpgradePendingStatePublisherReturnValue = pendingStateSubject.eraseToAnyPublisher() + billingService.premiumUpgradePendingStateReturnValue = PremiumUpgradePendingState( + isPending: false, + lastAttemptFailed: false, + ) + + let task = Task { + await subject.perform(.streamPremiumUpgradePendingState) + } + defer { task.cancel() } + + waitFor(!coordinator.alertShown.isEmpty) + let alert = try XCTUnwrap(coordinator.alertShown.last) + + Task { + try await alert.tapAction(title: Localizations.tryAgain) + } + waitFor(coordinator.routes.last == .premiumUpgradeComplete) + + XCTAssertEqual(billingService.premiumStatusChangedCallsCount, 1) } /// `perform(_:)` with `.streamPremiumUpgradePendingState` re-shows the "Sync Unsuccessful" @@ -1345,6 +1384,10 @@ class VaultListProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ PremiumUpgradePendingState(isPending: true, lastAttemptFailed: true), ) billingService.premiumUpgradePendingStatePublisherReturnValue = pendingStateSubject.eraseToAnyPublisher() + billingService.premiumUpgradePendingStateReturnValue = PremiumUpgradePendingState( + isPending: true, + lastAttemptFailed: true, + ) billingService.premiumStatusChangedClosure = { pendingStateSubject.send(PremiumUpgradePendingState(isPending: true, lastAttemptFailed: true)) } @@ -1363,6 +1406,7 @@ class VaultListProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ waitFor(coordinator.alertShown.count == 2) XCTAssertEqual(coordinator.alertShown.last?.title, Localizations.syncUnsuccessful) + XCTAssertNotEqual(coordinator.routes.last, .premiumUpgradeComplete) } /// `perform(_:)` with `.streamShowWebIcons` requests the value of the show diff --git a/BitwardenShared/UI/Vault/Vault/VaultRoute.swift b/BitwardenShared/UI/Vault/Vault/VaultRoute.swift index bccb72e461..76750ed4bd 100644 --- a/BitwardenShared/UI/Vault/Vault/VaultRoute.swift +++ b/BitwardenShared/UI/Vault/Vault/VaultRoute.swift @@ -77,6 +77,11 @@ public enum VaultRoute: Equatable, Hashable { /// A route to the Premium upgrade view. case premiumUpgrade + /// A route to a standalone Premium upgrade complete screen, shown when an upgrade resolves + /// outside of the upgrade screen itself (e.g. a "Sync Now" retry succeeding after the + /// upgrade screen has already been dismissed). + case premiumUpgradeComplete + /// A route to switch accounts. /// /// - Parameter userId: The user id of the selected account. diff --git a/BitwardenShared/UI/Vault/VaultItem/VaultItemCoordinator.swift b/BitwardenShared/UI/Vault/VaultItem/VaultItemCoordinator.swift index 7eeaf3e26b..fea8dd7f7a 100644 --- a/BitwardenShared/UI/Vault/VaultItem/VaultItemCoordinator.swift +++ b/BitwardenShared/UI/Vault/VaultItem/VaultItemCoordinator.swift @@ -143,6 +143,8 @@ class VaultItemCoordinator: NSObject, Coordinator, HasStackNavigator { // swiftl showPasswordHistory(passwordHistory) case .premiumUpgrade: showPremiumUpgrade() + case .premiumUpgradeComplete: + showPremiumUpgradeCompleteScreen() case let .saveFile(temporaryUrl): showSaveFile(temporaryUrl) case .setupTotpManual: @@ -462,6 +464,17 @@ class VaultItemCoordinator: NSObject, Coordinator, HasStackNavigator { // swiftl stackNavigator?.present(navigationController) } + /// Shows a standalone Premium upgrade complete screen, for when an upgrade resolves outside + /// of the upgrade screen itself (e.g. a "Sync Now" retry succeeding after the upgrade screen + /// has already been dismissed). + /// + private func showPremiumUpgradeCompleteScreen() { + let navigationController = module.makeNavigationController() + let coordinator = module.makeBillingCoordinator(stackNavigator: navigationController) + coordinator.navigate(to: .premiumUpgradeCompleteStandalone) + stackNavigator?.present(navigationController) + } + /// Present the `UIDocumentPickerViewController` that allows users to save the newly downloaded file. /// /// - Parameter temporaryUrl: The temporary url where the file is currently stored. diff --git a/BitwardenShared/UI/Vault/VaultItem/VaultItemCoordinatorTests.swift b/BitwardenShared/UI/Vault/VaultItem/VaultItemCoordinatorTests.swift index 3691ee813c..4a13176e80 100644 --- a/BitwardenShared/UI/Vault/VaultItem/VaultItemCoordinatorTests.swift +++ b/BitwardenShared/UI/Vault/VaultItem/VaultItemCoordinatorTests.swift @@ -456,6 +456,17 @@ class VaultItemCoordinatorTests: BitwardenTestCase { // swiftlint:disable:this t XCTAssertEqual(module.billingCoordinator.routes, [.premiumUpgrade]) } + /// `navigate(to:)` with `.premiumUpgradeComplete` presents a standalone Premium upgrade + /// complete screen via the billing coordinator. + @MainActor + func test_navigateTo_premiumUpgradeComplete() throws { + subject.navigate(to: .premiumUpgradeComplete) + + let action = try XCTUnwrap(stackNavigator.actions.last) + XCTAssertEqual(action.type, .presented) + XCTAssertEqual(module.billingCoordinator.routes, [.premiumUpgradeCompleteStandalone]) + } + /// `navigate(to:)` with `.setupTotpCamera` with context without conformance fails to present. @MainActor func test_navigateTo_setupTotpCamera_noConformance() async throws { diff --git a/BitwardenShared/UI/Vault/VaultItem/VaultItemRoute.swift b/BitwardenShared/UI/Vault/VaultItem/VaultItemRoute.swift index 05b9fca649..befaecf781 100644 --- a/BitwardenShared/UI/Vault/VaultItem/VaultItemRoute.swift +++ b/BitwardenShared/UI/Vault/VaultItem/VaultItemRoute.swift @@ -88,6 +88,11 @@ enum VaultItemRoute: Equatable, Hashable { /// A route to the Premium upgrade screen. case premiumUpgrade + /// A route to a standalone Premium upgrade complete screen, shown when an upgrade resolves + /// outside of the upgrade screen itself (e.g. a "Sync Now" retry succeeding after the + /// upgrade screen has already been dismissed). + case premiumUpgradeComplete + /// A route to the file saving view. /// /// - Parameter temporaryUrl: The url where the file is currently stored.