From 1fedf0919ec6a9010809e7cb062fe9cb70fb0ddc Mon Sep 17 00:00:00 2001 From: Kevin Date: Tue, 23 Jun 2026 13:20:54 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(skills):=20document=20user-attribute?= =?UTF-8?q?=20=E2=86=92=20campaign=20targeting=20timing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setting a user attribute saves the value (persisted across sessions) but does not re-trigger or re-evaluate any placement/campaign. The value is applied only on the next placement fetch or campaign trigger, which for APP_STARTED fires shortly after SDK start. A custom-attribute audience therefore misses on the first launch and matches from the next session once the value is persisted — the classic hit-or-miss symptom. Document the reliable first-launch recipe: allowCampaigns(false) → set attribute → allowCampaigns(true) (ordering: start → attributes → campaigns). - user-attributes-targeting.md: new timing section - campaigns.md: custom-attribute audience section + anti-pattern - common-issues.md §7: first-launch campaign race symptom/fix - purchasely-sdk-expert / purchasely-debug skills: timing rule - CHANGELOG: Unreleased entries Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 10 +++++++++ purchasely/references/concepts/campaigns.md | 22 +++++++++++++++++++ .../concepts/user-attributes-targeting.md | 20 +++++++++++++++++ .../troubleshooting/common-issues.md | 2 ++ purchasely/skills/purchasely-debug/SKILL.md | 1 + .../skills/purchasely-sdk-expert/SKILL.md | 1 + 6 files changed, 56 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 361cc26..bf9e350 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project are documented here. The format is based on ## [Unreleased] +### Added + +- `references/concepts/user-attributes-targeting.md` — new "When an attribute change takes effect (timing)" section: `setUserAttribute` saves the value (persisted across sessions) but does not re-evaluate placements/campaigns; the value is applied only on the next placement fetch or campaign trigger. +- `references/concepts/campaigns.md` — new "Custom-attribute audiences: set the attribute before campaigns are evaluated" section explaining why a custom-attribute campaign misses on first launch (matches from the next session once the value is persisted) and the `allowCampaigns(false)` → set attribute → `allowCampaigns(true)` recipe for reliable first-launch matching. + +### Changed + +- `purchasely-sdk-expert` and `purchasely-debug` now state the user-attribute → campaign timing rule: setting an attribute does not re-trigger targeting, and custom-attribute audiences are gated with `allowCampaigns` (ordering: start → set attributes → allow campaigns). +- `references/troubleshooting/common-issues.md` §7 now covers the "campaign on a custom-attribute audience is hit-or-miss on first launch" symptom and its fix. + ## [2.0.0-rc.3] — 2026-06-17 Flutter joins the **v6 line**, and skills-only installs gain first-class expert Q&A. Flutter guidance moves off v5 to match native iOS & Android — the published Flutter SDK now targets **Purchasely v6.0.0-rc.1** with the builder API. A new portable `purchasely-sdk-expert` skill brings free-form SDK Q&A to harnesses without the Claude Code subagent. **React Native and Cordova stay on v5 (`5.7.3`).** diff --git a/purchasely/references/concepts/campaigns.md b/purchasely/references/concepts/campaigns.md index 3b4d7d5..42b46e2 100644 --- a/purchasely/references/concepts/campaigns.md +++ b/purchasely/references/concepts/campaigns.md @@ -81,6 +81,27 @@ await PurchaselyBuilder.apiKey('') > **v6 native:** `allowCampaigns` and `allowDeeplink` are **independent** flags (in v5 a single flag governed both). Control campaign display with `allowCampaigns`; control deeplink presentations with `allowDeeplink` (defaults to `true`). Android also **auto-intercepts** deeplinks, so no manual `handleDeeplink` call is required for them. > If you implement a [UI Handler](https://docs.purchasely.com/docs/ui-handler-deeplinks) to manage deeplink display yourself, **keep the presentation object returned** and do not refetch it — refetching loses the campaign context. +### Custom-attribute audiences: set the attribute before campaigns are evaluated + +A trigger-based campaign's audience is evaluated **when the trigger resolves** — for the default `APP_STARTED` trigger, shortly after SDK start. The attributes used are whatever the SDK holds **at that moment**. Setting a [user attribute](user-attributes-targeting.md#when-an-attribute-change-takes-effect-timing) does **not** re-trigger or re-evaluate a campaign; it is saved (and persisted across sessions) and only applied on the *next* trigger resolution or placement fetch. + +So if your audience is built on a custom attribute (e.g. an AppsFlyer campaign id) that the app sets after start, the campaign **won't match on the first launch**. Since the value is persisted in the SDK's disk cache, it is present at the next cold start, so the campaign matches **from the next session** — which makes the symptom look intermittent. + +Two ways to handle it: + +1. **Accept the first-launch miss.** Do nothing special; the campaign matches from the next session once the attribute is persisted. +2. **Gate campaigns until attributes are set (reliable on first launch).** Initialize with `allowCampaigns(false)`, set your attributes, then flip `allowCampaigns(true)` — the queued trigger resolves with the attribute present. Ordering: **start → set user attributes → `allowCampaigns(true)`**. + +```swift +// iOS — make a custom-attribute audience match on the very first launch +Purchasely.apiKey("YOUR_API_KEY").allowCampaigns(false).start { _ in } +// once the AppsFlyer (or other) value is known: +Purchasely.setUserAttribute(withStringValue: campaignId, forKey: "appsflyer_last_campaign_id") +Purchasely.allowCampaigns(true) // queued campaign now resolves its audience with the attribute set +``` + +> Add a safety timeout: if the attribute source (e.g. an AppsFlyer conversion callback) never returns, call `allowCampaigns(true)` anyway after a few seconds so users without that attribute still get the normal campaign flow. + ## Placement-based campaigns — no extra SDK code You already fetch the placement (native iOS/Android v6: `PLYPresentationBuilder.forPlacementId("PLACEMENT_ID")` / `PLYPresentation { placementId("PLACEMENT_ID") }`; Flutter v6: `PresentationBuilder.placement("PLACEMENT_ID").build()`; React Native / Cordova v5: `fetchPresentation("PLACEMENT_ID")`). When a campaign targets that placement and the user matches the audience, the SDK substitutes the campaign's Screen for the Placement's default rules. Same `PLYPresentationType` handling, same display path. Nothing to change in your code. @@ -113,6 +134,7 @@ Property bag includes `campaign_id`, `campaign_name`, `screen_id`, `audience_id` - ❌ **Leaving campaigns gated.** If you set `allowCampaigns = false` (native iOS/Android v6 and Flutter v6) / `readyToOpenDeeplink(false)` (React Native / Cordova v5) and never flip it back, trigger-based campaigns silently never appear. - ❌ **Re-enabling campaigns too early.** If your splash screen runs after `start()`, flipping `allowCampaigns = true` (native iOS/Android v6 and Flutter v6) / `readyToOpenDeeplink(true)` (React Native / Cordova v5) while it is still up lands the campaign paywall on top of the splash. Wait until your launch routine is complete. - ❌ **Coupling capping logic to placement-based campaigns.** Capping only applies on triggers — if you need capping on a placement, build the cap into your audience attribute or use a trigger. +- ❌ **Assuming `setUserAttribute` re-launches campaign targeting.** It does not. The attribute is saved but no campaign/placement is re-evaluated — only the next trigger resolution or placement fetch uses it. For a custom-attribute audience, set the attribute *before* campaigns are evaluated (see [above](#custom-attribute-audiences-set-the-attribute-before-campaigns-are-evaluated)). - ❌ **Refetching the presentation returned by the deeplink handler.** You lose the campaign context (audience match, screen variant, exposure tracking). - ❌ **Targeting subscribers with promotional offers without eligibility audience.** See [promotional-offers.md](promotional-offers.md#eligibility-is-your-responsibility-promotional-offers--developer-determined-offers). diff --git a/purchasely/references/concepts/user-attributes-targeting.md b/purchasely/references/concepts/user-attributes-targeting.md index d53f51c..2b54842 100644 --- a/purchasely/references/concepts/user-attributes-targeting.md +++ b/purchasely/references/concepts/user-attributes-targeting.md @@ -15,6 +15,26 @@ User attributes are key-value pairs the SDK forwards to Purchasely servers. They > **Attribute changes can change which audience matches** → invalidate any [presentation cache](presentation-cache.md) after setting attributes. +## When an attribute change takes effect (timing) + +Setting an attribute is **not** an event the SDK reacts to. `setUserAttribute(...)` saves the value immediately (and persists it across sessions in the SDK's own disk cache, so you only have to set it once), but it does **not** re-run, re-fetch, or re-evaluate any placement or campaign on its own. The new value is applied to audience matching only on the **next call** that resolves a screen: + +- **Placements:** the next presentation fetch/build picks up the value (native v6 `PLYPresentationBuilder.forPlacementId(...).build()` / `.preload()`, Flutter v6 `PresentationBuilder.placement(...).build()` → `preload()` / `display(...)`, React Native / Cordova v5 `fetchPresentation(...)`). So set the attribute **before** you fetch the placement. +- **Campaigns:** the audience is evaluated when the campaign trigger resolves — for the default `APP_STARTED` trigger this happens **shortly after SDK start** (or when you flip [`allowCampaigns(true)`](campaigns.md#sdk-setup--gating-campaign-display) if you gated it). Setting the attribute afterwards has no effect on that already-resolved trigger. + +**Consequence for a campaign whose audience is built on a custom attribute:** if the attribute is set *after* the SDK has already evaluated campaigns at start, the audience will **not** match on the **first launch**. Because the value is persisted, it is present at the next cold start, so the campaign matches **from the next session** onward — which is why this kind of bug looks intermittent ("it worked once"). + +To make it reliable on the **first** launch, set the attribute before campaigns are evaluated. The recommended ordering is **start SDK → set user attributes → allow campaigns**, gating campaigns until your attributes are set: + +```swift +// iOS — defer campaigns, set attributes, then release +Purchasely.apiKey("YOUR_API_KEY").allowCampaigns(false).start { _ in } +Purchasely.setUserAttribute(withStringValue: campaignId, forKey: "appsflyer_last_campaign_id") +Purchasely.allowCampaigns(true) // the campaign trigger now resolves with the attribute present +``` + +See [campaigns.md](campaigns.md#custom-attribute-audiences-set-the-attribute-before-campaigns-are-evaluated) for the per-platform `allowCampaigns` API and the full recipe. + ## Supported attribute types Same on every platform; only the method signatures differ: diff --git a/purchasely/references/troubleshooting/common-issues.md b/purchasely/references/troubleshooting/common-issues.md index 5c41f62..c916ea9 100644 --- a/purchasely/references/troubleshooting/common-issues.md +++ b/purchasely/references/troubleshooting/common-issues.md @@ -338,6 +338,8 @@ Purchasely.Builder(applicationContext) } ``` +**Related — campaign on a custom-attribute audience is hit-or-miss on the first launch:** `setUserAttribute(...)` saves the value but does **not** re-evaluate any campaign. A trigger-based campaign evaluates its audience when the trigger resolves (default `APP_STARTED` → shortly after start), using the attributes held at that moment. If the attribute is set after that, the audience won't match on the **first** launch; because the value is persisted in the SDK's disk cache, it matches **from the next session** (hence the "it worked once" symptom). To make it reliable on first launch, gate campaigns until attributes are set — `allowCampaigns(false)` → `setUserAttribute(...)` → `allowCampaigns(true)` (ordering: start → set attributes → allow campaigns). See [campaigns.md](../concepts/campaigns.md#custom-attribute-audiences-set-the-attribute-before-campaigns-are-evaluated). + ## 8. Paywall Disappears Immediately **Symptoms:** Paywall flashes on screen and then vanishes. diff --git a/purchasely/skills/purchasely-debug/SKILL.md b/purchasely/skills/purchasely-debug/SKILL.md index 8afd777..ed48d04 100644 --- a/purchasely/skills/purchasely-debug/SKILL.md +++ b/purchasely/skills/purchasely-debug/SKILL.md @@ -194,6 +194,7 @@ When you identify one of these patterns, apply the known fix immediately: | Events fire twice | Listener registered in `onResume`/`viewWillAppear` instead of `onCreate`/`viewDidLoad` | Move registration to a lifecycle method that runs only once, or guard with a flag | | User attributes not syncing | `setAttribute` called before `start()` completes | Move `setAttribute` calls into the `start()` completion handler or after it resolves | | Wrong paywall showing | Confusion between `placementId` and `presentationId`, or audience not matching | Use `placementId` for production flows (respects targeting); `presentationId` only for testing a specific screen | +| Campaign on a custom-attribute audience is hit-or-miss / only shows on the 2nd launch | `setUserAttribute(...)` was called after the SDK evaluated campaigns at start; it saves the value but does not re-trigger targeting, so the audience misses on first launch and matches from the next session (value persisted) | Gate campaigns until attributes are set: `allowCampaigns(false)` → `setUserAttribute(...)` → `allowCampaigns(true)` (ordering: start → set attributes → allow campaigns). See `../../references/concepts/campaigns.md#custom-attribute-audiences-set-the-attribute-before-campaigns-are-evaluated` | | Purchase succeeds but status not updated | Observer mode without `synchronize()` call | Add `Purchasely.synchronize()` after every successful purchase in Observer mode. If using a wrapper pattern, ensure the wrapper calls `synchronize()` when observing `TransactionResult.Success` | | Observer purchase works but paywall freezes | The interceptor never signalled completion after the native purchase finished | Native v6: the `.purchase` handler must `return PLYInterceptResult.SUCCESS` (or `.FAILED`) for every outcome (success, cancel, error) -- a hung/unawaited billing call leaves it unsignalled. Flutter v6: the `PresentationActionKind.purchase` handler must `return InterceptResult.success` (or `.failed`) for every outcome. Cross-platform (RN / Cordova, v5): call `onProcessAction(false)` for all outcomes. In decoupled (reactive) architectures, make sure the billing result is mapped back to a returned result / completion for every branch | | Paywall loads but buttons do nothing | `PLYUIDelegate` / `UIDelegate` not set or not retained | Set the delegate and store a strong reference to the delegate object | diff --git a/purchasely/skills/purchasely-sdk-expert/SKILL.md b/purchasely/skills/purchasely-sdk-expert/SKILL.md index ec0aca3..ae90a33 100644 --- a/purchasely/skills/purchasely-sdk-expert/SKILL.md +++ b/purchasely/skills/purchasely-sdk-expert/SKILL.md @@ -135,6 +135,7 @@ For any campaign / trigger / `APP_STARTED` / launch display question, load `../. - Trigger-based campaigns are SDK-managed. The app does not manually build or fetch the campaign paywall. - Placement-based campaigns override the placement when the app displays that placement. - Mention deeplink display readiness: v6 native / Flutter use `allowDeeplink` (default true); React Native / Cordova v5 use `readyToOpenDeeplink(true)` after the app UI is ready. +- **Attribute timing for custom-attribute audiences.** `setUserAttribute(...)` saves the value (persisted across sessions) but does **not** re-trigger or re-evaluate any campaign/placement. A trigger-based campaign evaluates its audience when the trigger resolves (default `APP_STARTED` → shortly after start), using the attributes held at that instant. So a custom-attribute audience won't match on the **first** launch if the attribute is set after start; it matches **from the next session** because the value is persisted (this is the classic "works once / hit-or-miss" symptom). For reliable first-launch matching, gate campaigns: `allowCampaigns(false)` → `setUserAttribute(...)` → `allowCampaigns(true)` (ordering: start → set attributes → allow campaigns). `allowCampaigns` (plural) defaults to true; gating queues the campaign *trigger*, which then resolves its audience with the attribute present. Do not claim setting an attribute re-runs targeting. Load `../../references/concepts/campaigns.md` and `../../references/concepts/user-attributes-targeting.md`. ### BYOS From 43cde6c805d3eef62746b131180330dea341d0cb Mon Sep 17 00:00:00 2001 From: Kevin Date: Tue, 23 Jun 2026 17:38:06 +0200 Subject: [PATCH 2/3] docs(flutter): use typed purchase payload models --- .../references/concepts/observer-mode-post-purchase.md | 2 +- purchasely/references/concepts/promotional-offers.md | 2 +- purchasely/references/flutter/integration.md | 2 +- purchasely/references/flutter/migration-v6.md | 6 ++++-- purchasely/skills/purchasely-integrate/SKILL.md | 4 ++-- purchasely/skills/purchasely-migrate/SKILL.md | 2 +- 6 files changed, 10 insertions(+), 8 deletions(-) diff --git a/purchasely/references/concepts/observer-mode-post-purchase.md b/purchasely/references/concepts/observer-mode-post-purchase.md index 7fb4ad3..27230d4 100644 --- a/purchasely/references/concepts/observer-mode-post-purchase.md +++ b/purchasely/references/concepts/observer-mode-post-purchase.md @@ -113,7 +113,7 @@ In v6 the `.purchase` interceptor is an async callback that **returns** an `Inte Purchasely.interceptAction(PresentationActionKind.purchase, (info, payload) async { if (payload is! PurchasePayload) return InterceptResult.notHandled; - final purchased = await myBilling.purchase(payload.plan['productId']); + final purchased = await myBilling.purchase(payload.plan.productId); if (!purchased) return InterceptResult.failed; await Purchasely.synchronize(); // resolves once the native bridge confirms diff --git a/purchasely/references/concepts/promotional-offers.md b/purchasely/references/concepts/promotional-offers.md index 4449e1e..c0f9fae 100644 --- a/purchasely/references/concepts/promotional-offers.md +++ b/purchasely/references/concepts/promotional-offers.md @@ -139,7 +139,7 @@ Purchasely.setPaywallActionInterceptorCallback((result) => { #### Flutter (Dart) — `PurchasePayload` from the per-action interceptor -In v6 Flutter mirrors the native per-action model: register `Purchasely.interceptAction` for the purchase kind and return an `InterceptResult`. +In v6 Flutter mirrors the native per-action model: register `Purchasely.interceptAction` for the purchase kind and return an `InterceptResult`. `PurchasePayload` carries real Dart model objects (`PLYPlan`, nullable `PLYSubscriptionOffer`, nullable `PLYPromoOffer`), so read properties instead of indexing maps. ```dart Purchasely.interceptAction(PresentationActionKind.purchase, (info, payload) async { diff --git a/purchasely/references/flutter/integration.md b/purchasely/references/flutter/integration.md index 7c5378c..222add7 100644 --- a/purchasely/references/flutter/integration.md +++ b/purchasely/references/flutter/integration.md @@ -260,7 +260,7 @@ await Purchasely.interceptAction( ); ``` -Action kinds (`PresentationActionKind`): `close`, `closeAll`, `login`, `navigate`, `purchase`, `restore`, `openPresentation`, `openPlacement`, `promoCode`, `webCheckout`. Each kind has a typed payload (`NavigatePayload`, `PurchasePayload`, `ClosePayload`, `CloseAllPayload`, `OpenPresentationPayload`, `OpenPlacementPayload`, `WebCheckoutPayload`); payload-less kinds (`login`, `restore`, `promoCode`) carry no extra fields. +Action kinds (`PresentationActionKind`): `close`, `closeAll`, `login`, `navigate`, `purchase`, `restore`, `openPresentation`, `openPlacement`, `promoCode`, `webCheckout`. Each kind has a typed payload (`NavigatePayload`, `PurchasePayload`, `ClosePayload`, `CloseAllPayload`, `OpenPresentationPayload`, `OpenPlacementPayload`, `WebCheckoutPayload`); payload-less kinds (`login`, `restore`, `promoCode`) carry no extra fields. `PurchasePayload` exposes real Dart objects: `plan` is a `PLYPlan`, `subscriptionOffer` is a nullable `PLYSubscriptionOffer`, and `offer` is a nullable `PLYPromoOffer`. ### Removing interceptors diff --git a/purchasely/references/flutter/migration-v6.md b/purchasely/references/flutter/migration-v6.md index a0ec389..18cfb46 100644 --- a/purchasely/references/flutter/migration-v6.md +++ b/purchasely/references/flutter/migration-v6.md @@ -263,7 +263,7 @@ await Purchasely.interceptAction( PresentationActionKind.purchase, (info, payload) async { if (payload is PurchasePayload) { - final ok = await MyPurchaseSystem.purchase(payload.plan['productId']); + final ok = await MyPurchaseSystem.purchase(payload.plan.productId); return ok ? InterceptResult.success : InterceptResult.failed; } return InterceptResult.notHandled; @@ -291,7 +291,9 @@ Action kinds (`PresentationActionKind`): `close`, `closeAll`, `login`, `navigate `webCheckout`. Each kind has a typed payload (`NavigatePayload`, `PurchasePayload`, `ClosePayload`, `CloseAllPayload`, `OpenPresentationPayload`, `OpenPlacementPayload`, `WebCheckoutPayload`); payload-less kinds (`login`, -`restore`, `promoCode`) carry no extra fields. +`restore`, `promoCode`) carry no extra fields. `PurchasePayload` exposes real +Dart objects: `plan` is a `PLYPlan`, `subscriptionOffer` is a nullable +`PLYSubscriptionOffer`, and `offer` is a nullable `PLYPromoOffer`. --- diff --git a/purchasely/skills/purchasely-integrate/SKILL.md b/purchasely/skills/purchasely-integrate/SKILL.md index 16eef36..f601549 100644 --- a/purchasely/skills/purchasely-integrate/SKILL.md +++ b/purchasely/skills/purchasely-integrate/SKILL.md @@ -813,7 +813,7 @@ await Purchasely.interceptAction( ); ``` -Action kinds (`PresentationActionKind`): `close`, `closeAll`, `login`, `navigate`, `purchase`, `restore`, `openPresentation`, `openPlacement`, `promoCode`, `webCheckout`. Each kind has a typed payload (`NavigatePayload`, `PurchasePayload`, `OpenPresentationPayload`, `OpenPlacementPayload`, `WebCheckoutPayload`, …); payload-less kinds (`login`, `restore`, `promoCode`) carry no extra fields. +Action kinds (`PresentationActionKind`): `close`, `closeAll`, `login`, `navigate`, `purchase`, `restore`, `openPresentation`, `openPlacement`, `promoCode`, `webCheckout`. Each kind has a typed payload (`NavigatePayload`, `PurchasePayload`, `OpenPresentationPayload`, `OpenPlacementPayload`, `WebCheckoutPayload`, …); payload-less kinds (`login`, `restore`, `promoCode`) carry no extra fields. `PurchasePayload` exposes real Dart objects: `plan` is a `PLYPlan`, `subscriptionOffer` is a nullable `PLYSubscriptionOffer`, and `offer` is a nullable `PLYPromoOffer`. ### Cordova (JavaScript) @@ -1042,7 +1042,7 @@ await Purchasely.interceptAction( PresentationActionKind.purchase, (info, payload) async { if (payload is! PurchasePayload) return InterceptResult.notHandled; - final ok = await MyPurchaseSystem.purchase(payload.plan['vendorId']); + final ok = await MyPurchaseSystem.purchase(payload.plan.vendorId); if (!ok) return InterceptResult.failed; try { await Purchasely.synchronize(); // upload the receipt to Purchasely diff --git a/purchasely/skills/purchasely-migrate/SKILL.md b/purchasely/skills/purchasely-migrate/SKILL.md index 32c631f..089dcd4 100644 --- a/purchasely/skills/purchasely-migrate/SKILL.md +++ b/purchasely/skills/purchasely-migrate/SKILL.md @@ -120,7 +120,7 @@ The Flutter plugin migration **adapts the integration to the Purchasely 6.0 nati 5. **Bump the Android host build config.** In the app's `android/app/build.gradle(.kts)` (and any module that overrides them) set `compileSdk 36`, `targetSdk 35`, `minSdk 23` (raise from the v5 `compileSdk 33`). iOS deployment target is `13.4`. Then `cd ios && pod install --repo-update` to pull the pinned `Purchasely` pod. 6. Run `flutter pub get` and `flutter analyze` immediately. Treat analyzer errors as the migration worklist; apply API migrations in small passes and re-run `flutter analyze` after each. 7. **Rewrite initialization.** Replace `await Purchasely.start(apiKey: …, androidStores: …, storeKit1: …, logLevel: PLYLogLevel.…, runningMode: PLYRunningMode.…, userId: …)` with the fluent builder `await PurchaselyBuilder.apiKey('…').appUserId(id).runningMode(RunningMode.full).logLevel(LogLevel.error).stores([PLYStore.google]).storekitVersion(StorekitVersion.storeKit2).allowDeeplink(true).allowCampaigns(true).start()`. The new enums are `RunningMode{observer, full}`, `LogLevel{debug, info, warn, error}`, `StorekitVersion{storeKit1, storeKit2}`. **The default `runningMode` is now `RunningMode.observer` (was Full)** — if the app relies on Purchasely to handle and validate purchases, you **must** pass `.runningMode(RunningMode.full)` explicitly (this silent default change is the single most impactful v6 break; see the warning at the top). Map a v5 `storeKit1: true` to `.storekitVersion(StorekitVersion.storeKit1)` (default is `storeKit2`). -8. **Action interceptor.** Replace `Purchasely.setPaywallActionInterceptorCallback((info, action, parameters, processAction) {…})` + `Purchasely.onProcessAction(bool)` with **per-action** `await Purchasely.interceptAction(PresentationActionKind.purchase, (info, payload) async { …; return InterceptResult.success; })`. The handler is `async` and **returns** an `InterceptResult` (`success` / `failed` / `notHandled`) instead of calling `onProcessAction(true/false)` — there is no `onProcessAction` in v6. Payloads are typed (`PurchasePayload`, `NavigatePayload`, …); register one handler per `PresentationActionKind` (`close`, `closeAll`, `login`, `navigate`, `purchase`, `restore`, `openPresentation`, `openPlacement`, `promoCode`, `webCheckout`). Use `Purchasely.removeInterceptor(kind)` / `Purchasely.removeAllInterceptors()` for cleanup. (This mirrors the native per-action `interceptAction` model — do **not** use `onProcessAction` or a single `setPaywallActionInterceptorCallback` for Flutter.) +8. **Action interceptor.** Replace `Purchasely.setPaywallActionInterceptorCallback((info, action, parameters, processAction) {…})` + `Purchasely.onProcessAction(bool)` with **per-action** `await Purchasely.interceptAction(PresentationActionKind.purchase, (info, payload) async { …; return InterceptResult.success; })`. The handler is `async` and **returns** an `InterceptResult` (`success` / `failed` / `notHandled`) instead of calling `onProcessAction(true/false)` — there is no `onProcessAction` in v6. Payloads are typed (`PurchasePayload`, `NavigatePayload`, …); register one handler per `PresentationActionKind` (`close`, `closeAll`, `login`, `navigate`, `purchase`, `restore`, `openPresentation`, `openPlacement`, `promoCode`, `webCheckout`). For `PurchasePayload`, use object properties (`payload.plan.productId`, `payload.subscriptionOffer?.basePlanId`, `payload.offer?.storeOfferId`) rather than map indexing. Use `Purchasely.removeInterceptor(kind)` / `Purchasely.removeAllInterceptors()` for cleanup. (This mirrors the native per-action `interceptAction` model — do **not** use `onProcessAction` or a single `setPaywallActionInterceptorCallback` for Flutter.) 9. **Presentation API (display / preload).** Replace `fetchPresentation` / `presentPresentation*` with a `PresentationBuilder` request: `PresentationBuilder.placement(id)` | `.screen(id)` | `.defaultSource()`, chain `.contentId(...)` / `.onLoaded` / `.onPresented` / `.onCloseRequested` / `.onDismissed`, then `.build()` to get a `PresentationRequest`. `request.preload()` → a loaded `Presentation`; `request.display([Transition])` shows the screen and resolves **at dismiss** with a `PresentationOutcome` (fields: `presentation`, `purchaseResult`, `plan`, `closeReason`, `error`). `purchaseResult` is the `PurchaseResult` enum (`purchased` / `restored` / `cancelled`). Transitions are `const Transition.fullScreen()` / `Transition.modal()`. Concretely: `presentPresentationForPlacement(id, isFullscreen: true)` → `PresentationBuilder.placement(id).build().display(const Transition.fullScreen())`; `presentPresentationWithIdentifier(screenId)` → `PresentationBuilder.screen(screenId).build().display(const Transition.modal())`; `fetchPresentation(placementId: id)` → `PresentationBuilder.placement(id).build().preload()`. Never use `fetchPresentation`/`presentPresentation` in v6. 10. **Presentation lifecycle (close / back).** A loaded `Presentation` exposes `.display([Transition])`, `.close()` and `.back()`. Replace `Purchasely.closePresentation()` / `hidePresentation()` / `close()` with `presentation.close()` and `showPresentation()` with `presentation.display()`. **There is NO `closePresentation()` / `hidePresentation()` / `closeAllScreens()` in Flutter v6** — dismiss via `presentation.close()`. 11. **Inline / embedded UI.** Replace `Purchasely.getPresentationView(...)` with the `PLYPresentationView(request: …)` widget (from `package:purchasely_flutter/native_view_widget.dart`); build the `PresentationRequest` (e.g. with `.onDismissed((outcome) => …)`) and pass it to the widget. From c6d33969d06086774d094430ee39824f1a593773 Mon Sep 17 00:00:00 2001 From: Kevin Date: Tue, 23 Jun 2026 17:52:59 +0200 Subject: [PATCH 3/3] docs(flutter): document default dismiss handler --- purchasely/references/flutter/integration.md | 14 ++++++-------- purchasely/references/flutter/migration-v6.md | 19 +++++++++---------- purchasely/skills/purchasely-migrate/SKILL.md | 2 +- 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/purchasely/references/flutter/integration.md b/purchasely/references/flutter/integration.md index 222add7..f908c5d 100644 --- a/purchasely/references/flutter/integration.md +++ b/purchasely/references/flutter/integration.md @@ -423,17 +423,15 @@ if (handled) { > The v5 names `readyToOpenDeeplink(bool)` and `isDeeplinkHandled(uri)` remain only as **deprecated aliases** for `allowDeeplink` / `handleDeeplink`. -### Default Presentation Result Handler +### Default Presentation Dismiss Handler -Retrieve the result of user actions on presentations opened via deeplinks by attaching `onDismissed` to a default-source request (replaces `setDefaultPresentationResultHandler`): +Retrieve the result of user actions on presentations opened by the SDK itself (deeplinks, campaigns, promoted in-app purchases) with `setDefaultPresentationDismissHandler` (replaces `setDefaultPresentationResultHandler`): ```dart -PresentationBuilder.defaultSource() - .onDismissed((outcome) { - print('Deeplink presentation dismissed: ${outcome.purchaseResult} / ${outcome.closeReason}'); - }) - .build() - .display(); +await Purchasely.setDefaultPresentationDismissHandler((outcome) { + print('SDK presentation dismissed: ${outcome.presentation?.screenId} / ' + '${outcome.purchaseResult} / ${outcome.closeReason}'); +}); ``` ## Synchronize Purchases diff --git a/purchasely/references/flutter/migration-v6.md b/purchasely/references/flutter/migration-v6.md index 18cfb46..428ff56 100644 --- a/purchasely/references/flutter/migration-v6.md +++ b/purchasely/references/flutter/migration-v6.md @@ -68,7 +68,7 @@ been removed in favour of the builder API. | `Purchasely.closePresentation()` / `hidePresentation()` / `close()` | `presentation.close()` (on the loaded `Presentation`) | | `Purchasely.showPresentation()` | `presentation.display()` (on the loaded `Presentation`) | | `Purchasely.clientPresentationDisplayed(...)` / `clientPresentationClosed(...)` | handled via the `PresentationRequest` lifecycle (`preload` → inspect `PresentationType.client` → render your own UI) | -| `Purchasely.setDefaultPresentationResultHandler(cb)` / `setDefaultPresentationResultCallback(cb)` | `PresentationBuilder.defaultSource().onDismissed((outcome) => …).build().display()` | +| `Purchasely.setDefaultPresentationResultHandler(cb)` / `setDefaultPresentationResultCallback(cb)` | `Purchasely.setDefaultPresentationDismissHandler(cb)` | | `Purchasely.setPaywallActionInterceptorCallback(cb)` + `Purchasely.onProcessAction(bool)` | `Purchasely.interceptAction(kind, handler)` — handler returns `InterceptResult.success` / `.failed` / `.notHandled` (no more `onProcessAction`) | > **Reminder.** Everything *not* in this table — purchases, restore, login, @@ -297,20 +297,19 @@ Dart objects: `plan` is a `PLYPlan`, `subscriptionOffer` is a nullable --- -## Deeplinks & default result handler +## Deeplinks, campaigns & default dismiss handler ```dart // Allow deeplinks at start: await PurchaselyBuilder.apiKey('').allowDeeplink(true).start(); -// Default result handler (replaces setDefaultPresentationResultHandler) — attach -// onDismissed to a default-source request: -PresentationBuilder.defaultSource() - .onDismissed((outcome) { - print('Deeplink presentation dismissed: ${outcome.purchaseResult} / ${outcome.closeReason}'); - }) - .build() - .display(); +// Default dismiss handler (renamed from setDefaultPresentationResultHandler). +// Used for presentations opened by the SDK itself: campaigns, deeplinks, +// promoted in-app purchases. +await Purchasely.setDefaultPresentationDismissHandler((outcome) { + print('SDK presentation dismissed: ${outcome.presentation?.screenId} / ' + '${outcome.purchaseResult} / ${outcome.closeReason}'); +}); // isDeeplinkHandled is UNCHANGED: final handled = await Purchasely.isDeeplinkHandled('app://ply/presentations/'); diff --git a/purchasely/skills/purchasely-migrate/SKILL.md b/purchasely/skills/purchasely-migrate/SKILL.md index 089dcd4..9b4e21a 100644 --- a/purchasely/skills/purchasely-migrate/SKILL.md +++ b/purchasely/skills/purchasely-migrate/SKILL.md @@ -124,7 +124,7 @@ The Flutter plugin migration **adapts the integration to the Purchasely 6.0 nati 9. **Presentation API (display / preload).** Replace `fetchPresentation` / `presentPresentation*` with a `PresentationBuilder` request: `PresentationBuilder.placement(id)` | `.screen(id)` | `.defaultSource()`, chain `.contentId(...)` / `.onLoaded` / `.onPresented` / `.onCloseRequested` / `.onDismissed`, then `.build()` to get a `PresentationRequest`. `request.preload()` → a loaded `Presentation`; `request.display([Transition])` shows the screen and resolves **at dismiss** with a `PresentationOutcome` (fields: `presentation`, `purchaseResult`, `plan`, `closeReason`, `error`). `purchaseResult` is the `PurchaseResult` enum (`purchased` / `restored` / `cancelled`). Transitions are `const Transition.fullScreen()` / `Transition.modal()`. Concretely: `presentPresentationForPlacement(id, isFullscreen: true)` → `PresentationBuilder.placement(id).build().display(const Transition.fullScreen())`; `presentPresentationWithIdentifier(screenId)` → `PresentationBuilder.screen(screenId).build().display(const Transition.modal())`; `fetchPresentation(placementId: id)` → `PresentationBuilder.placement(id).build().preload()`. Never use `fetchPresentation`/`presentPresentation` in v6. 10. **Presentation lifecycle (close / back).** A loaded `Presentation` exposes `.display([Transition])`, `.close()` and `.back()`. Replace `Purchasely.closePresentation()` / `hidePresentation()` / `close()` with `presentation.close()` and `showPresentation()` with `presentation.display()`. **There is NO `closePresentation()` / `hidePresentation()` / `closeAllScreens()` in Flutter v6** — dismiss via `presentation.close()`. 11. **Inline / embedded UI.** Replace `Purchasely.getPresentationView(...)` with the `PLYPresentationView(request: …)` widget (from `package:purchasely_flutter/native_view_widget.dart`); build the `PresentationRequest` (e.g. with `.onDismissed((outcome) => …)`) and pass it to the widget. -12. **Deeplinks.** Move deeplink permission onto the builder: `.allowDeeplink(true)`. At runtime use `Purchasely.handleDeeplink(uri)` and `Purchasely.allowDeeplink(bool)`. The v5 names `readyToOpenDeeplink` / `isDeeplinkHandled` remain only as **deprecated aliases** — prefer the v6 names. v6 displays deeplinks/campaigns immediately by default. +12. **Deeplinks / campaigns / default dismiss handler.** Move deeplink permission onto the builder: `.allowDeeplink(true)`, and gate automatic campaigns with `.allowCampaigns(false/true)` when needed. At runtime use `Purchasely.handleDeeplink(uri)`, `Purchasely.allowDeeplink(bool)`, and `Purchasely.allowCampaigns(bool)`. Replace `setDefaultPresentationResultHandler(cb)` with `Purchasely.setDefaultPresentationDismissHandler(cb)` for SDK-opened presentations (campaigns, deeplinks, promoted in-app purchases). The v5 names `readyToOpenDeeplink` / `isDeeplinkHandled` were removed from the Flutter v6 surface. v6 displays deeplinks/campaigns immediately by default. 13. **`synchronize()` now reports completion.** `await Purchasely.synchronize()` keeps its `Future` signature but now **resolves when synchronization completes** and **throws a `PlatformException` on failure** (was fire-and-forget). Wrap it in `try/catch` if you act on the result before chaining a subscriber-targeted presentation. 14. **Removed: `presentSubscriptions()` (BREAKING).** `Purchasely.presentSubscriptions()` is **removed entirely** from Flutter v6 (the native subscriptions screen was removed on both platforms) — it is **not** a no-op, the method no longer exists. Remove every call and build your own subscriptions screen from `Purchasely.userSubscriptions()` / `Purchasely.userSubscriptionsHistory()`. `Purchasely.displaySubscriptionCancellationInstruction()` is kept for source-compatibility but is a **no-op on both platforms**. 15. **Unchanged (do not rewrite).** Everything outside the paywall surface keeps source-compatible `Purchasely.*` signatures: `purchaseWithPlanVendorId`, `signPromotionalOffer`, `restoreAllProducts`, `userLogin` / `userLogout` / `isAnonymous`, `allProducts` / `productWithIdentifier` / `planWithIdentifier` / `isEligibleForIntroOffer`, `userSubscriptions` / `userSubscriptionsHistory`, user attributes, `listenToEvents` / `listenToPurchases`, dynamic offerings, consent, `setLanguage` / `setThemeMode` / `setLogLevel` / `setDebugMode`. The bridge is still MethodChannel/EventChannel — do not touch it.