Skip to content

Commit c31d3b7

Browse files
facumenzellaclaude
andauthored
Add close_workflow button action (#6753)
* feat: add close_workflow button action Ports RevenueCat/purchases-android#3453 to iOS. Adds a new `close_workflow` button action that always dismisses the entire paywall, regardless of workflow step or whether triggered from inside a sheet. The action flows through the existing pipeline: - `PaywallComponent.ButtonComponent.Action.closeWorkflow` (serialization) - `ButtonComponentViewModel.Action.closeWorkflow` (view model) - `closeWorkflowAction` environment key (set at RootView, propagates through sheets where local onDismiss only closes the sheet) - `ButtonComponentView` reads the env key and calls it on tap Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: preserve close_workflow behavior without public API change * fix: make close_workflow dismiss the full workflow * fix: include close workflow marker in button equality * fix: apply closeWorkflowAction after bottomSheet so sheets inherit it The environment modifier must be outer to bottomSheet — views created inside BottomSheetOverlayModifier.body are siblings of content and only inherit from the modifier's outer context, not from inner modifiers on the content itself. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: add close_workflow mapping and equality regression tests - ButtonComponentCodableTests: assert that close_workflow and navigate_back buttons with identical .action values are nonetheless unequal via isCloseWorkflowAction (Equatable and Hashable both verified) - ButtonComponentViewModelMappingTests (new file): assert that a decoded close_workflow button maps to .closeWorkflow in the view model (interaction value "close_workflow"), and navigate_back maps to .navigateBack Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: add ButtonComponentViewModelMappingTests.swift to RevenueCat.xcodeproj Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: add ButtonComponentViewModelMappingTests.swift file reference to xcodeproj Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Review fixes: consistent optional chain, single-decode for close_workflow, drop fragile hash assertion Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Simplify: set closeWorkflowAction env in LoadedPaywallsV2View, drop param from ComponentsView and RootView Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f80647f commit c31d3b7

12 files changed

Lines changed: 245 additions & 7 deletions

File tree

RevenueCat.xcodeproj/project.pbxproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2866,6 +2866,7 @@
28662866
FACD00072F61A1230073D2DE /* PackageComponentViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PackageComponentViewTests.swift; sourceTree = "<group>"; };
28672867
FACD00092F61A1230073D2DE /* PackageValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PackageValidatorTests.swift; sourceTree = "<group>"; };
28682868
FACD000B2F61A1230073D2DE /* PackageVisibilityPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PackageVisibilityPreview.swift; sourceTree = "<group>"; };
2869+
0E4CCD8E9E454A2EB3760E06 /* ButtonComponentViewModelMappingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonComponentViewModelMappingTests.swift; sourceTree = "<group>"; };
28692870
FB5A72012F65F5B9005C64A1 /* ViewModelFactoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelFactoryTests.swift; sourceTree = "<group>"; };
28702871
FD05A7192EE2430E00FE671F /* StoreKit2PromotionalOfferPurchaseOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreKit2PromotionalOfferPurchaseOptions.swift; sourceTree = "<group>"; };
28712872
FD15AF572DF9A8F30062467E /* VirtualCurrenciesScrollViewWithOSBackgroundSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VirtualCurrenciesScrollViewWithOSBackgroundSection.swift; sourceTree = "<group>"; };
@@ -3066,6 +3067,7 @@
30663067
DB1FC9592F9A1B74009A95EA /* WorkflowScreenMapperTests.swift */,
30673068
F468C7BCEB786BEC80277ECA /* WorkflowNavigatorTests.swift */,
30683069
6EFD5F7BD8404B6E828C4E10 /* WorkflowPaywallViewTests.swift */,
3070+
0E4CCD8E9E454A2EB3760E06 /* ButtonComponentViewModelMappingTests.swift */,
30693071
);
30703072
path = PaywallsV2;
30713073
sourceTree = "<group>";

RevenueCatUI/Data/Strings.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ enum Strings {
120120
// Conditional Configurability
121121
case paywall_contains_unsupported_condition
122122
case workflow_paywall_invalid_state(currentStepId: String, screenId: String?)
123+
case paywall_close_workflow_action_not_handled(componentName: String?)
123124
case paywall_workflow_trigger_not_handled(componentName: String?)
124125
case workflow_package_context_unresolvable(stepId: String)
125126

@@ -389,6 +390,9 @@ extension Strings: CustomStringConvertible {
389390
case let .workflow_paywall_invalid_state(currentStepId, screenId):
390391
return "Workflow paywall could not resolve the current screen. " +
391392
"currentStepId=\(currentStepId), screenId=\(screenId ?? "nil")"
393+
case let .paywall_close_workflow_action_not_handled(componentName):
394+
return "Close workflow button was tapped but no close workflow action was available. " +
395+
"componentName=\(componentName ?? "nil")"
392396
case let .paywall_workflow_trigger_not_handled(componentName):
393397
return "Workflow trigger button was tapped but no matching workflow action was available. " +
394398
"componentName=\(componentName ?? "nil")"

RevenueCatUI/Modifiers/EnvironmentValues+Workflow.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ private struct WorkflowTriggerActionKey: EnvironmentKey {
3232
static let defaultValue: ((String) -> Bool)? = nil
3333
}
3434

35+
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
36+
private struct CloseWorkflowActionKey: EnvironmentKey {
37+
static let defaultValue: (() -> Void)? = nil
38+
}
39+
3540
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
3641
private struct WorkflowPageTransitionContextKey: EnvironmentKey {
3742
static let defaultValue = WorkflowPageTransitionContext.identity
@@ -74,6 +79,14 @@ extension EnvironmentValues {
7479
get { self[WorkflowPackageContextKey.self] }
7580
set { self[WorkflowPackageContextKey.self] = newValue }
7681
}
82+
83+
/// Dismisses the entire paywall, bypassing any intermediate workflow step or sheet.
84+
/// Set at the outermost paywall view so it remains accessible from nested contexts (e.g. sheets)
85+
/// where the local `onDismiss` only closes the sheet.
86+
var closeWorkflowAction: (() -> Void)? {
87+
get { self[CloseWorkflowActionKey.self] }
88+
set { self[CloseWorkflowActionKey.self] = newValue }
89+
}
7790
}
7891

7992
#endif

RevenueCatUI/Templates/V2/Components/Button/ButtonComponentView.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ struct ButtonComponentView: View {
3535

3636
@Environment(\.componentInteractionLogger) var componentInteractionLogger
3737
@Environment(\.workflowTriggerAction) private var workflowTriggerAction
38+
@Environment(\.closeWorkflowAction) private var closeWorkflowAction
3839
@Environment(\.workflowPageTransitionContext) private var workflowPageTransitionContext
3940
@Environment(\.isWorkflowHeader) private var isWorkflowHeader
4041

@@ -115,6 +116,14 @@ struct ButtonComponentView: View {
115116
navigateTo(destination: destination)
116117
case .navigateBack:
117118
onDismiss()
119+
case .closeWorkflow:
120+
if let closeWorkflowAction {
121+
closeWorkflowAction()
122+
} else {
123+
Logger.warning(
124+
Strings.paywall_close_workflow_action_not_handled(componentName: self.viewModel.component.name)
125+
)
126+
}
118127
case .workflowTrigger:
119128
Logger.warning(
120129
Strings.paywall_workflow_trigger_not_handled(componentName: self.viewModel.component.name)

RevenueCatUI/Templates/V2/Components/Button/ButtonComponentViewModel.swift

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class ButtonComponentViewModel {
2727
case sheet(RevenueCat.PaywallComponent.ButtonComponent.Sheet)
2828
case navigateBack
2929
case workflowTrigger
30+
case closeWorkflow
3031
case unknown
3132
}
3233

@@ -99,7 +100,7 @@ class ButtonComponentViewModel {
99100
self.action = .sheet(sheet)
100101
}
101102
case .navigateBack:
102-
self.action = .navigateBack
103+
self.action = component.isCloseWorkflowAction ? .closeWorkflow : .navigateBack
103104
case .workflowTrigger:
104105
self.action = .workflowTrigger
105106
case .unknown:
@@ -135,6 +136,8 @@ class ButtonComponentViewModel {
135136
return false
136137
case .workflowTrigger:
137138
return false
139+
case .closeWorkflow:
140+
return false
138141
case .unknown:
139142
return false
140143
case .sheet:
@@ -155,6 +158,8 @@ extension ButtonComponentViewModel.Action {
155158
return "navigate_back"
156159
case .workflowTrigger:
157160
return "workflow_trigger"
161+
case .closeWorkflow:
162+
return "close_workflow"
158163
case .unknown:
159164
return "unknown"
160165
case .sheet:
@@ -168,7 +173,7 @@ extension ButtonComponentViewModel.Action {
168173
switch self {
169174
case .navigateTo(let destination):
170175
return destination.paywallComponentInteractionURL
171-
case .restorePurchases, .navigateBack, .workflowTrigger, .unknown, .sheet:
176+
case .restorePurchases, .navigateBack, .workflowTrigger, .closeWorkflow, .unknown, .sheet:
172177
return nil
173178
}
174179
}

RevenueCatUI/Templates/V2/Components/ComponentsView.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,11 @@ struct ComponentsView: View {
6161
private func view(for item: PaywallComponentViewModel) -> some View {
6262
switch item {
6363
case .root(let viewModel):
64-
RootView(viewModel: viewModel, onDismiss: onDismiss, defaultPackage: defaultPackage)
64+
RootView(
65+
viewModel: viewModel,
66+
onDismiss: onDismiss,
67+
defaultPackage: defaultPackage
68+
)
6569
case .text(let viewModel):
6670
TextComponentView(viewModel: viewModel)
6771
case .image(let viewModel):

RevenueCatUI/Templates/V2/PaywallsV2View.swift

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ struct PaywallsV2View: View {
7171
/// default paywall is shown. This is not used in the success path
7272
private let displayCloseButton: Bool
7373
private let onDismiss: () -> Void
74+
private let closeWorkflowAction: (() -> Void)?
7475
@State
7576
private var didFinishEligibilityCheck: Bool = false
7677

@@ -90,6 +91,7 @@ struct PaywallsV2View: View {
9091
workflowPackages: [Package]? = nil,
9192
displayCloseButton: Bool = false,
9293
onDismiss: @escaping () -> Void,
94+
closeWorkflowAction: (() -> Void)? = nil,
9395
failedToLoadFont: @escaping UIConfigProvider.FailedToLoadFont,
9496
colorScheme: ColorScheme,
9597
promoOfferCache: PaywallPromoOfferCache? = nil,
@@ -110,6 +112,7 @@ struct PaywallsV2View: View {
110112
self.showZeroDecimalPlacePrices = showZeroDecimalPlacePrices
111113
self.displayCloseButton = displayCloseButton
112114
self.onDismiss = onDismiss
115+
self.closeWorkflowAction = closeWorkflowAction
113116
self._paywallPromoOfferCache = .init(wrappedValue: promoOfferCache ?? PaywallPromoOfferCache(
114117
subscriptionHistoryTracker: purchaseHandler.subscriptionHistoryTracker
115118
))
@@ -193,7 +196,8 @@ struct PaywallsV2View: View {
193196
uiConfigProvider: self.uiConfigProvider,
194197
selectedPackageContext: self.selectedPackageContext,
195198
defaultPackage: defaultPackage,
196-
onDismiss: self.onDismiss
199+
onDismiss: self.onDismiss,
200+
closeWorkflowAction: self.closeWorkflowAction
197201
)
198202
.environment(\.locale, contentLocale)
199203
.environment(\.layoutDirection, contentLocale.swiftUILayoutDirection)
@@ -366,6 +370,7 @@ private struct LoadedPaywallsV2View: View {
366370
private let paywallState: PaywallState
367371
private let uiConfigProvider: UIConfigProvider
368372
private let onDismiss: () -> Void
373+
private let closeWorkflowAction: (() -> Void)?
369374
private let defaultPackage: Package?
370375

371376
@ObservedObject
@@ -377,14 +382,16 @@ private struct LoadedPaywallsV2View: View {
377382
uiConfigProvider: UIConfigProvider,
378383
selectedPackageContext: PackageContext,
379384
defaultPackage: Package?,
380-
onDismiss: @escaping () -> Void
385+
onDismiss: @escaping () -> Void,
386+
closeWorkflowAction: (() -> Void)? = nil
381387
) {
382388
self.introOfferEligibilityContext = introOfferEligibilityContext
383389
self.paywallState = paywallState
384390
self.uiConfigProvider = uiConfigProvider
385391
self.selectedPackageContext = selectedPackageContext
386392
self.defaultPackage = defaultPackage
387393
self.onDismiss = onDismiss
394+
self.closeWorkflowAction = closeWorkflowAction
388395
}
389396

390397
var body: some View {
@@ -396,6 +403,7 @@ private struct LoadedPaywallsV2View: View {
396403
defaultPackage: self.defaultPackage
397404
)
398405
.fixMacButtons()
406+
.environment(\.closeWorkflowAction, self.closeWorkflowAction ?? self.onDismiss)
399407
}
400408
// Used for header image and sticky footer
401409
.environment(\.safeAreaInsets, proxy.safeAreaInsets)

RevenueCatUI/Templates/V2/WorkflowPaywallView.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,7 @@ struct WorkflowPaywallView: View {
250250
workflowPackages: self.workflowPackageContext?.packages,
251251
displayCloseButton: page.showCloseButton,
252252
onDismiss: self.handleDismiss,
253+
closeWorkflowAction: self.onDismiss,
253254
failedToLoadFont: self.failedToLoadFont,
254255
colorScheme: self.colorScheme,
255256
promoOfferCache: self.promoOfferCache

Sources/Paywalls/Components/PaywallButtonComponent.swift

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import Foundation
2525
public let action: Action
2626
public let stack: PaywallComponent.StackComponent
2727
public let transition: PaywallComponent.Transition?
28+
/// Preserves the backend-only `close_workflow` action without growing the public enum surface.
29+
@_spi(Internal) public let isCloseWorkflowAction: Bool
2830

2931
public init(
3032
name: String? = nil,
@@ -39,6 +41,7 @@ import Foundation
3941
self.action = action
4042
self.stack = stack
4143
self.transition = transition
44+
self.isCloseWorkflowAction = false
4245
}
4346

4447
private enum CodingKeys: String, CodingKey {
@@ -50,12 +53,24 @@ import Foundation
5053
case transition
5154
}
5255

56+
private enum ActionCodingKeys: String, CodingKey {
57+
case type
58+
}
59+
5360
required public init(from decoder: Decoder) throws {
5461
let container = try decoder.container(keyedBy: CodingKeys.self)
5562
self.type = try container.decode(ComponentType.self, forKey: .type)
5663
self.name = try container.decodeIfPresent(String.self, forKey: .name)
5764
self.id = try container.decodeIfPresent(String.self, forKey: .id)
58-
self.action = try container.decode(Action.self, forKey: .action)
65+
let actionContainer = try container.nestedContainer(keyedBy: ActionCodingKeys.self, forKey: .action)
66+
let rawActionType = try actionContainer.decode(String.self, forKey: .type)
67+
if rawActionType == "close_workflow" {
68+
self.isCloseWorkflowAction = true
69+
self.action = .navigateBack
70+
} else {
71+
self.isCloseWorkflowAction = false
72+
self.action = try container.decode(Action.self, forKey: .action)
73+
}
5974
self.stack = try container.decode(PaywallComponent.StackComponent.self, forKey: .stack)
6075
self.transition = try container.decodeIfPresent(PaywallComponent.Transition.self, forKey: .transition)
6176
}
@@ -65,7 +80,12 @@ import Foundation
6580
try container.encode(type, forKey: .type)
6681
try container.encodeIfPresent(name, forKey: .name)
6782
try container.encodeIfPresent(id, forKey: .id)
68-
try container.encode(action, forKey: .action)
83+
if self.isCloseWorkflowAction {
84+
var actionContainer = container.nestedContainer(keyedBy: ActionCodingKeys.self, forKey: .action)
85+
try actionContainer.encode("close_workflow", forKey: .type)
86+
} else {
87+
try container.encode(action, forKey: .action)
88+
}
6989
try container.encode(stack, forKey: .stack)
7090
try container.encodeIfPresent(transition, forKey: .transition)
7191
}
@@ -75,6 +95,7 @@ import Foundation
7595
hasher.combine(name)
7696
hasher.combine(id)
7797
hasher.combine(action)
98+
hasher.combine(isCloseWorkflowAction)
7899
hasher.combine(stack)
79100
hasher.combine(transition)
80101
}
@@ -84,6 +105,7 @@ import Foundation
84105
lhs.name == rhs.name &&
85106
lhs.id == rhs.id &&
86107
lhs.action == rhs.action &&
108+
lhs.isCloseWorkflowAction == rhs.isCloseWorkflowAction &&
87109
lhs.stack == rhs.stack &&
88110
lhs.transition == rhs.transition
89111

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
//
2+
// Copyright RevenueCat Inc. All Rights Reserved.
3+
//
4+
// Licensed under the MIT License (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// https://opensource.org/licenses/MIT
9+
//
10+
// ButtonComponentViewModelMappingTests.swift
11+
12+
import Nimble
13+
@_spi(Internal) @testable import RevenueCat
14+
@testable import RevenueCatUI
15+
import XCTest
16+
17+
#if !os(tvOS) // For Paywalls V2
18+
19+
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
20+
final class ButtonComponentViewModelMappingTests: TestCase {
21+
22+
// MARK: - close_workflow → .closeWorkflow mapping
23+
24+
func testCloseWorkflowDecodedComponentMapsToCloseWorkflowAction() throws {
25+
let component = try self.decodeButton(actionType: "close_workflow")
26+
27+
let viewModel = try self.makeViewModel(for: component)
28+
29+
XCTAssertEqual(viewModel.action.paywallComponentInteractionValue, "close_workflow")
30+
}
31+
32+
func testNavigateBackDecodedComponentMapsToNavigateBackAction() throws {
33+
let component = try self.decodeButton(actionType: "navigate_back")
34+
35+
let viewModel = try self.makeViewModel(for: component)
36+
37+
// navigate_back without isCloseWorkflowAction must NOT map to closeWorkflow.
38+
XCTAssertEqual(viewModel.action.paywallComponentInteractionValue, "navigate_back")
39+
}
40+
41+
// MARK: - Helpers
42+
43+
private func decodeButton(actionType: String) throws -> PaywallComponent.ButtonComponent {
44+
let json = """
45+
{
46+
"type": "button",
47+
"action": { "type": "\(actionType)" },
48+
"stack": {
49+
"type": "stack",
50+
"dimension": {"type": "vertical", "alignment": "center", "distribution": "start"},
51+
"size": {"width": {"type": "fill"}, "height": {"type": "fill"}},
52+
"padding": {"top": 0, "bottom": 0, "leading": 0, "trailing": 0},
53+
"margin": {"top": 0, "bottom": 0, "leading": 0, "trailing": 0},
54+
"components": []
55+
}
56+
}
57+
"""
58+
return try JSONDecoder.default.decode(
59+
PaywallComponent.ButtonComponent.self,
60+
from: json.data(using: .utf8)!
61+
)
62+
}
63+
64+
private func makeViewModel(
65+
for component: PaywallComponent.ButtonComponent
66+
) throws -> ButtonComponentViewModel {
67+
let uiConfigProvider = UIConfigProvider(uiConfig: PreviewUIConfig.make())
68+
let stackViewModel = StackComponentViewModel(
69+
component: component.stack,
70+
viewModels: [],
71+
badgeViewModels: [],
72+
uiConfigProvider: uiConfigProvider
73+
)
74+
return try ButtonComponentViewModel(
75+
component: component,
76+
localizationProvider: LocalizationProvider(locale: .current, localizedStrings: [:]),
77+
offering: .init(
78+
identifier: "test",
79+
serverDescription: "",
80+
metadata: [:],
81+
availablePackages: [],
82+
webCheckoutUrl: nil
83+
),
84+
stackViewModel: stackViewModel
85+
)
86+
}
87+
88+
}
89+
90+
#endif

0 commit comments

Comments
 (0)