Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion BitwardenShared/UI/Billing/BillingCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ class BillingCoordinator: NSObject, Coordinator, HasStackNavigator {
}
case .premiumUpgradeComplete:
showPremiumUpgradeComplete()
case .premiumUpgradeCompleteStandalone:
showPremiumUpgradeCompleteStandalone()
case let .premiumPlan(subscription):
showPremiumPlan(subscription: subscription)
case .premiumUpgrade:
Expand All @@ -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
Expand All @@ -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.
Expand Down
28 changes: 28 additions & 0 deletions BitwardenShared/UI/Billing/BillingCoordinatorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions BitwardenShared/UI/Billing/BillingRoute.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
52 changes: 50 additions & 2 deletions BitwardenShared/UI/Billing/PremiumUpgradeHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<Route: PremiumUpgradeRoute, Event>(
billingService: BillingService,
coordinator: any Coordinator<Route, Event>,
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.
Expand Down Expand Up @@ -157,12 +195,22 @@ class DefaultPremiumUpgradeHelper<Route: PremiumUpgradeRoute, Event>: 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()
}
})
}

Expand Down
50 changes: 49 additions & 1 deletion BitwardenShared/UI/Billing/PremiumUpgradeHelperTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<PremiumCheckoutStatus, Never>()
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
Expand All @@ -396,4 +444,4 @@ struct PremiumUpgradeHelperTests { // swiftlint:disable:this type_body_length

#expect(coordinator.routes.count == routeCountBeforeSend)
}
}
} // swiftlint:disable:this file_length
21 changes: 21 additions & 0 deletions BitwardenShared/UI/Platform/Settings/SettingsCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions BitwardenShared/UI/Platform/Settings/SettingsRoute.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions BitwardenShared/UI/Tools/Send/Send/SendCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions BitwardenShared/UI/Tools/Send/Send/SendCoordinatorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions BitwardenShared/UI/Tools/Send/Send/SendRoute.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading