Skip to content

refactor: migrate the Flutter plugin to the Purchasely 6.0 native SDK#120

Open
kherembourg wants to merge 21 commits into
mainfrom
feat/sdk-v6-migration
Open

refactor: migrate the Flutter plugin to the Purchasely 6.0 native SDK#120
kherembourg wants to merge 21 commits into
mainfrom
feat/sdk-v6-migration

Conversation

@kherembourg

@kherembourg kherembourg commented May 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adapts the Flutter plugin to the Purchasely 6.0 native SDK. This is a migration of the existing plugin — same public surface as before, just updated for 6.0 — not a rewrite. There is no separate "v6" module and no v6/V6 symbol anywhere: each platform keeps a single FlutterPlugin, and the Dart code keeps the lib/src/ split.

What changed

  • start, presentation (preload/display/close/back) and the action interceptor now call the native 6.0 API. Their implementations were folded back into the single plugin per platform (SwiftPurchaselyFlutterPlugin.swift, PurchaselyFlutterPlugin.kt) — the temporary PurchaselyV6Bridge.{swift,kt} is removed.
  • Every other method is kept (purchases, restore, identity, user attributes, products/plans, subscriptions data, events, dynamic offerings, consent, config) and adapted where the 6.0 API changed a signature (allowDeeplink, handleDeeplink, isEligibleToOffer, storeOfferId, userLogout(true), …).
  • Inline native view (NativeView/NativeViewFactory + native_view_widget.dart) kept and adapted to 6.0 (built from a loaded presentation keyed by requestId).
  • Dart (lib/src/ split kept): dispatcher renamed PurchaselyV6BridgePurchaselyBridge; V6RunningMode/V6LogLevelRunningMode/LogLevel; MethodChannel verbs un-prefixed; the presentation/interceptor stream is EventChannel('purchasely-presentation-events'); request_id.dart inlined. The legacy Purchasely class drops only the old start/presentation/interceptor methods (now provided by the builders); the example v6_demo_screen.dartpresentation_demo_screen.dart.

Removed

  • Android: PLYProductActivity + PLYSubscriptionsActivity — the 6.0 Android SDK no longer exposes the built-in subscriptions screen, so presentSubscriptions is a no-op on Android (still works on iOS).
  • iOS: 3 dead v5 presentation/interceptor +ToMap extensions whose types changed/disappeared in 6.0 (PLYPlan/PLYProduct/PLYSubscription/PLYOfferSignature +ToMap are kept).

Native SDK dependency status

  • Android — requires io.purchasely:core:6.0.0, not yet on Maven Central; resolved from mavenLocal() for local builds.
  • iOS — requires Purchasely 6.0.0, not yet on CocoaPods trunk (trunk tops out at 5.7.x); the 6.0 API lives on the iOS SDK develop branch and is consumed locally via a :path dev-pod. The example's iOS build config (Podfile dev-pod, deployment target 15.0) is intentionally kept local / uncommitted (machine-specific).
  • ⇒ The native CI jobs (Build / Unit Tests, Android & iOS) stay red until 6.0.0 is published to the package registries; the Dart jobs are green. Remove mavenLocal() and the iOS :path dev-pod once 6.0.0 ships.

Test plan / verification

  • flutter analyze (package + example) — 0 errors
  • flutter test209 pass
  • ./gradlew :purchasely_flutter:compileDebugKotlin — BUILD SUCCESSFUL (against core:6.0.0 from mavenLocal)
  • flutter build ios --simulator — Built (against the Purchasely 6.0.0 dev-pod)
  • Native Android JUnit + iOS XCTest in CI — pending 6.0.0 publish

🤖 Generated with Claude Code

kherembourg and others added 9 commits May 28, 2026 20:00
Introduces the Dart-side v6 façade per BRIDGE-CONTRACT.md:
- Presentation, PresentationBuilder, PresentationRequest
- PresentationOutcome (5-field enriched result)
- ActionInterceptor with typed actions
- Transition (animation/transition options)
- RequestId (correlation id for bridge calls)
- PurchaselyBuilder (top-level v6 entrypoint)

These types are platform-agnostic and form the contract the iOS/Android
Flutter bridges will implement via MethodChannel/EventChannel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds re-exports for the v6 cross-platform façade (Presentation,
PresentationBuilder, PresentationRequest, PresentationOutcome,
Transition, action interceptor types, PurchaselyBuilder) so callers
get the full v6 API by importing the package entry point.

The v6 builder enums clash by name with two legacy v5 enums
(`PLYRunningMode` had 4 values in v5, `PLYLogLevel` had the same 4 in
v5) so they are renamed `V6RunningMode` / `V6LogLevel` to allow both
APIs to co-exist during the migration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a new PurchaselyV6Bridge.kt that dispatches `v6/*` MethodChannel
calls against the v6 Android SDK builder DSL (PLYPresentationBase),
emits lifecycle callbacks (`onLoaded`, `onPresented`, `onCloseRequested`,
`onDismissed`) and interceptor invocations on a new `purchasely/v6-events`
EventChannel, and round-trips interceptor results via
`v6/interceptorResolve`.

Bumps the native dependency `io.purchasely:core` to `6.0.0` (v6 SDK
Builder DSL + `PLYPresentationBase`/`PLYPresentationAction` sealed
class). Adjusts the legacy v5 start callback path to match the v6
single-arg `(PLYError?) -> Unit` callback shape and collapses the
v5 PaywallObserver/TransactionOnly running modes onto v6
`PLYRunningMode.Observer`.

The existing v5 surface (`Purchasely.start`, `fetchPresentation`, etc.)
is left intact; the v6 bridge runs alongside it so apps can migrate
incrementally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…karounds

Adds PurchaselyV6Bridge.swift dispatching `v6/*` MethodChannel calls
against the v6 iOS SDK (PurchaselyBuilder, PLYPresentationBuilder /
PLYPresentationRequest, interceptAction). Lifecycle and interceptor
events are emitted on the new `purchasely/v6-events` EventChannel and
results round-trip through `v6/interceptorResolve`.

Bridge workarounds per BRIDGE-CONTRACT.md:

  * P0.1 — iOS exposes `onClose`; emitted on the wire as
    `onCloseRequested` so the Dart façade matches Android.
  * P0.2 — iOS `PLYPresentationOutcome` has only `purchaseResult` + `plan`;
    the 5-field enriched outcome (`presentation`, `closeReason`, `error`)
    is synthesised here. `closeReason` is `nil` until the native fix lands.
  * P0.3 — `display(...)` completion fires at trigger time, not dismiss
    time; the Dart-side `.display()` Future resolves from the
    `onDismissed` event, not from this completion handler.
  * P0.4 — when the display/preload completion delivers an error, the
    bridge synthesises `onPresented(nil, error)` and an error outcome so
    Dart callbacks fire uniformly across platforms.
  * P1.1 — Dart `screen(screenId)` maps to iOS
    `PLYPresentationBuilder.from(presentationId:)`; iOS `presentation.id`
    is emitted as `screenId` on the wire.

Bumps the iOS pod dependency to `Purchasely 6.0.0`. The existing v5
SwiftPurchaselyFlutterPlugin is left in place and dispatches v6 calls to
the new bridge before falling through to legacy handlers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a v6 demo screen (`example/lib/v6_demo_screen.dart`) showing the
canonical v6 flow:
  * SDK init via `PurchaselyBuilder.apiKey(...).start()`
  * Display via `PresentationBuilder.placement(...).build().display(...)`
  * Lifecycle callbacks: onLoaded, onPresented, onCloseRequested, onDismissed
  * Enriched 5-field `PresentationOutcome` rendered as a card

A placeholder for typed `interceptAction(navigate, ...)` is wired to a
button; the actual cross-bridge interceptor dispatcher lives on the Dart
façade side and ships separately.

The legacy v5 example screens are kept intact — a new "Open v6 demo"
button on the home screen routes to the new demo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- README: documents the new v6 builder-based API as the primary usage
  path, with a v5 → v6 migration table and a legacy v5 section kept for
  reference.
- CHANGELOG: adds the 6.0.0-beta.0 entry covering the new cross-platform
  façade, bridge contract workarounds, native SDK bumps, and breaking
  changes (Observer is now the default running mode).
- pubspec.yaml: bumps `purchasely_flutter` to `6.0.0-beta.0`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduces `lib/src/bridge.dart` (PurchaselyV6Bridge) which routes the
v6 Dart façade calls to the `purchasely` MethodChannel (`v6/*` verbs)
and dispatches lifecycle events from the `purchasely/v6-events`
EventChannel back to the request/presentation callbacks per the
BRIDGE-CONTRACT v3-claude. Hooks `PresentationActions.instance` and
`PresentationRequestActions.instance` once any v6 entry point is
invoked (lazy install in `PurchaselyBuilder.start()` and
`PresentationBuilder.build()`).

- Routes preload/display/close/back to native, decodes Presentation +
  PresentationOutcome maps, surfaces PlatformException as
  PresentationError.
- `display()` awaits the native `onDismissed` event (matches
  P0.3 — Promise resolves at DISMISS, not trigger).
- Interceptor pipeline: registers handlers on the Dart side, awaits
  `interceptorTriggered` events, resolves via
  `v6/interceptorResolve` (mapped to PLYInterceptResult).
- Exposes `PurchaselyV6Bridge.ensureInstalled` / `.debugReset` to allow
  channel injection in tests.

Adds `test/bridge_test.dart` (4 tests) covering preload args,
display-awaits-dismiss, onLoaded callback firing and Transition
serialization. Full suite: 267 tests pass, `flutter analyze` clean.

Known native gaps (not in scope of this commit):
- Android `v6/close` ignores `requestId` and globally calls
  `closeAllScreens()` — per-presentation programmatic close is not yet
  exposed by the SDK (already documented in PurchaselyV6Bridge.kt).
- iOS bridge will need to surface `onLoaded` events explicitly for the
  Dart-side `onLoaded` callback to fire post-preload (currently the
  preload completion handler is the only signal — Dart treats the
  MethodChannel response as the loaded state, so this works, but a
  parallel `onLoaded` event would let the request-level callback fire
  with the iOS-synthesized PresentationError on load failure).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…fecycle

Extends test/bridge_test.dart from 4 to 9 tests:
- Outcome 5 fields with closeReason (P0.2)
- Outcome with error and null closeReason (P0.2 mutual exclusion)
- onCloseRequested fires builder callback
- Interceptor lifecycle: register → trigger → resolve via invocationId
- removeInterceptor unregisters the kind

Parity with React Native v6 integration tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented May 28, 2026

Copy link
Copy Markdown

Greptile Summary

This PR introduces the v6 cross-platform bridge: a new Dart façade (PresentationBuilder, PresentationRequest, Presentation, PresentationOutcome, typed action interceptors) backed by native bridges in Kotlin (507 lines) and Swift (525 lines), all wired through a shared purchasely MethodChannel and a dedicated purchasely/v6-events EventChannel. The v5 surface remains untouched, making this entirely opt-in.

  • Dart bridge (lib/src/bridge.dart) manages in-flight request entries keyed by requestId, correctly handles preload → display → dismiss → re-display lifecycle (regression tested), and routes typed interceptor invocations to Dart handlers with async resolve back to native.
  • Android bridge correctly maps all PLYTransitionType values (including inlinePaywall), uses ConcurrentHashMap for thread safety, and emits outcomes via the EventChannel rather than the MethodChannel result — matching the Dart dispatcher's expectation.
  • iOS bridge synthesises the 5-field outcome contract on top of the 2-field native PLYPresentationOutcome, but the display-error synthesis path (P0.4) has a timing issue: events are dispatched async while the MethodChannel error is synchronous, causing the Dart entry to be removed before the onPresented(nil, error) callback can fire.

Confidence Score: 4/5

Safe to merge as an opt-in v6 façade; the v5 surface is untouched. One real defect in the iOS display-error path means builder-registered onPresented callbacks silently don't fire when display fails on iOS, though the Future-based API resolves correctly.

The iOS bridge's display error path calls result(FlutterError) synchronously while emitting onPresented/onDismissed events asynchronously via DispatchQueue.main.async. The Dart PlatformException handler removes the request entry before the async events are processed, so the P0.4 onPresented(nil, error) synthesis never reaches the builder callback. Developers relying on builder callbacks for error notification on iOS get silent failures.

purchasely/ios/Classes/PurchaselyV6Bridge.swift — the display-error completion block (lines 272–299) should call result(true) instead of result(FlutterError) and let the already-emitted onDismissed event carry the error to Dart, matching Android's event-only outcome delivery.

Important Files Changed

Filename Overview
purchasely/ios/Classes/PurchaselyV6Bridge.swift iOS bridge synthesising the 5-field contract. Display-error path emits events via DispatchQueue.main.async but calls result(FlutterError) synchronously, so P0.4 onPresented(nil,error) callbacks are silently swallowed by the Dart PlatformException handler. Also hardcodes NSNull() for contentId.
purchasely/android/src/main/kotlin/io/purchasely/purchasely_flutter/PurchaselyV6Bridge.kt Android bridge implementing the full v6 contract. displayCallbacks entry leaks on synchronous display errors; loadedPresentations/preparedRequests grow unboundedly across the session. Logic is otherwise correct.
purchasely/lib/src/bridge.dart Core Dart dispatcher wiring MethodChannel/EventChannel to the v6 façade. Entry lifecycle (register, re-display, dismiss) is solid; the PlatformException guard prevents double-completion. No new issues found.
purchasely/lib/src/action_interceptor.dart Typed action payload hierarchy and wire serialisation. Consistent with both native bridges; null-guarded parsing for required fields.
purchasely/test/bridge_test.dart 5 new integration tests covering preload, display, callbacks, interceptor lifecycle, and re-display regression. Good coverage of the happy path and the previously-flagged re-display hang.
purchasely/lib/src/presentation_outcome.dart 5-field outcome model with correct closeReason/error mutual-exclusion semantics; handles both camelCase and snake_case variants for backSystem.

Sequence Diagram

sequenceDiagram
    participant App as Flutter App
    participant Dart as PurchaselyV6Bridge (Dart)
    participant MC as MethodChannel purchasely
    participant EC as EventChannel purchasely/v6-events
    participant Native as Native Bridge Android/iOS

    App->>Dart: display()
    Dart->>MC: "v6/display {requestId, transition}"
    MC->>Native: handle v6/display
    Native-->>MC: result(true)
    MC-->>Dart: invokeMethod resolves OK
    Note over Dart: completer stored awaiting dismiss
    Native-->>EC: "onPresented {requestId, presentation}"
    EC-->>Dart: _handleOnPresented fires callback
    Native-->>EC: "onDismissed {requestId, outcome}"
    EC-->>Dart: completer.complete(outcome)
    Dart-->>App: Future resolves with PresentationOutcome
    Note over Native,Dart: iOS error path P0.4 issue
    Native-->>EC: onPresented nil error async main queue
    Native-->>EC: onDismissed error outcome async main queue
    Native-->>MC: result(FlutterError) synchronous
    MC-->>Dart: PlatformException entry removed completer complete
    EC-->>Dart: onPresented arrives entry null SKIPPED
    EC-->>Dart: onDismissed arrives entry null SKIPPED
Loading

Fix All in Claude Code Fix All in Cursor Fix All in Codex

Prompt To Fix All With AI
Fix the following 4 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 4
purchasely/ios/Classes/PurchaselyV6Bridge.swift:272-299
**P0.4 onPresented synthesis never fires on iOS display errors**

`events.emit(...)` wraps its payload in `DispatchQueue.main.async`, so the `onPresented` and `onDismissed` events are queued for a future run-loop iteration. But `result(FlutterError(...))` is called synchronously in the same closure and is processed first by Dart. The `_displayPresentation`/`_displayRequest` PlatformException handler removes the entry from `_entries` and completes the dismiss completer before either async event is delivered. When the events eventually arrive, `_handleOnPresented` and `_handleOnDismissed` find `entry == null` and return early — the builder's `onPresented(nil, error)` callback never fires.

Changing the error path to call `result(true)` instead of `result(FlutterError(...))` would let both events be processed while the entry is still live, matching the behavior documented in BRIDGE-CONTRACT P0.4 and aligning with Android's event-only outcome delivery.

### Issue 2 of 4
purchasely/ios/Classes/PurchaselyV6Bridge.swift:386
**iOS `contentId` always serialised as `null`**

All other optional fields (`placementId`, `audienceId`, `abTestId`, etc.) pass through their `PLYPresentation` values with `as Any`, but `contentId` is unconditionally `NSNull()`. If `PLYPresentation` exposes a `contentId` property in the v6 SDK, this silently drops the value; `Presentation.contentId` will always be `nil` on iOS even when the backend set one.

```suggestion
            "contentId": p.contentId as Any,  // TODO: verify contentId is exposed in v6 SDK
```

### Issue 3 of 4
purchasely/android/src/main/kotlin/io/purchasely/purchasely_flutter/PurchaselyV6Bridge.kt:264-275
**`displayCallbacks` entry leaks on synchronous display error**

`displayCallbacks[requestId]` is set before the `try` block. If `prepared.display(...)` throws synchronously, the catch block calls `result.error(...)` but never removes the `displayCallbacks[requestId]` entry. The key lives in the map indefinitely, holding a no-op lambda. Adding `displayCallbacks.remove(requestId)` inside the catch block closes the leak.

### Issue 4 of 4
purchasely/android/src/main/kotlin/io/purchasely/purchasely_flutter/PurchaselyV6Bridge.kt:209-218
**`loadedPresentations` and `preparedRequests` grow unboundedly**

`loadedPresentations[requestId]` is populated in `v6Preload` and `preparedRequests[requestId]` in `buildPrepared`, but neither map is pruned when a presentation is dismissed (`buildPrepared.onDismissed` only removes from `displayCallbacks`). Because `nextRequestId()` generates a fresh UUID for every `PresentationBuilder.build()` call, each displayed presentation leaves a permanent `PLYPresentation` reference for the lifetime of the bridge. The same applies to `presentations` on the iOS side.

Reviews (3): Last reviewed commit: "chore(dart): apply dart format to v6 dem..." | Re-trigger Greptile

Comment thread purchasely/lib/src/bridge.dart
Comment thread purchasely/ios/Classes/PurchaselyV6Bridge.swift Outdated
- bridge.dart: re-display after dismiss no longer hangs — _displayPresentation
  now re-registers the request entry (keyed by requestId) from the Presentation
  handle so the dismiss completer is always stored. _RequestEntry.request is now
  nullable; handlers guard accordingly. Adds a regression test (P1).
- PurchaselyV6Bridge.kt: drop dead if/else in v6Close (both branches called
  closeAllScreens) and collapse identical errorToMap branches; remove now-unused
  PLYError import.
- PurchaselyV6Bridge.swift: outcomeToMap now threads the real requestId into the
  nested presentation map instead of an empty string.

https://claude.ai/code/session_01TMtx4cHizaTD3TR77MD1Vk

Copy link
Copy Markdown
Collaborator Author

@greptileai review


Generated by Claude Code

kherembourg and others added 2 commits May 29, 2026 12:18
parseTransition fell through to else -> null for the inlinePaywall wire
value, so a Dart caller passing Transition(type: TransitionType.inlinePaywall)
got the SDK default transition on Android while iOS correctly mapped it to
.inlinePaywall. Map "inlinePaywall" to PLYTransitionType.INLINE_PAYWALL to
restore cross-platform parity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kherembourg

Copy link
Copy Markdown
Collaborator Author

@greptileai review

Comment on lines +272 to +299
} else if let error = error {
// P0.4 — synthesise onPresented(nil, error) so the Dart-side
// builder onPresented handler fires uniformly across platforms.
self.events.emit([
"event": "onPresented",
"requestId": requestId,
"presentation": nil as Any?,
"error": Self.errorToMap(error),
])
// Also synthesise onDismissed with the 5-field error outcome.
let outcome = self.outcomeToMap(
PLYPresentationOutcome(purchaseResult: .none, plan: nil),
presentation: nil,
error: error,
requestId: requestId
)
self.events.emit([
"event": "onDismissed",
"requestId": requestId,
"outcome": outcome,
])
result(FlutterError(code: "V6_DISPLAY",
message: error.localizedDescription,
details: Self.errorToMap(error)))
} else {
result(true)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 P0.4 onPresented synthesis never fires on iOS display errors

events.emit(...) wraps its payload in DispatchQueue.main.async, so the onPresented and onDismissed events are queued for a future run-loop iteration. But result(FlutterError(...)) is called synchronously in the same closure and is processed first by Dart. The _displayPresentation/_displayRequest PlatformException handler removes the entry from _entries and completes the dismiss completer before either async event is delivered. When the events eventually arrive, _handleOnPresented and _handleOnDismissed find entry == null and return early — the builder's onPresented(nil, error) callback never fires.

Changing the error path to call result(true) instead of result(FlutterError(...)) would let both events be processed while the entry is still live, matching the behavior documented in BRIDGE-CONTRACT P0.4 and aligning with Android's event-only outcome delivery.

Prompt To Fix With AI
This is a comment left during a code review.
Path: purchasely/ios/Classes/PurchaselyV6Bridge.swift
Line: 272-299

Comment:
**P0.4 onPresented synthesis never fires on iOS display errors**

`events.emit(...)` wraps its payload in `DispatchQueue.main.async`, so the `onPresented` and `onDismissed` events are queued for a future run-loop iteration. But `result(FlutterError(...))` is called synchronously in the same closure and is processed first by Dart. The `_displayPresentation`/`_displayRequest` PlatformException handler removes the entry from `_entries` and completes the dismiss completer before either async event is delivered. When the events eventually arrive, `_handleOnPresented` and `_handleOnDismissed` find `entry == null` and return early — the builder's `onPresented(nil, error)` callback never fires.

Changing the error path to call `result(true)` instead of `result(FlutterError(...))` would let both events be processed while the entry is still live, matching the behavior documented in BRIDGE-CONTRACT P0.4 and aligning with Android's event-only outcome delivery.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Cursor Fix in Codex

kherembourg and others added 4 commits May 29, 2026 12:50
…6.0.0

io.purchasely:core:6.0.0 is not yet on Maven Central/Google; resolve it from
the local Maven repo for local builds, mirroring the Shaker sample. mavenLocal()
is placed first in the plugin's rootProject.allprojects and the example app's
allprojects repositories. To be removed once 6.0.0 is published.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…urface

Presentation display, the action interceptor, and SDK init are now v6-only
across Dart, iOS and Android. The legacy v5 paywall-display methods, the v5
action interceptor, Purchasely.start and the inline native view were removed;
all other v5 methods (purchases, identity, attributes, products/plans,
subscriptions data, events, offerings, consent, config) are kept and now
require a PurchaselyBuilder start. Terminology: "paywall" -> "Presentation".

- Dart: remove v5 presentation/interceptor/start + native_view_widget; keep
  the rest; rename paywall -> Presentation.
- iOS: gut SwiftPurchaselyFlutterPlugin to a v6-only-presentation shell (keep
  register + kept v5 handlers + v5 event channels); delete NativeView(Factory)
  + presentation/interceptor ToMaps; fix PurchaselyV6Bridge for native 6.0.
- Android: v6-only dispatch + ActivityAware; delete NativeView(Factory) +
  PLYProductActivity; port kept v5 methods to native core 6.0.0; fix v6 bridge
  (display import, PLYPresentationPlan.storeOfferId).
- Tests: drop v5 presentation/interceptor tests, keep v6 + kept-v5 coverage.
- Example: rewrite to v6-only init + presentation + interceptor.
- Docs: add MIGRATION.md; update CHANGELOG/README/VERSIONS.

BREAKING CHANGE: v5 presentation-display methods, the v5 action interceptor,
Purchasely.start and the PLYPresentationView inline widget are removed. See
purchasely/MIGRATION.md. presentSubscriptions is a no-op on Android (native
6.0 removed the screen).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…lugin, no v6 naming)

This is a pure adaptation of the existing plugin to the Purchasely 6.0 native
SDK — same public surface as before, just migrated. The separate presentation
bridge added during the migration is removed and folded back into the one
plugin per platform; there is no "v6" type/class/symbol anywhere.

- Native: merge PurchaselyV6Bridge.swift / PurchaselyV6Bridge.kt INTO the single
  SwiftPurchaselyFlutterPlugin / PurchaselyFlutterPlugin. start, presentation
  (preload/display/close/back) and the action interceptor now call the 6.0 API
  (PLYPresentationBuilder/Request, interceptAction, ...); every other method is
  kept and adapted to 6.0 signature changes (allowDeeplink, handleDeeplink,
  isEligibleToOffer, storeOfferId, ...). Wire verbs are un-prefixed; the
  presentation/interceptor EventChannel is `purchasely-presentation-events`.
- NativeView / NativeViewFactory kept and adapted to 6.0 (inline view built from
  a loaded presentation keyed by requestId).
- Android: PLYProductActivity + PLYSubscriptionsActivity removed (the 6.0 Android
  SDK no longer exposes the subscriptions screen); presentSubscriptions is a
  no-op on Android (still works on iOS).
- iOS: 3 dead v5 presentation/interceptor +ToMap extensions removed (their types
  changed/disappeared in 6.0); PLYPlan/Product/Subscription/OfferSignature +ToMap
  kept.
- Dart: lib/src split kept; PurchaselyV6Bridge -> PurchaselyBridge,
  V6RunningMode/V6LogLevel -> RunningMode/LogLevel, verbs/channel de-"v6"'d;
  request_id.dart inlined; native_view_widget.dart + example presentation_screen
  kept and adapted; v6_demo_screen -> presentation_demo_screen. The Purchasely
  class drops only the old start/presentation/interceptor methods (now provided
  by the builders); all other methods kept.

Verified: Android compileDebugKotlin OK, iOS simulator build OK, flutter analyze
clean, 209 Dart tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kherembourg kherembourg changed the title feat: v6 cross-platform contract migration refactor: migrate the Flutter plugin to the Purchasely 6.0 native SDK Jun 1, 2026
kherembourg and others added 5 commits June 1, 2026 12:45
Mirrors the React Native SDK's migration docs for Flutter.

- MIGRATION-v6.md: v5 → 6.0 guide (mapping table + before/after) — start,
  presentation and the action interceptor now use the 6.0 builder API; every
  other method is unchanged. Points at the Purchasely AI skills.
- sdk_public_doc.md: public integration guide rewritten for the 6.0 API
  (PurchaselyBuilder, PresentationBuilder/Request, PresentationOutcome,
  PurchaselyBridge.registerInterceptor) with outcome + action-kind tables.
- CHANGELOG.md: rewrite the 6.0.0-beta.0 entry to the real change set; drop the
  stale dual-façade wording and the non-existent Purchasely.interceptAction ref.
- README.md (root + package): add the "Upgrading to 6.0?" link and fix stale
  V6RunningMode/V6LogLevel symbol names.
- action_interceptor.dart: fix the doc comment to the real registration API.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Thin façade over PurchaselyBridge.ensureInstalled().registerInterceptor so the
public way to register an action interceptor reads like the rest of the
`Purchasely` API (mirrors the v5 `setPaywallActionInterceptorCallback` ergonomics):

  Purchasely.interceptAction(kind, handler)
  Purchasely.removeInterceptor(kind)
  Purchasely.removeAllInterceptors()

The bridge API still works underneath. Docs (MIGRATION-v6.md, sdk_public_doc.md),
the action_interceptor doc comment and the example now use the clean API.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review of the 6.0 adaptation surfaced a real regression and several smaller
improvements.

- fix(start): the native `start` handlers (Android + iOS) still read the old v5
  wire shape (`userId`, Int runningMode/logLevel, capitalized store names, Bool
  storeKit1) while `PurchaselyBuilder.start()` sends the new shape (`appUserId`,
  string `runningMode`/`logLevel`, lowercase stores, `storekitVersion`,
  `allowDeeplink`/`allowCampaigns`). The mismatch silently dropped the user id,
  forced Full mode (instead of the documented Observer default), never
  registered a Store, and ignored deeplink/campaign flags. Both handlers now
  read the builder contract; `getStoresInstances` matches lowercase
  google/huawei/amazon. Added a bridge test asserting the exact `start` args.
- fix(leak): the per-requestId presentation maps (loaded/prepared/requests) were
  never cleared; they are now removed in the `onDismissed` callback on both
  platforms (matching the Dart side).
- docs: VERSIONS.md ("Flutter" not "React Native" + 6.0.0-beta.0 row); CHANGELOG
  interceptor snippet uses Purchasely.interceptAction; podspec/build.gradle
  comments reference the single plugin (no more "PurchaselyV6Bridge");
  native_view_widget docstring no longer over-promises inline lifecycle.
- example: demonstrate Purchasely.interceptAction with a typed PurchasePayload.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The embedded inline view previously rendered the screen but reported its
outcome on a dead `native_view` MethodChannel that Dart never handled, so the
PresentationRequest's onDismissed/outcome never fired for the inline path.

Both NativeViews now emit the same `{event:'onDismissed', requestId, outcome}`
envelope on the shared `purchasely-presentation-events` sink (identical shape to
the full-screen path). The Dart bridge already routes that by requestId — the
inline request is registered on preload() — so `PresentationRequest.onDismissed`
and the display()-style outcome now fire for inline presentations too.

- Android: NativeView calls PurchaselyFlutterPlugin.emitPresentationEvent with
  the shared envelope/outcomeToMap; the dead native_view channel is removed;
  the requestId is cleaned from the static maps on dismiss.
- iOS: NativeView emits via a static plugin helper, wiring the loaded
  presentation's onDismissed plus a PLYEventDelegate `.presentationClosed`
  fallback (the embedded child controller doesn't reliably fire the request
  callback), guarded exactly-once; static maps cleaned on dismiss.
- Dart: native_view_widget docstring updated — inline now surfaces dismissal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

2 participants