Skip to content

feat: Custom Screens (BYOS) support for the Flutter SDK - #134

Open
chouaibMo wants to merge 1 commit into
mainfrom
docs/custom-screens-byos-plan
Open

feat: Custom Screens (BYOS) support for the Flutter SDK#134
chouaibMo wants to merge 1 commit into
mainfrom
docs/custom-screens-byos-plan

Conversation

@chouaibMo

@chouaibMo chouaibMo commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

What

Adds the Custom Screens (BYOS) feature to the Flutter SDK — Flutter-authored UI for CLIENT steps inside native Purchasely flows. When a flow reaches an is_client screen, the SDK asks the app for UI via a provider and embeds it inside the flow container (inheriting the step transition); the app drives navigation by executing connections.

Scope note: this branch is stacked on the full Purchasely 6.0 (v6) migration (native SDKs pinned to 6.0.0-rc.3), so the diff vs main spans the whole 6.0 line — PLY-prefix API rename, builder-based presentation API, native bridge rewrite, E2E infrastructure, and migration docs — culminating in Custom Screens.

Tracked by MOB-203 (parent MOB-200 "Pause/Resume flow → BYOS Bridges"). Plan: docs/plans/custom-screens-byos.md.

Custom Screens implementation

  • Public Dart API: Purchasely.setCustomScreenProvider({entrypoint, libraryUri}) / removeCustomScreenProvider(), PurchaselyCustomScreens.run(args, builder), PLYConnection, and execute/back/close on PLYCustomScreenPresentation.
  • Rendering: a secondary FlutterEngine (via FlutterEngineGroup) runs a dedicated @pragma('vm:entry-point') entrypoint where the app registers a widget builder; the native provider (PLYCustomScreenProvider on Android, PLYCustomScreenViewControllerDelegate on iOS) returns a host FlutterFragment / FlutterViewController. The isolate-isolation trade-off (no shared app state) is documented.
  • Wire protocol: a purchasely-custom-screen MethodChannel bridges the spawned isolate to a customScreenId-keyed native registry (getCustomScreenPresentation / customScreenExecuteConnection / customScreenBack / customScreenClose), modeled on the existing action-interceptor round trip.
  • Docs + example: README, sdk_public_doc.md, and an example demo against placement byos.

Code review (Custom Screens subset)

Ran a multi-persona review of the custom-screens change. Fixes applied, committed as fix(review): …, and verified — flutter analyze clean, 249 Dart tests pass, Android debug APK + iOS simulator builds succeed:

  • P0 Kotlin compile error — args["connectionId"] used the index operator on a nullable map (would fail the Android build)
  • P2 Android registry leak on config-change / system-initiated teardown paths
  • P2 iOS secondary-engine teardown timing moved from willMove(toParent:nil) to deinit
  • Graceful Dart nav error handling; disambiguated the customScreenExecuteConnection channel method; deterministic iOS connection ordering; sdk_public_doc.md TOC fix

Open follow-ups (not yet addressed)

  • P1 — metadata serialization. metadata is now serialized on every presentation event with no codec coercion. Verify SDK metadata values are StandardMessageCodec-safe (else coerce/gate to client steps) + add a native round-trip test — otherwise a non-primitive value can break normal paywalls, not just custom screens.
  • P2 — decide whether execute()/customScreenId belong on the base PLYPresentation (currently a foot-gun on non-client presentations + a source-breaking abstract method); confirm/document the iOS screenId source change (p.idp.screenId).
  • Land the deferred M5 E2E validation (all 5 transition types, back nav, engine-leak, process-death restoration, event parity) before release.

The review covered the Custom Screens subset only; the broader v6-migration commits on this branch were not part of that pass.

🤖 Generated with Claude Code

Base automatically changed from feat/sdk-v6-migration to main July 21, 2026 16:06
@chouaibMo
chouaibMo marked this pull request as ready for review July 23, 2026 14:17
@chouaibMo chouaibMo changed the title docs(plans): Custom Screens (BYOS) implementation plan feat: Custom Screens (BYOS) support for the Flutter SDK Jul 23, 2026
@greptile-apps

greptile-apps Bot commented Jul 23, 2026

Copy link
Copy Markdown

Greptile Summary

This PR migrates the Flutter bridge to the native v6 APIs and adds Custom Screen support. The main changes are:

  • A new presentation builder and lifecycle API.
  • Action interceptors and dismiss-event routing across Dart, Android, and iOS.
  • Custom Screens hosted in secondary Flutter engines.
  • Updated native dependencies, examples, tests, workflows, and migration docs.

Confidence Score: 4/5

The Android presentation-close path needs a fix before merging.

  • Android ignores the presentation handle and closes every active native screen.
  • The Dart and iOS APIs otherwise model close as a handle-scoped operation.
  • No security issue was identified in the changed paths.

purchasely/android/src/main/kotlin/io/purchasely/purchasely_flutter/PurchaselyFlutterPlugin.kt

Important Files Changed

Filename Overview
purchasely/lib/src/custom_screens.dart Adds the Custom Screen entrypoint, presentation wrapper, widget host, and navigation channel.
purchasely/lib/src/bridge.dart Adds v6 presentation lifecycle, interceptor, event-routing, and Custom Screen bridge behavior.
purchasely/android/src/main/kotlin/io/purchasely/purchasely_flutter/PurchaselyCustomScreenFragment.kt Hosts Android Custom Screens in dedicated Flutter engines with keyed bridge routing and cleanup.
purchasely/android/src/main/kotlin/io/purchasely/purchasely_flutter/PurchaselyFlutterPlugin.kt Implements the Android v6 bridge and Custom Screen provider, but broadens handle-scoped close into a global close.
purchasely/ios/Classes/PurchaselyCustomScreenViewController.swift Hosts iOS Custom Screens in secondary Flutter engines and manages their bridge lifecycle.
purchasely/ios/Classes/SwiftPurchaselyFlutterPlugin.swift Implements the iOS v6 presentation, interceptor, dismiss, and Custom Screen contracts.
docs/plans/custom-screens-byos.md Documents the Custom Screen architecture, API, wire protocol, milestones, trade-offs, and open questions.

Sequence Diagram

sequenceDiagram
    participant App as Host Flutter App
    participant Bridge as Flutter Bridge
    participant Native as Native Purchasely SDK
    participant Screen as Custom Screen Engine
    App->>Bridge: setCustomScreenProvider(entrypoint)
    Bridge->>Native: Register provider
    Native->>Bridge: Request CLIENT screen
    Bridge->>Screen: Start entrypoint(customScreenId)
    Screen->>Bridge: Load presentation data
    Bridge-->>Screen: Metadata and connections
    Screen->>Bridge: execute / back / close
    Bridge->>Native: Update native flow
Loading

Fix All in Claude Code

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

---

### Issue 1 of 1
purchasely/android/src/main/kotlin/io/purchasely/purchasely_flutter/PurchaselyFlutterPlugin.kt:631-635
**Handle Close Becomes Global Close**

Dart passes the loaded presentation's `requestId`, but Android ignores it and calls `closeAllScreens()`. When an app-owned presentation and an SDK-opened campaign overlap, closing either handle dismisses both screens and emits outcomes for the unrelated presentation; iOS preserves the handle-scoped behavior by resolving `loadedPresentations[requestId]`.

Reviews (1): Last reviewed commit: "feat(example): point the Custom Screen d..." | Re-trigger Greptile

Comment on lines +631 to +635
private fun closePresentation(args: Map<String, Any?>?, result: Result) {
// The native SDK does not expose per-presentation programmatic close;
// close all screens regardless of whether a requestId was provided.
Purchasely.closeAllScreens()
result.safeSuccess(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 Handle Close Becomes Global Close

Dart passes the loaded presentation's requestId, but Android ignores it and calls closeAllScreens(). When an app-owned presentation and an SDK-opened campaign overlap, closing either handle dismisses both screens and emits outcomes for the unrelated presentation; iOS preserves the handle-scoped behavior by resolving loadedPresentations[requestId].

Prompt To Fix With AI
This is a comment left during a code review.
Path: purchasely/android/src/main/kotlin/io/purchasely/purchasely_flutter/PurchaselyFlutterPlugin.kt
Line: 631-635

Comment:
**Handle Close Becomes Global Close**

Dart passes the loaded presentation's `requestId`, but Android ignores it and calls `closeAllScreens()`. When an app-owned presentation and an SDK-opened campaign overlap, closing either handle dismisses both screens and emits outcomes for the unrelated presentation; iOS preserves the handle-scoped behavior by resolving `loadedPresentations[requestId]`.

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

Fix in Claude Code

Render Flutter UI for a CLIENT step inside a native Purchasely flow via a
secondary FlutterEngine (dedicated isolate) and a purchasely-custom-screen
MethodChannel bridging to a customScreenId-keyed native registry. Adds
PLYConnection, connections/customScreenId/metadata on PLYPresentation,
execute() navigation, Purchasely.setCustomScreenProvider,
PurchaselyCustomScreens.run, example demo, docs, and tests. Includes the
code-review fixes (Android registry-leak guard, iOS engine teardown timing,
graceful Dart channel error handling, deterministic iOS connection order,
customScreenExecuteConnection channel method).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@chouaibMo
chouaibMo force-pushed the docs/custom-screens-byos-plan branch from a0c9fd1 to 06583aa Compare July 24, 2026 12:04
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