mirror of
https://github.com/RevenueCat/purchases-ios
synced 2026-11-11 22:55:55 +05:45
* 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
198 lines
8.0 KiB
Swift
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
|