Skip to content

feat(paywalls-v2): add haptic feedback on package/tab selection#7167

Draft
facumenzella wants to merge 13 commits into
mainfrom
facu/paywall-package-selection-haptics
Draft

feat(paywalls-v2): add haptic feedback on package/tab selection#7167
facumenzella wants to merge 13 commits into
mainfrom
facu/paywall-package-selection-haptics

Conversation

@facumenzella

@facumenzella facumenzella commented Jul 8, 2026

Copy link
Copy Markdown
Member

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 existing ComponentInteractionLogger pattern) that fires UISelectionFeedbackGenerator().selectionChanged() on iOS.
  • New optional hapticFeedbackEnabled: Bool? on PackageComponent, TabControlButtonComponent, and TabControlToggleComponent (in case we want to add dashboard support

V1 templates, out of scope.

AI session context

AI Context

Metadata

  • PR: Not captured (created via gh pr create)
  • Branch: facu/paywall-package-selection-haptics
  • Author / human owner: facumenzella
  • Agent(s): Claude Code (Sonnet 5), with an independent design pressure-test pass from Codex
  • Session source: current conversation
  • Generated: 2026-07-08
  • Context document version: 1

Goal

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

  • "Ignore V1" — narrowed scope from all paywall templates (V1 + V2) down to Paywalls V2 components only.
  • "Can we combine A and C somehow?" — asked whether the environment-injected imperative approach (haptic wrapper struct) could be combined with SwiftUI's .sensoryFeedback(.selection, trigger:) modifier.
  • "Too much right? Let's then implement A, and add a comment with .sensoryFeedback availability" — rejected the combined approach after seeing the added complexity (local trigger state + dual firing paths + weaker test coverage on the declarative half), chose the pure environment-injection approach with a forward-looking comment instead.
  • "run this through codex, and once you agree let's implement it. Have in mind that we will need to add something to the editor as well" — requested an independent design review and revealed the feature needed to be dashboard-configurable, not just always-on.
  • Clarifying question resolved: "the editor" meant the dashboard Paywall Builder web app needing a new toggle, not local repo tooling — this turned "always fire the haptic" into "add an editor-configurable per-component field."
  • "Lets go" (spec approval) → 1 (chose Subagent-Driven Development for plan execution) → "keep going" (x2, continuing execution through the task loop).
  • "push, and let's open a draft PR" (finishing-a-development-branch menu selection).

Agent Contribution

  • Investigated the GitHub issue, explored the V1/V2 package-selection call sites and existing haptic infra (none existed), and scoped the change before proposing anything.
  • Brainstormed 3 architecture options (environment-injected wrapper vs. static helper vs. SwiftUI .sensoryFeedback), then a combined-approach variant, walking through concrete tradeoffs for each before the human picked one.
  • Ran the finalized design through Codex (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).
  • Wrote and committed a design spec (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.
  • Executed the plan via subagent-driven-development: a fresh implementer subagent per task (TDD, own commit) followed by an independent reviewer subagent per task, all 8 tasks approved, then one final whole-branch review on a stronger model.
  • Independently re-verified test evidence for every task rather than trusting implementer reports at face value — caught and corrected two cases where an implementer claimed "tests pass" from a swift build compile-check alone (the relevant test files are #if os(iOS)-gated and report 0 tests when run via plain swift test on macOS); re-ran everything via the correct xcodebuild+iOS-Simulator invocation and confirmed all tests genuinely passed in both cases.
  • Caught and fixed a subagent that accidentally committed Task 1's work to the user's main checkout (~/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.
  • Ran the full UnitTests and RevenueCatUITests schemes (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).
  • Diagnosed and fixed a broken local Ruby gem checkout (unrelated to this branch) that was blocking bundle exec fastlane run_api_tests; confirmed the API surface is unaffected once it ran (api/*.swiftinterface has zero diff on this branch).
  • Rebased onto origin/main before pushing, resolved a genuine (non-conflicting-in-intent) merge conflict against an unrelated same-week PR that added an id: String? field to PackageComponent at 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

  • Narrowed scope to Paywalls V2 only (dropped V1 templates and WatchTemplateView).
  • Rejected the "combine A and C" design after seeing its complexity cost, in favor of the simpler environment-injection approach with a migration comment.
  • Revealed the dashboard-editor requirement, which changed the field from "always on" to "editor-configurable, default on."
  • Chose to rename the worktree's auto-generated branch name to facu/paywall-package-selection-haptics before the first commit, to match repo convention.
  • Confirmed how to handle the subagent's stray commit in the main checkout (reset it) when asked.
  • Chose Subagent-Driven Development over inline execution for the implementation phase.
  • Chose to push and open a draft PR as the finishing step.

Key Implementation Decisions

  • Decision: UISelectionFeedbackGenerator().selectionChanged(), gated #if canImport(UIKit) && os(iOS), wrapped in an environment-injected PackageSelectionHapticFeedback struct mirroring ComponentInteractionLogger.
    • Rationale: matches the same generator UIKit's own Picker/UISegmentedControl use, 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).
    • Rejected: a static helper with no DI (harder to test without either calling real UIKit in tests or adding a protocol anyway); a combined imperative+.sensoryFeedback(.selection, trigger:) approach (doubles the code per call site, needs a local trigger @State decoupled 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.
  • Decision: hapticFeedbackEnabled: Bool? on all three component schema classes, nil/absent = enabled, wire key haptic_feedback_enabled.
    • Rationale: the dashboard editor needs to be able to turn haptics off per component (not opt authors in), and the project's global .convertFromSnakeCase/.convertToSnakeCase decoder/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 neither TabControlButtonComponent nor TabControlToggleComponent has an explicit CodingKeys enum (so no custom raw value should be added there), while PackageComponent does have one and needs the plain case hapticFeedbackEnabled added to it.
  • Decision: gate the package-card and tab-button haptic calls on an actual selection change; leave the tab-toggle path unguarded.
    • Rationale: re-tapping an already-selected package/tab must stay silent to match native behavior. Codex flagged that the existing tab-button analytics call (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's Binding<Bool> setter is only invoked on an actual flip already, so no extra guard was needed there.

Files / Symbols Touched

  • RevenueCatUI/Purchasing/PaywallEventTracker.swift
    • Why: hosts the new haptic wrapper, alongside the existing ComponentInteractionLogger.
    • Symbols: PackageSelectionHapticFeedback, PackageSelectionHapticFeedbackKey, EnvironmentValues.packageSelectionHapticFeedback
    • Review relevance: no @MainActor/Sendable annotations, deliberately mirroring ComponentInteractionLogger's existing (unannotated) closure signature since the project isn't in Swift 6 strict concurrency mode.
  • Sources/Paywalls/Components/PaywallPackageComponent.swift
    • Why: adds the hapticFeedbackEnabled field to PackageComponent.
    • Symbols: PackageComponent.hapticFeedbackEnabled, PackageComponent.CodingKeys.hapticFeedbackEnabled
    • Review relevance: this file also picked up an unrelated id: 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.swift
    • Why: adds hapticFeedbackEnabled to TabControlButtonComponent and TabControlToggleComponent.
    • Symbols: TabControlButtonComponent.hapticFeedbackEnabled, TabControlToggleComponent.hapticFeedbackEnabled
    • Review relevance: neither class has an explicit CodingKeys enum; 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.swift
    • Why: flattens component.hapticFeedbackEnabled ?? true into PackageComponentViewModel.hapticFeedbackEnabled, matching the existing flattening pattern for sibling fields.
  • RevenueCatUI/Templates/V2/Components/Packages/Package/PackageComponentView.swift
    • Why: fires the haptic on package selection.
    • Symbols: PackageSelectorIfNeeded (changed from private to internal so it's @testable-reachable), PackageSelectorIfNeeded.shouldTriggerHapticFeedback(origin:destination:hapticFeedbackEnabled:)
    • Review relevance: the haptic-firing if is a sibling to (not nested inside) the existing analytics if origin?.identifier != self.package.identifier { ... } block — shouldTriggerHapticFeedback independently re-derives the same identifier comparison as a single self-contained testable unit. Both branches read the same captured origin/self.package values 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.swift
    • Why: fires the haptic on tab-button selection.
    • Symbols: TabControlButtonComponentView.shouldTriggerHapticFeedback(originTabId:destinationTabId:hapticFeedbackEnabled:)
    • Review relevance: the pre-existing trackTabcomponentInteraction analytics 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.swift
    • Why: fires the haptic on toggle flip; simplest call site, no guard/static method needed.
  • Tests/RevenueCatUITests/Purchasing/PackageSelectionHapticFeedbackTests.swift (new), Tests/UnitTests/Paywalls/Components/PackageComponentTests.swift (new — merged with an unrelated same-named/same-week file for the id field), Tests/UnitTests/Paywalls/Components/TabsComponentTests.swift (new), Tests/RevenueCatUITests/PaywallsV2/PackageComponentViewTests.swift (extended), Tests/RevenueCatUITests/PaywallsV2/TabControlHapticFeedbackTests.swift (new)
    • Review relevance: the env-injection/closure mechanism itself is tested in isolation (PackageSelectionHapticFeedbackTests), and the "should this fire" decision logic is tested directly as pure functions (shouldTriggerHapticFeedback variants) — 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 nil defaults on @_spi(Internal) types; api/*.swiftinterface has zero diff on this branch (confirmed via git diff --stat and a passing bundle exec fastlane run_api_tests).

Validation

  • Commands run (all re-run independently by the controller, not just trusted from subagent reports):
    • 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-in id-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.
    • Full UnitTests scheme (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.
    • Full RevenueCatUITests scheme (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 with bundle install).
  • Manual verification:
    • Not run. Haptic feel on a physical device/simulator was not verified in this session — see Validation Gaps and the PR description's manual-verification note.
  • CI: Not captured (PR just opened).

Validation Gaps

  • No test hosts a real view, dispatches a tap/toggle through SwiftUI, and asserts the haptic actually fires end-to-end at any of the 3 call sites — only the pure decision logic and the closure-wrapper mechanism are tested in isolation. The final whole-branch reviewer flagged this as low-risk (the wiring is 3 straight-line lines per site) and consciously scoped out per the plan, not overlooked.
  • 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.
  • No manual/device verification yet that the haptic actually buzzes, that haptic_feedback_enabled: false suppresses it, or that re-tapping an already-selected package/tab stays silent.

Review Focus

  • Does merging in the unrelated id: String? field on PackageComponent during rebase (from a different, already-merged PR) look correct — property order, hash(into:)/==/CodingKeys all include both fields with nothing dropped?
  • Is the PackageSelectorIfNeeded.shouldTriggerHapticFeedback sibling-if structure (vs. nesting inside the existing analytics guard) acceptable, given both branches provably read the same captured values?
  • Any concern with constructing UISelectionFeedbackGenerator() fresh per fire (no retained/prepared generator) inside a SwiftUI Button/Binding closure?

Risks / Reviewer Notes

  • Risk: UISelectionFeedbackGenerator() is constructed fresh per fire with no .prepare() call.
    • Evidence: Apple recommends preparing a retained generator ahead of the interaction to minimize actuation latency; this keeps the code stateless instead.
    • Mitigation: negligible UX impact for a paywall selection tap; the planned iOS-17 .sensoryFeedback(.selection, trigger:) migration (see the comment left in PaywallEventTracker.swift) resolves this automatically once the deployment target allows it.
  • Risk: PackageSelectionHapticFeedback/packageSelectionHapticFeedback is named after "package" but also fires for tab selection.
    • Evidence: flagged in final review; the type's doc comment does clarify "package or tab," so it's self-documenting, but the name itself reads narrower than its actual usage.
    • Mitigation: none applied; cosmetic, not worth an API rename right now.

Non-goals / Out of Scope

  • V1 templates (Template2/4/5/7View.swift).
  • WatchTemplateView.swift — watchOS has its own WKInterfaceDevice.play(.click) haptic API, tracked as a separate follow-up if wanted.
  • CarouselComponentView.swift — a generic pager, not inherently a package selector.
  • The dashboard Paywall Builder editor's UI itself (separate repo/team) — this PR's job ends at correctly decoding and honoring 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.

facumenzella and others added 12 commits July 21, 2026 11:17
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
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
facumenzella force-pushed the facu/paywall-package-selection-haptics branch from 57c9bd9 to a5a0436 Compare July 21, 2026 09:19
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dfLyU9xkXkd38Evv2tNzb
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant