Files
purchases-ios/RevenueCatUI/Modifiers/EnvironmentValues+Workflow.swift
Rick dc7ee0a7c7 feat(checkpoints): distinguish back navigation from close actions (#7685)
* Tell back navigation apart from close actions in checkpoint paywalls

* fix: keep sheet dismissals distinct from checkpoint back navigation

* refactor: model checkpoint completion explicitly

* refactor: clarify workflow dismissal handling

* refactor: clarify checkpoint execution result

* refactor: clarify back navigation resolution

* test: fix checkpoint execution test setup

* fix: preserve checkpoint back navigation through exit offers

* fix: dismiss bottom sheets locally from workflows

* Restrict workflow error handling to supported platforms

* refactor: split workflow offering resolution

* fix: compile workflow offering resolution

* Resolve offerings per checkpoint workflow step

* Fix duplicate workflow error handler initializer

* refactor: unify workflow dismissal resolution

* refactor: stage checkpoint dismissal reasons

* test: cover workflow sheet back navigation

* fix: compile checkpoint dismissal state on tvOS
2026-09-15 12:00:24 +02:00

198 lines
8.0 KiB
Swift

//
// Copyright RevenueCat Inc. All Rights Reserved.
//
// Licensed under the MIT License (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://opensource.org/licenses/MIT
//
// EnvironmentValues+Workflow.swift
@_spi(Internal) import RevenueCat
import SwiftUI
/// Describes how a workflow was dismissed.
///
/// This is shared with checkpoint presentation state, which is compiled on every supported platform.
enum WorkflowDismissalReason: Equatable {
/// The workflow was dismissed through its normal close path.
case close
/// The workflow was dismissed by navigating back from its initial step.
case navigatedBack
}
#if !os(tvOS)
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
struct WorkflowPageTransitionContext {
/// Current horizontal offset applied to the page by `WorkflowPaywallView`.
/// Header buttons use the inverse value so they stay visually fixed while page content slides.
let pageOffset: CGFloat
/// Opacity for buttons rendered inside workflow headers.
/// Header buttons fade when a header enters or leaves, but stay stable when both pages share the same header.
let headerButtonOpacity: CGFloat
/// Whether the page is currently participating in a workflow-level transition.
/// Child component entrance transitions and horizontal safe-area bleed should be suppressed while this is true.
let isTransitioning: Bool
static let identity = Self(pageOffset: 0, headerButtonOpacity: 1, isTransitioning: false)
}
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
struct WorkflowRenderingContext {
/// Transition state consumed by workflow page bodies and header controls.
let pageTransition: WorkflowPageTransitionContext
/// Whether page-owned headers should be hidden while a workflow-level header overlay is visible.
/// The header layout is preserved so body content does not jump when the overlay appears.
let pageHeaderSuppressed: Bool
/// Marks the header subtree so only header buttons consume workflow page transition context.
let isHeader: Bool
/// Whether the workflow has a step to go back to. Lets a `navigate_back` button describe itself
/// as going back rather than closing, matching where `onDismiss` will actually take the user.
let canNavigateBack: Bool
static let identity = Self()
init(
pageTransition: WorkflowPageTransitionContext = .identity,
pageHeaderSuppressed: Bool = false,
isHeader: Bool = false,
canNavigateBack: Bool = false
) {
self.pageTransition = pageTransition
self.pageHeaderSuppressed = pageHeaderSuppressed
self.isHeader = isHeader
self.canNavigateBack = canNavigateBack
}
/// For subtrees whose dismissal is local, like a bottom sheet: a `navigate_back` button there
/// closes that subtree rather than stepping back through the workflow, so it must not describe
/// itself as going back.
func withoutBackNavigation() -> Self {
return .init(
pageTransition: self.pageTransition,
pageHeaderSuppressed: self.pageHeaderSuppressed,
isHeader: self.isHeader,
canNavigateBack: false
)
}
func markingHeader() -> Self {
return .init(
pageTransition: self.pageTransition,
pageHeaderSuppressed: self.pageHeaderSuppressed,
isHeader: true,
canNavigateBack: self.canNavigateBack
)
}
}
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
private struct WorkflowTriggerActionKey: EnvironmentKey {
static let defaultValue: ((String) -> Bool)? = nil
}
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
private struct CloseWorkflowActionKey: EnvironmentKey {
static let defaultValue: (() -> Void)? = nil
}
private struct WorkflowNavigateBackHandlerKey: EnvironmentKey {
static let defaultValue: (() -> Void)? = nil
}
private struct WorkflowDismissalObserverKey: EnvironmentKey {
static let defaultValue: ((WorkflowDismissalReason) -> Void)? = nil
}
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
private struct WorkflowRenderingContextKey: EnvironmentKey {
static let defaultValue = WorkflowRenderingContext.identity
}
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
private struct WorkflowPackageContextKey: EnvironmentKey {
/// Package context from the workflow's `singleStepFallbackId` step, used by packageless
/// screens to resolve price/period template variables.
static let defaultValue: WorkflowPackageContext? = nil
}
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
private struct WorkflowExitOfferOfferingBindingKey: EnvironmentKey {
static let defaultValue: Binding<Offering?> = .constant(nil)
}
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
private struct WorkflowCompletedInSessionBindingKey: EnvironmentKey {
static let defaultValue: Binding<Bool> = .constant(false)
}
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
extension EnvironmentValues {
/// Called when a button with a component `id` is tapped inside a workflow paywall.
/// Returns `true` if the workflow consumed the trigger (navigator found a matching step),
/// `false` if not — in which case the button falls through to its normal action.
var workflowTriggerAction: ((String) -> Bool)? {
get { self[WorkflowTriggerActionKey.self] }
set { self[WorkflowTriggerActionKey.self] = newValue }
}
var workflowRenderingContext: WorkflowRenderingContext {
get { self[WorkflowRenderingContextKey.self] }
set { self[WorkflowRenderingContextKey.self] = newValue }
}
var workflowPackageContext: WorkflowPackageContext? {
get { self[WorkflowPackageContextKey.self] }
set { self[WorkflowPackageContextKey.self] = newValue }
}
/// Dismisses the entire paywall, bypassing any intermediate workflow step or sheet.
/// Set at the outermost paywall view so it remains accessible from nested contexts (e.g. sheets)
/// where the local `onDismiss` only closes the sheet.
var closeWorkflowAction: (() -> Void)? {
get { self[CloseWorkflowActionKey.self] }
set { self[CloseWorkflowActionKey.self] = newValue }
}
/// Handles a `navigate_back` action from a button inside a workflow.
///
/// The workflow injects this handler for its descendants so it can decide whether navigating
/// back means moving to a previous step or dismissing the workflow.
var workflowNavigateBackHandler: (() -> Void)? {
get { self[WorkflowNavigateBackHandlerKey.self] }
set { self[WorkflowNavigateBackHandlerKey.self] = newValue }
}
/// Notifies the host that the workflow's dismissal reason differs from the default `.close`.
///
/// `WorkflowPaywallView` currently uses this only when navigating back from its initial step.
var workflowDismissalObserver: ((WorkflowDismissalReason) -> Void)? {
get { self[WorkflowDismissalObserverKey.self] }
set { self[WorkflowDismissalObserverKey.self] = newValue }
}
/// A binding injected by `PresentingPaywallModifier` so `WorkflowPaywallView` can write the
/// resolved exit offer offering directly, bypassing the preference-key path which does not
/// propagate reliably across the sheet boundary.
var workflowExitOfferOfferingBinding: Binding<Offering?> {
get { self[WorkflowExitOfferOfferingBindingKey.self] }
set { self[WorkflowExitOfferOfferingBindingKey.self] = newValue }
}
/// A binding injected by the outer presenter so restore-driven dismissal can mark the workflow
/// as completed before the embedded workflow view disappears. Regular paywalls do not read this.
var workflowCompletedInSessionBinding: Binding<Bool> {
get { self[WorkflowCompletedInSessionBindingKey.self] }
set { self[WorkflowCompletedInSessionBindingKey.self] = newValue }
}
}
#endif