Skip to content

Hel 4506 update ios embedded paywall#91

Merged
salami merged 8 commits into
mainfrom
HEL-4506-update-ios-embedded
May 5, 2026
Merged

Hel 4506 update ios embedded paywall#91
salami merged 8 commits into
mainfrom
HEL-4506-update-ios-embedded

Conversation

@salami

@salami salami commented May 5, 2026

Copy link
Copy Markdown
Collaborator

Note

Medium Risk
Medium risk because it changes the public Flutter API for getUpsellWidget (new required parameter) and alters embedded paywall show/no-show logic across Dart + iOS native bridging, which could affect when paywalls render or fall back.

Overview
Embedded paywall API is updated: getUpsellWidget now requires a paywallNotShownReplacement widget, and the wrapper renders this replacement when an embedded paywall can’t be shown (instead of a default fallback widget).

Availability checks are tightened for embedded paywalls by combining canPresentUpsell with getPaywallInfo.shouldShow to treat targeting holdouts as non-displayable and return explicit unavailability reasons.

iOS embedded paywall creation is refactored to build a HeliumPaywall with PaywallPresentationConfig(customPaywallTraits:), decoding boolean marker values and forwarding customPaywallTraits from Flutter. Boolean-marker decoding helpers are moved to file-level Swift functions, and Dart method-channel calls for getPaywallInfo/canPresentUpsell now handle exceptions and log failures more consistently.

Reviewed by Cursor Bugbot for commit 52dcbca. Bugbot is set up for automated code reviews on this repo. Configure here.

Summary by CodeRabbit

  • New Features

    • Enhanced paywall availability checks (adds holdout detection) and clearer fallback behavior.
    • Embedded upsell now requires a provided "paywall not shown" replacement view; replacement is shown centered when paywall unavailable.
    • More consistent error handling around paywall queries.
  • Bug Fixes

    • More reliable native iOS paywall construction and rendering.
  • Breaking Changes

    • getUpsellWidget now requires a paywallNotShownReplacement parameter.

@coderabbitai

coderabbitai Bot commented May 5, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This PR requires callers to provide a replacement widget when an embedded paywall is not shown, updates availability checks to combine capability and paywall-targeting info, changes iOS native view construction from an upsell helper to direct paywall creation, and adapts call sites/tests and docs accordingly.

Changes

Embedded Paywall Availability & Presentation

Layer / File(s) Summary
API contract
packages/helium_flutter/lib/helium_flutter.dart, packages/helium_flutter/lib/core/helium_flutter_platform.dart
getUpsellWidget signature adds required paywallNotShownReplacement parameter; docstring for customPaywallTraits clarified (traits applied only on initial build).
Availability checks
packages/helium_flutter/lib/core/helium_flutter_method_channel.dart
Introduces _checkEmbeddedAvailability(trigger) which calls canPresentUpsell then getPaywallInfo to suppress presentation when shouldShow is false; getPaywallInfo and canPresentUpsell wrapped in try/catch returning null on errors.
Widget rendering
packages/helium_flutter/lib/core/helium_flutter_method_channel.dart
getUpsellWidget / UpsellWrapperWidget now require paywallNotShownReplacement; UpsellWrapperWidget.build renders UpsellViewForTrigger only when snapshot.data?.canShow == true, otherwise triggers fallback callbacks and returns paywallNotShownReplacement.
iOS native view
packages/helium_flutter/ios/.../FLNativeViewFactory.swift, packages/helium_flutter/ios/.../HeliumFlutterPlugin.swift
Replaced upsellViewForTrigger flow with makePaywallView(trigger:customPaywallTraits:) that builds PaywallPresentationConfig and HeliumPaywall; parses trigger and customPaywallTraits, converts marker maps to booleans, maps to HeliumUserTraits; moved marker-conversion helpers to file-level functions and simplified optional mapping with .map.
Example usage
example/lib/presentation/view_for_trigger_page.dart
Reads TRIGGER from env, normalizes to 'sdk_test' fallback, and passes Center(child: Text('Paywall not available')) as paywallNotShownReplacement.
Tests / Mocks
packages/helium_flutter/test/helium_flutter_test.dart, packages/helium_stripe/test/helium_stripe_test.dart
Mock platform implementations updated to include the new required paywallNotShownReplacement parameter in getUpsellWidget signatures; return values unchanged.

Sequence Diagram

sequenceDiagram
    participant App as Flutter App
    participant MC as MethodChannel
    participant AC as AvailabilityChecker
    participant PW as NativePaywall
    participant UI as Flutter UI

    App->>MC: getUpsellWidget(trigger, paywallNotShownReplacement)
    MC->>AC: _checkEmbeddedAvailability(trigger)
    AC->>AC: canPresentUpsell(trigger)
    alt canShow == true
        AC->>AC: getPaywallInfo(trigger)
        alt shouldShow == true
            AC-->>MC: canShow: true
        else shouldShow == false
            AC-->>MC: canShow: false (reason: targetingHoldout)
        end
    else
        AC-->>MC: canShow: false
    end

    alt canShow == true
        MC->>PW: instantiate paywall view (trigger, traits)
        PW-->>UI: platform-hosted paywall widget
    else
        MC-->>UI: paywallNotShownReplacement
    end

    UI-->>App: rendered result
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested reviewers

  • koyinusa

Poem

🐰 I hopped through code to find a view,
When paywalls hide, a fallback greets you.
Traits mapped neat and native views spun,
Flutter asks, iOS answers — now show or not, we're done. 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Hel 4506 update ios embedded paywall' directly corresponds to the main changes in this PR: updating iOS embedded paywall functionality by adding required paywallNotShownReplacement parameter and refactoring availability logic.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch HEL-4506-update-ios-embedded

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/helium_stripe/test/helium_stripe_test.dart (1)

112-118: ⚠️ Potential issue | 🔴 Critical

MockHeliumFlutterPlatform is missing two required method implementations — this will cause compile errors.

The mock is incomplete and lacks implementations for handleURL and setThirdPartyAnalyticsAnonymousId, both of which are declared in the HeliumFlutterPlatform interface. Since the mock class implements this interface, all abstract methods must be present. Add these two methods with appropriate stub implementations (e.g., returning Future.value(false) for handleURL and Future.value() for setThirdPartyAnalyticsAnonymousId).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/helium_stripe/test/helium_stripe_test.dart` around lines 112 - 118,
MockHeliumFlutterPlatform is missing required implementations for the
HeliumFlutterPlatform interface; add stub async methods named handleURL(String
url) and setThirdPartyAnalyticsAnonymousId(String id) to the
MockHeliumFlutterPlatform class so the class implements the interface fully —
implement handleURL to return Future.value(false) (or appropriate boolean) and
setThirdPartyAnalyticsAnonymousId to return Future.value() (void) or a completed
Future to satisfy the interface signature; ensure method signatures match
HeliumFlutterPlatform exactly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@packages/helium_flutter/ios/helium_flutter/Sources/helium_flutter/FLNativeViewFactory.swift`:
- Around line 56-70: makePaywallView currently returns hostingController.view
only, so the local UIHostingController is deallocated immediately which breaks
SwiftUI state; retain the controller as a stored property on FLNativeView (e.g.,
add a property like paywallHostingController: UIHostingController<...>? on the
FLNativeView class), assign hostingController to that property before returning
the view in makePaywallView, and if appropriate make the hosting controller a
child view controller (call addChild(hostingController) and
hostingController.didMove(toParent:)) or otherwise ensure the controller's
lifetime matches the view's so SwiftUI state continues to update.

---

Outside diff comments:
In `@packages/helium_stripe/test/helium_stripe_test.dart`:
- Around line 112-118: MockHeliumFlutterPlatform is missing required
implementations for the HeliumFlutterPlatform interface; add stub async methods
named handleURL(String url) and setThirdPartyAnalyticsAnonymousId(String id) to
the MockHeliumFlutterPlatform class so the class implements the interface fully
— implement handleURL to return Future.value(false) (or appropriate boolean) and
setThirdPartyAnalyticsAnonymousId to return Future.value() (void) or a completed
Future to satisfy the interface signature; ensure method signatures match
HeliumFlutterPlatform exactly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 749b712c-b42d-45ee-ba70-9bdbc0c81d84

📥 Commits

Reviewing files that changed from the base of the PR and between 1a4ecca and 5425001.

📒 Files selected for processing (8)
  • example/lib/presentation/view_for_trigger_page.dart
  • packages/helium_flutter/ios/helium_flutter/Sources/helium_flutter/FLNativeViewFactory.swift
  • packages/helium_flutter/ios/helium_flutter/Sources/helium_flutter/HeliumFlutterPlugin.swift
  • packages/helium_flutter/lib/core/helium_flutter_method_channel.dart
  • packages/helium_flutter/lib/core/helium_flutter_platform.dart
  • packages/helium_flutter/lib/helium_flutter.dart
  • packages/helium_flutter/test/helium_flutter_test.dart
  • packages/helium_stripe/test/helium_stripe_test.dart

Comment on lines +56 to 70
func makePaywallView(trigger: String, customPaywallTraits: HeliumUserTraits?) -> UIView {
let config = PaywallPresentationConfig(customPaywallTraits: customPaywallTraits)
let paywallView = HeliumPaywall(
trigger: trigger,
config: config,
eventHandlers: PaywallEventHandlers.withHandlers(
onAnyEvent: postPaywallEvent
)
)
guard let swiftUIView else {
// this should never happen because we check canPresentUpsell before creating this view
return UITextField()
) { _ in
EmptyView()
}
let hostingController = UIHostingController(rootView: swiftUIView)
let hostingController = UIHostingController(rootView: paywallView)
hostingController.view.backgroundColor = .clear
return hostingController.view
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

UIHostingController is not retained and will be deallocated immediately — SwiftUI state management breaks.

makePaywallView returns only hostingController.view; the local hostingController variable goes out of scope and ARC releases it as soon as the function returns. A UIView does not retain its UIViewController, so the UIHostingController is gone before the paywall ever appears on screen.

UIHostingController drives SwiftUI's entire lifecycle management. Once it is deallocated "it can't perform its job", and the pattern of only keeping its view "can even appear to work fine until it doesn't" — the issue being that "we don't retain UIHostingController after the hosting setup, and it is deallocated instantly."

It is "tempting to embed [the SwiftUI view] inside UIHostingController and then simply plug out the root view of the controller, ignoring UIHostingController completely", but "there are scenarios where it can hit you hard." The recommendation is to "always store it somewhere so that its lifetime matches the lifetime of the view it embeds" — either as a child view controller or "at least save it in the property if you create it inside another UIView."

For an interactive HeliumPaywall (purchase taps, loading states, async event callbacks back through PaywallEventHandlers), SwiftUI state updates will silently stop propagating once the hosting controller is gone.

Fix: retain the hosting controller as a stored property on FLNativeView:

🔒 Proposed fix — retain the `UIHostingController`
 class FLNativeView: NSObject, FlutterPlatformView {
     let arguments: Any?
+    private var hostingController: UIViewController?
+
     private lazy var _view: UIView = {
         let args = arguments as? [String: Any] ?? [:]
         let trigger = args["trigger"] as? String ?? ""
         let traitsMap = convertMarkersToBooleans(args["customPaywallTraits"] as? [String: Any])
         let customPaywallTraits = traitsMap.map { HeliumUserTraits($0) }
-        return makePaywallView(trigger: trigger, customPaywallTraits: customPaywallTraits)
+        let config = PaywallPresentationConfig(customPaywallTraits: customPaywallTraits)
+        let paywallView = HeliumPaywall(
+            trigger: trigger,
+            config: config,
+            eventHandlers: PaywallEventHandlers.withHandlers(
+                onAnyEvent: postPaywallEvent
+            )
+        ) { _ in
+            EmptyView()
+        }
+        let hc = UIHostingController(rootView: paywallView)
+        hc.view.backgroundColor = .clear
+        hostingController = hc   // ← keep it alive
+        return hc.view
     }()
 
     // ... init unchanged ...
 
-    func makePaywallView(trigger: String, customPaywallTraits: HeliumUserTraits?) -> UIView {
-        let config = PaywallPresentationConfig(customPaywallTraits: customPaywallTraits)
-        let paywallView = HeliumPaywall(
-            trigger: trigger,
-            config: config,
-            eventHandlers: PaywallEventHandlers.withHandlers(
-                onAnyEvent: postPaywallEvent
-            )
-        ) { _ in
-            EmptyView()
-        }
-        let hostingController = UIHostingController(rootView: paywallView)
-        hostingController.view.backgroundColor = .clear
-        return hostingController.view
-    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/helium_flutter/ios/helium_flutter/Sources/helium_flutter/FLNativeViewFactory.swift`
around lines 56 - 70, makePaywallView currently returns hostingController.view
only, so the local UIHostingController is deallocated immediately which breaks
SwiftUI state; retain the controller as a stored property on FLNativeView (e.g.,
add a property like paywallHostingController: UIHostingController<...>? on the
FLNativeView class), assign hostingController to that property before returning
the view in makePaywallView, and if appropriate make the hosting controller a
child view controller (call addChild(hostingController) and
hostingController.didMove(toParent:)) or otherwise ensure the controller's
lifetime matches the view's so SwiftUI state continues to update.

Comment thread packages/helium_flutter/lib/core/helium_flutter_method_channel.dart

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/helium_flutter/lib/core/helium_flutter_method_channel.dart`:
- Around line 891-898: The code currently calls getPaywallInfo(trigger) and only
blocks showing when info != null && !info.shouldShow, which lets a null info
fall through and potentially return a positive CanPresentUpsellResult; change
the logic in the method around getPaywallInfo(trigger) so that if info is null
you treat it as failing the "shouldShow" check and return
CanPresentUpsellResult.fromMap({'canShow': false, 'paywallUnavailableReason':
'paywallInfoMissing'}) (or use 'targetingHoldout' if preferred), ensuring the
final returned result enforces the "canPresentUpsell && shouldShow" contract
rather than returning the original result when paywall info is missing.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: f1da8598-3195-4460-92bd-c57b11904a09

📥 Commits

Reviewing files that changed from the base of the PR and between 5425001 and 3015b28.

📒 Files selected for processing (6)
  • example/lib/presentation/view_for_trigger_page.dart
  • packages/helium_flutter/lib/core/helium_flutter_method_channel.dart
  • packages/helium_flutter/lib/core/helium_flutter_platform.dart
  • packages/helium_flutter/lib/helium_flutter.dart
  • packages/helium_flutter/test/helium_flutter_test.dart
  • packages/helium_stripe/test/helium_stripe_test.dart

Comment thread packages/helium_flutter/lib/core/helium_flutter_method_channel.dart
@salami salami merged commit 380c731 into main May 5, 2026
4 checks passed
@salami salami deleted the HEL-4506-update-ios-embedded branch May 5, 2026 18:05
@promptless

promptless Bot commented May 5, 2026

Copy link
Copy Markdown

Promptless prepared a documentation update related to this change.

Triggered by PR #91 in helium_flutter

Updated embedded paywall docs to reflect the new required paywallNotShownReplacement parameter and removed the iOS-only note for customPaywallTraits (now supported on both platforms).

Review: Flutter embedded paywall custom traits

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant