feat(paywalls-v2): add haptic feedback on package/tab selection#7167
Draft
facumenzella wants to merge 13 commits into
Draft
feat(paywalls-v2): add haptic feedback on package/tab selection#7167facumenzella wants to merge 13 commits into
facumenzella wants to merge 13 commits into
Conversation
Covers issue #6559, scoped to Paywalls V2 with an editor-configurable per-component opt-out field, reviewed against the repo via Codex.
…ents Add optional Bool? field to TabControlButtonComponent and TabControlToggleComponent to enable/disable haptic feedback on tab selection, with support for encoding/decoding via snake_case wire format. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RYNFnRoh1Q9cj1uZari74U
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RYNFnRoh1Q9cj1uZari74U
Add haptic feedback support to tab button selection in TabControlButtonComponentView. The static shouldTriggerHapticFeedback method gates haptic firing to only occur when the tab ID actually changes (not on repeat taps of the same tab), matching native selector behavior. The existing analytics event (trackTabcomponentInteraction) continues to fire on all taps as before. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RYNFnRoh1Q9cj1uZari74U
Danger flagged TabsComponentTests.swift, PackageSelectionHapticFeedbackTests.swift, and TabControlHapticFeedbackTests.swift as missing from RevenueCat.xcodeproj. Registered them following the exact pattern their sibling files already use.
The first haptic in a process loads the haptics engine on the main thread, a one-time non-decaying cost. Firing it synchronously inside the selection handler stalled the first selection's highlight render behind that load. Warm a single persistent generator on component appear so the engine load happens off the tap's critical path, and keep it warm across selections. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011dfLyU9xkXkd38Evv2tNzb
facumenzella
force-pushed
the
facu/paywall-package-selection-haptics
branch
from
July 21, 2026 09:19
57c9bd9 to
a5a0436
Compare
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011dfLyU9xkXkd38Evv2tNzb
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
Try to tackle #6559.
Add a haptic feedback when changing selection (packages and tabs).
Description
Adds haptic feedback for package/tab selection on V2 paywalls, with a per-component opt-out since some paywalls/authors may not want it:
PackageSelectionHapticFeedback, an environment-injected struct (mirrors the existingComponentInteractionLoggerpattern) that firesUISelectionFeedbackGenerator().selectionChanged()on iOS.hapticFeedbackEnabled: Bool?onPackageComponent,TabControlButtonComponent, andTabControlToggleComponent(in case we want to add dashboard supportAI session context
AI Context
Metadata
gh pr create)facu/paywall-package-selection-hapticsGoal
Add haptic feedback when the user changes the selected package or tab on a Paywalls V2 screen (GitHub issue #6559), matching how native iOS selectors/switches behave, with a per-component opt-out the dashboard editor can use.
Initial Prompt
"This should be easy #6559, right? Take a look" — resolved to: investigate the issue, scope how invasive a fix would be, and report back before implementing.
Important Follow-up Prompts
.sensoryFeedback(.selection, trigger:)modifier.1(chose Subagent-Driven Development for plan execution) → "keep going" (x2, continuing execution through the task loop).Agent Contribution
.sensoryFeedback), then a combined-approach variant, walking through concrete tradeoffs for each before the human picked one.codex exec, read-only) for an independent pressure-test against the actual repo source before writing the plan; Codex caught two real issues (see Key Implementation Decisions).docs/superpowers/specs/2026-07-06-paywall-package-selection-haptics-design.md) and an 8-task TDD implementation plan (docs/superpowers/plans/2026-07-06-paywall-package-selection-haptics.md), doing a pre-flight scan that removed two assertion-free tests from the plan before execution.swift buildcompile-check alone (the relevant test files are#if os(iOS)-gated and report 0 tests when run via plainswift teston macOS); re-ran everything via the correctxcodebuild+iOS-Simulator invocation and confirmed all tests genuinely passed in both cases.~/Developer/rc/purchases-ios, detached HEAD) instead of the assigned worktree; reset that checkout back to its prior commit before re-dispatching with a hardened working-directory check baked into every subsequent prompt.UnitTestsandRevenueCatUITestsschemes (not just the touched test classes) and manually confirmed zero overlap between this branch's changed files/tests and the pre-existing failures in both suites (missing fixtures, snapshot environment mismatches, V1 template snapshots — none of which are in scope here).bundle exec fastlane run_api_tests; confirmed the API surface is unaffected once it ran (api/*.swiftinterfacehas zero diff on this branch).origin/mainbefore pushing, resolved a genuine (non-conflicting-in-intent) merge conflict against an unrelated same-week PR that added anid: String?field toPackageComponentat the same location, and a coincidental add/add conflict on an identically-named test file (PackageComponentTests.swift) from a different, unrelated Claude session — merged both feature's tests into one file rather than dropping either.Human Decisions
WatchTemplateView).facu/paywall-package-selection-hapticsbefore the first commit, to match repo convention.Key Implementation Decisions
UISelectionFeedbackGenerator().selectionChanged(), gated#if canImport(UIKit) && os(iOS), wrapped in an environment-injectedPackageSelectionHapticFeedbackstruct mirroringComponentInteractionLogger.Picker/UISegmentedControluse, and the environment-injection shape is the established pattern in this file for giving views a side-effect they can call without owning the mechanism (and lets tests spy on it without touching real UIKit)..sensoryFeedback(.selection, trigger:)approach (doubles the code per call site, needs a local trigger@Statedecoupled from the real selection state to avoid firing on non-tap-driven resyncs, and the declarative half can't be asserted in a unit test) — deferred to a comment for a future iOS-17-floor cleanup instead.hapticFeedbackEnabled: Bool?on all three component schema classes,nil/absent = enabled, wire keyhaptic_feedback_enabled..convertFromSnakeCase/.convertToSnakeCasedecoder/encoder strategy handles the wire mapping automatically from the plain Swift property name — confirmed by Codex, which also caught that this only applies automatically because neitherTabControlButtonComponentnorTabControlToggleComponenthas an explicitCodingKeysenum (so no custom raw value should be added there), whilePackageComponentdoes have one and needs the plaincase hapticFeedbackEnabledadded to it.trackTabcomponentInteraction) has no such guard and fires on every tap — that pre-existing behavior was deliberately left alone; only the new haptic call got a guard. The toggle'sBinding<Bool>setter is only invoked on an actual flip already, so no extra guard was needed there.Files / Symbols Touched
RevenueCatUI/Purchasing/PaywallEventTracker.swiftComponentInteractionLogger.PackageSelectionHapticFeedback,PackageSelectionHapticFeedbackKey,EnvironmentValues.packageSelectionHapticFeedback@MainActor/Sendableannotations, deliberately mirroringComponentInteractionLogger's existing (unannotated) closure signature since the project isn't in Swift 6 strict concurrency mode.Sources/Paywalls/Components/PaywallPackageComponent.swifthapticFeedbackEnabledfield toPackageComponent.PackageComponent.hapticFeedbackEnabled,PackageComponent.CodingKeys.hapticFeedbackEnabledid: String?field from a same-week main merge during rebase — both fields coexist now, review that the merge didn't drop either.Sources/Paywalls/Components/PaywallTabsComponent.swifthapticFeedbackEnabledtoTabControlButtonComponentandTabControlToggleComponent.TabControlButtonComponent.hapticFeedbackEnabled,TabControlToggleComponent.hapticFeedbackEnabledCodingKeysenum; confirm no one adds one "for consistency" later, since that would break the automatic snake_case wire mapping this relies on.RevenueCatUI/Templates/V2/Components/Packages/Package/PackageComponentViewModel.swiftcomponent.hapticFeedbackEnabled ?? trueintoPackageComponentViewModel.hapticFeedbackEnabled, matching the existing flattening pattern for sibling fields.RevenueCatUI/Templates/V2/Components/Packages/Package/PackageComponentView.swiftPackageSelectorIfNeeded(changed fromprivateto internal so it's@testable-reachable),PackageSelectorIfNeeded.shouldTriggerHapticFeedback(origin:destination:hapticFeedbackEnabled:)ifis a sibling to (not nested inside) the existing analyticsif origin?.identifier != self.package.identifier { ... }block —shouldTriggerHapticFeedbackindependently re-derives the same identifier comparison as a single self-contained testable unit. Both branches read the same capturedorigin/self.packagevalues synchronously, so there's no divergence risk, just a redundant comparison computed twice; flagged and consciously accepted rather than restructured, since the self-contained-unit shape was the actual design intent.RevenueCatUI/Templates/V2/Components/Tabs/TabControlButtonComponentView.swiftTabControlButtonComponentView.shouldTriggerHapticFeedback(originTabId:destinationTabId:hapticFeedbackEnabled:)trackTabcomponentInteractionanalytics call is deliberately left with no change-guard (fires on every tap, unchanged behavior); only the new haptic call is gated.RevenueCatUI/Templates/V2/Components/Tabs/TabControlToggleComponentView.swiftTests/RevenueCatUITests/Purchasing/PackageSelectionHapticFeedbackTests.swift(new),Tests/UnitTests/Paywalls/Components/PackageComponentTests.swift(new — merged with an unrelated same-named/same-week file for theidfield),Tests/UnitTests/Paywalls/Components/TabsComponentTests.swift(new),Tests/RevenueCatUITests/PaywallsV2/PackageComponentViewTests.swift(extended),Tests/RevenueCatUITests/PaywallsV2/TabControlHapticFeedbackTests.swift(new)PackageSelectionHapticFeedbackTests), and the "should this fire" decision logic is tested directly as pure functions (shouldTriggerHapticFeedbackvariants) — there is no end-to-end test that hosts a view, taps it, and asserts the haptic fires through the full SwiftUI dispatch chain at any of the 3 call sites (see Validation Gaps).Dependencies / Config / Migrations
None. No new public API surface — all new fields are optional with
nildefaults on@_spi(Internal)types;api/*.swiftinterfacehas zero diff on this branch (confirmed viagit diff --statand a passingbundle exec fastlane run_api_tests).Validation
swift test --filter PackageSelectionHapticFeedbackTests: 2/2 passed.xcodebuild test -workspace RevenueCat-Tuist.xcworkspace -scheme UnitTests -destination 'platform=iOS Simulator,name=iPhone 16' -only-testing:UnitTests/PackageComponentTests -only-testing:UnitTests/PackageComponentCodableTests -only-testing:UnitTests/TabsComponentTests -only-testing:UnitTests/PartialComponentTests: 17/17 passed (post-rebase, includes the merged-inid-field tests).xcodebuild test -workspace RevenueCat-Tuist.xcworkspace -scheme "RevenueCatUITests (RevenueCatTests project)" -destination 'platform=iOS Simulator,name=iPhone 16' -only-testing:RevenueCatUITests/PackageComponentViewTests -only-testing:RevenueCatUITests/TabControlHapticFeedbackTests: 10/10 passed (post-rebase).swift build: clean.swiftlint: 0 violations.UnitTestsscheme (iOS sim, pre-rebase): 3481 passed / 320 failed / 11 skipped — all 320 failures independently confirmed pre-existing/environmental (missing fixtures, snapshot env mismatches, receipt-parsing crashes), zero overlap with this branch's files or tests.RevenueCatUITestsscheme (iOS sim, pre-rebase): 1253 passed / 66 failed — all 66 are V1 template snapshots + unrelated infra, zero overlap with this branch.bundle exec fastlane run_api_tests: BUILD SUCCEEDED (after fixing an unrelated broken local gem checkout withbundle install).Validation Gaps
TabControlToggleComponentView's haptic path has zero direct test coverage (no static decision function exists there to test, unlike the other two sites) — a deliberate, reviewed asymmetry.haptic_feedback_enabled: falsesuppresses it, or that re-tapping an already-selected package/tab stays silent.Review Focus
id: String?field onPackageComponentduring rebase (from a different, already-merged PR) look correct — property order,hash(into:)/==/CodingKeysall include both fields with nothing dropped?PackageSelectorIfNeeded.shouldTriggerHapticFeedbacksibling-ifstructure (vs. nesting inside the existing analytics guard) acceptable, given both branches provably read the same captured values?UISelectionFeedbackGenerator()fresh per fire (no retained/prepared generator) inside a SwiftUIButton/Bindingclosure?Risks / Reviewer Notes
UISelectionFeedbackGenerator()is constructed fresh per fire with no.prepare()call..sensoryFeedback(.selection, trigger:)migration (see the comment left inPaywallEventTracker.swift) resolves this automatically once the deployment target allows it.PackageSelectionHapticFeedback/packageSelectionHapticFeedbackis named after "package" but also fires for tab selection.Non-goals / Out of Scope
Template2/4/5/7View.swift).WatchTemplateView.swift— watchOS has its ownWKInterfaceDevice.play(.click)haptic API, tracked as a separate follow-up if wanted.CarouselComponentView.swift— a generic pager, not inherently a package selector.haptic_feedback_enabled.Omitted Context
Full multi-turn brainstorming dialogue (option comparisons, the visual-companion offer/decline, intermediate designs that were revised before settling) was summarized rather than included verbatim. Per-task subagent implementer/reviewer transcripts (8 tasks × implementer + reviewer, plus 2 fix/re-verify cycles for process issues) were summarized into the decisions and files above rather than included in full.