diff --git a/README.md b/README.md index 3c3d6f22..6289cf54 100644 --- a/README.md +++ b/README.md @@ -54,5 +54,33 @@ switch (outcome.purchaseResult) { } ``` +## Custom Screens + +Flutter can provide the UI for a CLIENT step inside a Purchasely-managed native +flow. Register a dedicated entrypoint immediately after `start()`: + +```dart +await Purchasely.setCustomScreenProvider(); + +@pragma('vm:entry-point') +void purchaselyCustomScreen(List args) { + PurchaselyCustomScreens.run(args, (context, presentation) { + return MaterialApp( + home: MyCustomStep( + onNext: () => presentation.execute(), + onBack: presentation.back, + ), + ); + }); +} +``` + +The custom widget runs in a dedicated Dart isolate: it does not inherit the +main app's Provider/Bloc/Riverpod state, Navigator, theme, or auto-registered +app plugins. Pass static configuration through presentation metadata or +persistent storage. Custom Screen hosting applies to CLIENT steps in native +flows; it is not supported by the inline `PLYPresentationView` or as a +standalone native presentation. + ## 🏁 Documentation A complete documentation is available on our website [https://docs.purchasely.com](https://docs.purchasely.com) diff --git a/docs/plans/custom-screens-byos.md b/docs/plans/custom-screens-byos.md new file mode 100644 index 00000000..3b21f975 --- /dev/null +++ b/docs/plans/custom-screens-byos.md @@ -0,0 +1,400 @@ +# Custom Screens (BYOS) — Flutter SDK Plan + +**Created:** 2026-07-17 +**Status:** Draft — awaiting review +**Tickets:** [MOB-203](https://linear.app/purchasely/issue/MOB-203) (Flutter), parent [MOB-200](https://linear.app/purchasely/issue/MOB-200) "Pause/Resume flow → BYOS Bridges" +**Base branch:** `feat/sdk-v6-migration` (6.0.0-rc.3) +**Native references:** Purchasely-Android-Sources (v6, shipped), Purchasely-iOS-Sources (shipped since 5.6.2) + +> Naming note: "BYOS" was the internal codename on iOS and was scrubbed from all public +> symbols before release. The public feature name is **Custom Screen** on both native SDKs. +> This plan uses "Custom Screen" for all public API and reserves "BYOS" for internal docs. + +--- + +## 1. Context & Goal + +A **Custom Screen** is a screen inside a Purchasely presentation/flow whose UI is authored by +the host app instead of the Screen Composer. The console marks such a screen `is_client: true` +and gives it a `connections` array (named exit paths, each with a `vendor_id`, a `default` +flag, and actions). When the SDK reaches a client screen it asks the app for UI, embeds the +returned UI **inside its own flow container** (inheriting the step's transition — +fullscreen/push/modal/drawer/popin), and the app drives navigation by executing connections. + +This is shipped on both native SDKs: + +| | Android | iOS | +|---|---|---| +| Registration | `Purchasely.setCustomScreenProvider(PLYCustomScreenProvider?)` | `Purchasely.setCustomScreenViewControllerDelegate(_:)` (UIKit) + `setCustomScreenViewDelegate(_:)` (SwiftUI), UIKit tried first | +| Provider callback | `onCustomScreenRequested(presentation: PLYPresentation): PLYCustomScreen?` — synchronous, main thread | `viewController(for: PLYPresentation) -> UIViewController?` — synchronous, main thread | +| Return type | `PLYCustomScreen.View(android.view.View)` / `.Fragment(Fragment)` | `UIViewController?` (SwiftUI variant wrapped in `UIHostingController` by the SDK) | +| Hosting | View `addView`'d / Fragment committed into the current flow fragment's container (`PLYFlowParentFragment.onCustomScreenLoaded`) | Child-VC containment inside the same `PLYProductViewController` used for normal paywalls | +| No provider / null | Warn log; container left empty; display still recorded | Warn log; presentation **self-closes** with `.cancelled` | +| Navigation | `presentation.execute(connection?)` (null → default), `presentation.back()`, `presentation.close()` | `presentation.executeConnection(connection?)` (nil → default), `presentation.close()` | +| Data handed over | Full `PLYPresentation` incl. `connections: List` (`id`, `default`, actions internal-ish) | Full `PLYPresentation` incl. `connections: Set` (only `id` public) | +| Auto events | `clientPresentationDisplayed` fired automatically on mount (`PRESENTATION_VIEWED`) | Standard presentation lifecycle events (client branch shares the normal configure path) | +| Applies to | Flow steps **and** standalone presentations (`PLYPresentationView` standalone path also routes through the provider) | Any `isClient` presentation, flow or standalone | + +The Flutter SDK has **none** of this: no provider API, no `PLYConnection` model, no +`execute()`. What it does have (and this plan builds on): + +- `PLYPresentationType.client` in the Dart enum (`purchasely/lib/src/presentation.dart`). +- `Purchasely.clientPresentationDisplayed/Closed` (notification pair for app-managed + standalone client presentations). +- `PLYPresentation.back()` / `.close()` routed to native. +- The **action-interceptor round trip** (`interceptorTriggered` event → id-keyed pending + completion map → `interceptorResolve` method) — the plumbing template for any + "native waits on Dart" handshake. +- A `requestId`-keyed static registry of native loaded presentations on both platforms + (`loadedPresentations` / `preparedRequests`). + +**Goal:** mirror the native Custom Screen feature in Flutter — same logic, same lifecycle, +API names aligned with Android's neutral naming (`setCustomScreenProvider`) — so a Flutter +app can supply Flutter-authored screens for `CLIENT` steps inside native flows and drive +navigation via connections. + +--- + +## 2. The Core Problem & Chosen Architecture + +The native provider callback is **synchronous on the main thread** and must return native UI +(`View`/`Fragment`/`UIViewController`). Dart cannot answer synchronously, and a Flutter widget +is not a native view. Three architectures were considered: + +### Option A — Secondary FlutterEngine (FlutterEngineGroup) ✅ **chosen** + +The plugin's native provider synchronously returns a host `FlutterFragment` (Android) / +`FlutterViewController` (iOS) backed by an engine spawned from a `FlutterEngineGroup` running +a **dedicated Dart entrypoint** in which the app registered a widget builder. Spawned engines +are cheap (shared GPU context/snapshots), plugins auto-register on them, and the existing +plugin already keeps its registries in static/companion state, so both engines see the same +native presentation registry. + +- ✔ Matches the native design exactly: the custom screen lives *inside* the flow container, + inherits transitions, back-stack, process-death restoration (`PLYFlowManager.update` runs + as for any step). No native-SDK changes required. +- ✔ Works for flows *and* standalone client presentations, `display()` *and* inline + `PLYPresentationView`. +- ✖ **The builder runs in a separate isolate**: no access to the main app's Provider/Bloc/ + Riverpod state or Navigator. This is the documented trade-off (see §8 DX guidance). + The Notion feasibility note ("Flutter — à priori possible") pointed at this route. + +### Option B — Pause/hide the flow, app renders a normal Flutter route (MOB-200 model) ❌ deferred + +The 2025 spec's hybrid model: SDK hides the flow window, app pushes its own route, then calls +`proceed(connection)`. Requires new native pause/resume APIs (MOB-201/MOB-205, both Backlog), +and hiding the flow was flagged as destroying flow context. On Android the flow lives in +`PLYFlowActivity` *above* the `FlutterActivity`, so "app renders behind it" needs the +activity→fragment rework the 15 Oct 2025 workshop deferred. Revisit only if Option A's +isolate DX proves blocking for customers. + +### Option C — Overlay above the inline platform view ❌ rejected + +Only works when the flow is embedded via the `PLYPresentationView` widget (Flutter can draw +routes above a platform view), not for `display()`/`PLYFlowActivity`. Two rendering models +for one feature is not acceptable. + +--- + +## 3. Public Dart API (spec) + +### 3.1 Registration (main isolate) + +```dart +/// Registers the app's custom screen entrypoint with the SDK. +/// [entrypoint] is the name of a top-level @pragma('vm:entry-point') function. +/// [libraryUri] is required if the entrypoint is not in the app's main library. +/// Call after Purchasely.start(), before any presentation is displayed +/// (typically right after start, mirroring native guidance). +static Future setCustomScreenProvider({ + String entrypoint = 'purchaselyCustomScreen', + String? libraryUri, +}) async { ... } + +static Future removeCustomScreenProvider() async { ... } +``` + +### 3.2 The entrypoint + builder (custom-screen isolate) + +```dart +typedef PLYCustomScreenBuilder = Widget Function( + BuildContext context, + PLYCustomScreenPresentation presentation, +); + +@pragma('vm:entry-point') +void purchaselyCustomScreen() { + PurchaselyCustomScreens.run((context, presentation) { + switch (presentation.id) { + case 'onboarding_custom_step': + return MyOnboardingStep(presentation: presentation); + default: + return MyGenericCustomScreen(presentation: presentation); + } + }); +} +``` + +`PurchaselyCustomScreens.run(builder)`: +1. `WidgetsFlutterBinding.ensureInitialized()`. +2. Reads the `customScreenId` from the engine's `dartEntrypointArgs`. +3. Fetches the presentation map over the dedicated channel (`getCustomScreenPresentation`). +4. `runApp` of a minimal host (`Directionality` + `MediaQuery` from the view, no MaterialApp + imposed — the builder brings its own theming) that invokes the builder. + +### 3.3 Models & navigation + +```dart +class PLYConnection { + final String? id; // console vendor_id + final bool isDefault; +} + +/// The presentation as seen from a custom screen. Same fields as PLYPresentation +/// (id/screenId, placementId, contentId, flowId, language, type, plans, metadata, +/// backgroundColor, height, displayMode) plus: +class PLYCustomScreenPresentation extends PLYPresentation { + final List connections; + + /// Executes [connection]'s actions; null → the connection flagged default. + Future execute([PLYConnection? connection]); + + /// Navigate to the previous flow step (or dismiss when first step). + Future back(); + + /// Close all Purchasely screens. + Future close(); +} +``` + +`execute`/`back`/`close` are bound to the **exact native presentation instance** the provider +received (via `customScreenId`), never to a previously fetched one — this mirrors the +documented native pitfall (Android `FlowTests.kt:576`: connections differ per flow instance). + +`connections` is also added to the base `PLYPresentation` model + `toMap`/`fromMap`, so +app-managed standalone CLIENT presentations (fetched via `preload()`) can execute connections +too (M1 below). + +--- + +## 4. Wire Protocol (bridge contract) + +New dedicated channel (registered by the plugin on **every** engine it attaches to, so the +spawned engine gets it automatically): `purchasely-custom-screen` (MethodChannel). + +Main-isolate `purchasely` channel additions: + +| Method | Args | Direction | Notes | +|---|---|---|---| +| `setCustomScreenProvider` | `{entrypoint, libraryUri?}` | Dart→native | Stores entrypoint config; registers the native provider/delegate | +| `removeCustomScreenProvider` | `{}` | Dart→native | Unregisters (Android: `setCustomScreenProvider(null)`; iOS: `removeCustomScreenViewControllerDelegate()`) | +| `executeConnection` | `{requestId or customScreenId, connectionId?}` | Dart→native | For standalone client presentations held by the app | + +Custom-screen channel (spawned isolate ↔ native): + +| Method | Args | Direction | Notes | +|---|---|---|---| +| `getCustomScreenPresentation` | `{customScreenId}` | Dart→native | Returns presentation map (incl. `connections`, `customScreenId`) | +| `executeConnection` | `{customScreenId, connectionId?}` | Dart→native | `connectionId == null` → default connection | +| `customScreenBack` | `{customScreenId}` | Dart→native | Android `presentation.back()`; iOS same call the existing `back()` bridge uses | +| `customScreenClose` | `{customScreenId}` | Dart→native | `presentation.close()` | + +Payload additions to the existing `presentationToMap` (both platforms): + +``` +connections: [ { id: String?, isDefault: Bool } ], +customScreenId: String? // only set when delivered through the provider +``` + +`customScreenId` format `ply_cs_` generated natively, keyed into a static +`ConcurrentHashMap` (Android) / static dictionary + lock (iOS), +removed when the host view is destroyed. Same single-shot registry discipline as +`pendingInterceptors`. + +--- + +## 5. Native Implementation + +### 5.1 Android (`purchasely/android/.../PurchaselyFlutterPlugin.kt` + new files) + +1. **Provider registration** (`setCustomScreenProvider` method handler): + ```kotlin + Purchasely.setCustomScreenProvider(object : PLYCustomScreenProvider { + override fun onCustomScreenRequested(presentation: PLYPresentation): PLYCustomScreen? { + val id = registerCustomScreenPresentation(presentation) // ply_cs_ + return PLYCustomScreen.Fragment( + PurchaselyCustomScreenFragment.newInstance(id, entrypoint, libraryUri) + ) + } + }) + ``` + Return a **Fragment** (not View) so we get lifecycle callbacks for engine teardown; the + flow machinery commits it via `childFragmentManager.replace` (`PLYFlowParentFragment.onCustomScreenLoaded`). +2. **New `PurchaselyCustomScreenFragment`** (subclasses `FlutterFragment` or hosts a + `FlutterView` directly): + - `onCreateView`: spawn engine from a process-wide `FlutterEngineGroup` + (`createAndRunEngine(context, DartEntrypoint(appBundlePath, entrypoint), listOf(customScreenId))`), + cache under `ply_cs_engine_`, build via `FlutterFragment.withCachedEngine(...) + .destroyEngineWithFragment(true).renderMode(texture)`. + - `onDestroyView`: destroy engine, remove registry entry, remove engine-cache entry. + - Render mode **texture** so the surface composites correctly inside modal/drawer/popin + containers with rounded corners/scrims (validate in M5; fall back to surface if + performance requires and clipping allows). +3. **Custom-screen channel handler** in the plugin (all engines): `getCustomScreenPresentation` + (map from registry via existing `presentationToMap` + `connections` + `customScreenId`), + `executeConnection` (`presentation.execute(presentation.connections.firstOrNull { it.id == connectionId })`, + null-id → `execute(null)` = default; run on main thread), `customScreenBack`, `customScreenClose`. +4. **`connections` in `presentationToMap`**: `PLYConnection.id` and `.default` are public on + Android — direct mapping. +5. **Graceful degradation** (SDK no-crash rule): every handler try/catches and no-ops with a + warn log if the registry entry is gone (e.g. execute after step already popped). + +### 5.2 iOS (`purchasely/ios/Classes/SwiftPurchaselyFlutterPlugin.swift` + new files) + +1. **Delegate registration**: plugin holds a `PurchaselyCustomScreenDelegate: NSObject, + PLYCustomScreenViewControllerDelegate`, registered via + `Purchasely.setCustomScreenViewControllerDelegate(...)` only when Dart calls + `setCustomScreenProvider` (so iOS's "no delegate → self-close with warning" default is + preserved when the Flutter app doesn't use the feature). +2. **`viewController(for:)`**: + ```swift + func viewController(for presentation: PLYPresentation) -> UIViewController? { + let id = registerCustomScreenPresentation(presentation) + let engine = engineGroup.makeEngine(with: options(entrypoint:, libraryURI:, entrypointArgs: [id])) + return PurchaselyCustomScreenViewController(engine: engine, customScreenId: id) + } + ``` + `PurchaselyCustomScreenViewController: FlutterViewController` — cleans up registry + + shuts the engine down in `deinit`/`viewDidDisappear` (when removed from parent). +3. **Custom-screen channel** handlers mirror Android. `executeConnection` maps `connectionId` + → `presentation.connections.first { $0.id == connectionId }` then + `presentation.executeConnection(conn)`; nil → `executeConnection(nil)` (SDK falls back to + default connection). Dispatch to main queue. +4. **`connections` in `presentationToMap`**: iOS `PLYConnection` publicly exposes only `id`; + the `default` flag is internal. → **Native iOS SDK prerequisite (tiny):** add + `@objc public var isDefault: Bool { _connection.default }` to `PLYConnection` + (Purchasely-iOS-Sources PR). Until merged, bridge `isDefault: false` on iOS and rely on + `execute(null)` for default-connection behavior (functional, slightly degraded metadata). +5. SwiftUI delegate (`PLYCustomScreenViewDelegate`) is **not** bridged — irrelevant from Dart. + +### 5.3 Event parity + +Android fires `clientPresentationDisplayed` automatically on mount; `clientPresentationClosed` +is not auto-fired by the flow fragments. iOS shares the normal presentation lifecycle events. +→ In M5, verify `PRESENTATION_VIEWED`/`PRESENTATION_CLOSED` parity end-to-end on both +platforms; if the closed event is missing on Android when a custom step is popped, call +`Purchasely.clientPresentationClosed(presentation)` from `PurchaselyCustomScreenFragment.onDestroyView` +(guarded so flow-forward navigation vs. close is respected — align with whatever native does +for normal steps). + +--- + +## 6. Milestones + +Work on a branch off `feat/sdk-v6-migration`. TDD where the layer is testable +(Dart models/channel handlers); commit atomically per milestone. + +| # | Milestone | Contents | Est. | +|---|---|---|---| +| M1 | **Models + standalone support** | `PLYConnection` Dart model; `connections` on `PLYPresentation` (+ `toMap`/`fromMap`); native `presentationToMap` additions (both platforms); `executeConnection` for requestId-held presentations; Dart unit tests | 1–1.5 d | +| M2 | **Android provider + engine host** | Provider registration, `PurchaselyCustomScreenFragment`, engine group, registry, custom-screen channel handlers | 2–3 d | +| M3 | **iOS delegate + engine host** | Delegate, `PurchaselyCustomScreenViewController`, engine group, channel handlers; iOS-Sources PR for `PLYConnection.isDefault` | 2–3 d | +| M4 | **Dart runtime API** | `Purchasely.setCustomScreenProvider/remove`, `PurchaselyCustomScreens.run`, `PLYCustomScreenPresentation` with `execute/back/close`; channel tests with `TestDefaultBinaryMessenger` | 1.5–2 d | +| M5 | **Example + E2E validation** | Example-app demo (entrypoint + per-connection buttons, mirroring native samples); manual E2E against a console flow with a CLIENT step on the example API key; verify: all 5 transition types, back navigation, pushed-inside-container steps, event parity, engine teardown (no leak via LeakCanary/Instruments), process-death restoration | 2–3 d | +| M6 | **Docs + release** | README + `sdk_public_doc.md` section, isolate DX guidance (§8), CHANGELOG/RELEASE_NOTES entry; fold into next 6.0.0-rc / 6.1.0 | 0.5–1 d | + +Total ≈ 9–13 dev-days. M2 and M3 can be parallelized after M1 locks the wire contract. + +--- + +## 7. Testing Strategy + +- **Dart unit tests** (`purchasely/test/`): connection parsing (`isDefault`, missing ids), + presentation map round-trip with `connections`/`customScreenId`, provider registration + invoking the channel, `execute/back/close` sending correct payloads, graceful behavior on + channel `PlatformException`. +- **Native**: keep plugin logic thin; registry add/remove and connection-lookup helpers unit + tested where extractable (Android: plain JVM test for the id-matching helper). +- **E2E (manual first, scripted later)**: a dedicated flow on the example app's API key with + a CLIENT step per transition type. The Android SDK integration tests use screen id + `integration_test_my_own_screen` — check with the console team whether the same flow can be + cloned to the Flutter example app (open question OQ-1). +- **Regression**: full existing test suites (`flutter test`, example builds on both + platforms) — the presentation map change touches every presentation event. + +--- + +## 8. DX Guidance (must ship with docs) + +The custom-screen builder runs in a **dedicated isolate**: + +- No shared memory with the main app: no Provider/Riverpod/Bloc/GetIt state, no main + Navigator, no inherited themes. +- Recommended patterns: keep custom flow steps self-contained; feed configuration via the + presentation's console-configured `metadata` (verify metadata is bridged in M1 — it exists + natively; confirm the Dart model carries it); persist decisions via your backend or + platform storage; advanced apps can bridge isolates with `IsolateNameServer`/`SendPort`. +- Registration must happen every launch **before** a client screen can appear (i.e. right + after `Purchasely.start()`), including cold starts into a deeplinked flow — same guidance + as native ("set during application initialization"). Android process-death restoration of + a flow re-requests the custom screen, so late registration = blank step. + +--- + +## 9. Risks & Mitigations + +| Risk | Impact | Mitigation | +|---|---|---| +| Isolate isolation surprises customers (login step can't reach app state) | DX complaints, feature unused | Prominent docs (§8); metadata-driven pattern; keep Option B (pause/resume, MOB-200) on the roadmap as a complement | +| First-frame latency of spawned engine (blank container during transition) | Visible jank | Engine-group spawn is fast; optionally pre-warm one engine at `setCustomScreenProvider` and hand it to the first request; measure in M5 | +| Rendering inside bottom-sheet/popin containers (clipping, rounded corners, gestures) | Visual/interaction bugs | Texture render mode; explicit M5 test matrix over all 5 transitions incl. pushed-inside-container | +| Engine leak per step | Memory growth over long flows | `destroyEngineWithFragment(true)` / `deinit` teardown; leak check in M5 | +| Android `null` (blank) vs iOS `nil` (self-close) asymmetry | Behavior divergence | Bridge always returns a host when the provider is registered; when not registered, the native defaults apply unchanged (documented) | +| iOS `PLYConnection.isDefault` not public | `isDefault` wrong on iOS | Tiny iOS-Sources PR (M3); interim fallback documented in §5.2 | +| Custom screen widget embedding another `PLYPresentationView` platform view | Recursive/undefined behavior | Document as unsupported in v1 | + +--- + +## 10. Open Questions + +- **OQ-1**: Which console placement/flow (API key of the Flutter example app) will carry a + CLIENT step for demos/E2E? Android integration tests use `integration_test_my_own_screen`; + iOS used `cm_flow_byos`. Needs console setup or key reuse. +- **OQ-2**: Confirm iOS flow-step `back()` parity — the existing Dart `PLYPresentation.back()` + path must behave identically when called from a custom flow step (Android pops one step via + `onCloseRequested(false)`); verify the iOS equivalent during M3. +- **OQ-3**: Expose connection `actions` metadata (e.g. action types) to Dart? Deliberately + **out of scope v1** — iOS keeps them opaque; parity = `id` + `isDefault` only. +- **OQ-4**: Should `PurchaselyCustomScreens.run` impose an app shell (MaterialApp) or stay + bare? Plan says bare host + docs example using MaterialApp inside the builder — revisit + with DX feedback. + +--- + +## 11. Key Source References + +**Native contract** (source of truth for parity): +- Android: `core/src/main/java/io/purchasely/ext/PLYCustomScreen.kt`, + `ext/interfaces.kt:147` (`PLYCustomScreenProvider`), `ext/Purchasely.kt:1115` + (`setCustomScreenProvider`), `ext/PLYConnection.kt`, + `ext/presentation/PLYPresentationBase.kt:351-385` (`execute`/`back`/`close`), + `views/flows/PLYFlowManager.kt:333` (`requestCustomScreen`), + `views/flows/fragments/PLYFlowParentFragment.kt:468` (`onCustomScreenLoaded`), + sample: `samplev2/.../SampleV2Application.kt:235`, tests: + `integration-tests/.../CustomScreenProviderTests.kt`, `FlowTests.kt:124,588`. +- iOS: `Purchasely/Classes/common/Purchasely+CustomScreen.swift`, + `Purchasely+PublicInterface.swift:813-857`, + `Model/UI/PLYPresentation+CustomScreen.swift` (`executeConnection`), + `Model/UI/PLYConnection.swift`, + `specific/uikit/Controller/PLYProductViewController+Configure.swift:142-297`, + sample: `Example/PurchaselySampleV2/.../Helpers/CustomScreens.swift`. + +**Flutter bridge precedents** (this repo): +- Interceptor round trip: `purchasely/lib/src/bridge.dart` + (`_handleInterceptorTriggered`/`_resolveInterceptor`), + `android/.../PurchaselyFlutterPlugin.kt` (`pendingInterceptors`, `interceptorResolve`), + `ios/Classes/SwiftPurchaselyFlutterPlugin.swift` (same). +- Presentation registry & marshalling: `presentationToMap` (both native mains), + `loadedPresentations`, `clientPresentationDisplayed/Closed` handlers. +- Platform view (reverse-direction precedent only): `purchasely/lib/native_view_widget.dart`, + `NativeView(.kt/.swift)`, `NativeViewFactory(.kt/.swift)`. diff --git a/purchasely/CHANGELOG.md b/purchasely/CHANGELOG.md index 7f2cf936..f1fdfe81 100644 --- a/purchasely/CHANGELOG.md +++ b/purchasely/CHANGELOG.md @@ -8,6 +8,11 @@ Full changelog available at https://docs.purchasely.com/changelog/60-12-month-co ## 6.0.0-rc.3 +- Adds Custom Screen support for Flutter-authored CLIENT steps inside native + Purchasely flows, including connection execution, back/close navigation, + dedicated-isolate hosting, documentation, tests, and an example screen. +- Aligns the declared minimum Flutter version with the existing Dart 3 + requirement (`Flutter >= 3.10.0`). - Aligns the Flutter package and native bridge version with 6.0.0-rc.3. - Keeps the iOS and Android Purchasely SDK dependencies pinned to 6.0.0-rc.3. diff --git a/purchasely/README.md b/purchasely/README.md index 397d5927..39ff0253 100644 --- a/purchasely/README.md +++ b/purchasely/README.md @@ -57,6 +57,36 @@ switch (outcome.purchaseResult) { } ``` +## Custom Screens + +Register a dedicated Flutter entrypoint immediately after `start()` to provide +the UI for CLIENT steps inside Purchasely-managed native flows: + +```dart +await Purchasely.setCustomScreenProvider(); + +@pragma('vm:entry-point') +void purchaselyCustomScreen(List args) { + PurchaselyCustomScreens.run(args, (context, presentation) { + return MaterialApp( + home: MyCustomStep( + connections: presentation.connections, + onNext: () => presentation.execute(), + onBack: presentation.back, + onClose: presentation.close, + ), + ); + }); +} +``` + +The builder runs in a dedicated isolate and does not inherit app state, +Navigator, inherited themes, service locators, or auto-registered app plugins +from the main isolate. Prefer self-contained steps, presentation `metadata`, +and persistent storage. Custom Screen hosting is flow-step-only; inline +`PLYPresentationView` and standalone native CLIENT presentation hosting are +unsupported. + ## Migration to 6.0 This release adapts the plugin to the Purchasely 6.0 native SDKs. Only the diff --git a/purchasely/android/src/main/kotlin/io/purchasely/purchasely_flutter/PurchaselyCustomScreenFragment.kt b/purchasely/android/src/main/kotlin/io/purchasely/purchasely_flutter/PurchaselyCustomScreenFragment.kt new file mode 100644 index 00000000..71ca1067 --- /dev/null +++ b/purchasely/android/src/main/kotlin/io/purchasely/purchasely_flutter/PurchaselyCustomScreenFragment.kt @@ -0,0 +1,171 @@ +package io.purchasely.purchasely_flutter + +import android.content.Context +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import io.flutter.FlutterInjector +import io.flutter.embedding.android.FlutterTextureView +import io.flutter.embedding.android.FlutterView +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.embedding.engine.FlutterEngineGroup +import io.flutter.embedding.engine.dart.DartExecutor +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel + +/** Hosts one Flutter-authored Custom Screen inside the native Purchasely flow. */ +class PurchaselyCustomScreenFragment : Fragment() { + private var engine: FlutterEngine? = null + private var flutterView: FlutterView? = null + private var customScreenChannel: MethodChannel? = null + + private val customScreenId: String + get() = requireArguments().getString(ARG_CUSTOM_SCREEN_ID).orEmpty() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + val args = requireArguments() + val entrypoint = args.getString(ARG_ENTRYPOINT).orEmpty() + val libraryUri = args.getString(ARG_LIBRARY_URI) + val context = requireContext() + val loader = FlutterInjector.instance().flutterLoader() + val dartEntrypoint = if (libraryUri.isNullOrBlank()) { + DartExecutor.DartEntrypoint(loader.findAppBundlePath(), entrypoint) + } else { + DartExecutor.DartEntrypoint(loader.findAppBundlePath(), libraryUri, entrypoint) + } + val createdEngine = engineGroup(context).createAndRunEngine( + FlutterEngineGroup.Options(context) + .setDartEntrypoint(dartEntrypoint) + .setDartEntrypointArgs(listOf(customScreenId)) + .setAutomaticallyRegisterPlugins(false) + ) + engine = createdEngine + customScreenChannel = MethodChannel( + createdEngine.dartExecutor.binaryMessenger, + CUSTOM_SCREEN_CHANNEL, + ).also { channel -> + channel.setMethodCallHandler(::handleCustomScreenCall) + } + + return FlutterView(context, FlutterTextureView(context)).also { view -> + flutterView = view + view.attachToFlutterEngine(createdEngine) + view.layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + } + } + + override fun onResume() { + super.onResume() + engine?.lifecycleChannel?.appIsResumed() + } + + override fun onPause() { + engine?.lifecycleChannel?.appIsInactive() + super.onPause() + } + + override fun onStop() { + engine?.lifecycleChannel?.appIsPaused() + super.onStop() + } + + override fun onDestroyView() { + customScreenChannel?.setMethodCallHandler(null) + customScreenChannel = null + flutterView?.detachFromFlutterEngine() + flutterView = null + engine?.destroy() + engine = null + // Free the retained native presentation on every teardown EXCEPT a + // configuration change, where the fragment is recreated and re-fetches + // the same customScreenId. Gating only on isRemoving/isFinishing (as + // before) leaked the entry on system-initiated, process-retained + // destroys. + if (activity?.isChangingConfigurations != true) { + PurchaselyFlutterPlugin.removeCustomScreenPresentation(customScreenId) + } + super.onDestroyView() + } + + private fun handleCustomScreenCall(call: MethodCall, result: MethodChannel.Result) { + @Suppress("UNCHECKED_CAST") + val args = call.arguments as? Map + val requestedId = args?.get("customScreenId") as? String + if (requestedId != customScreenId) { + result.error("STALE_CUSTOM_SCREEN", "Custom Screen id does not match this engine", null) + return + } + when (call.method) { + "getCustomScreenPresentation" -> { + result.success(PurchaselyFlutterPlugin.customScreenPresentationToMap(customScreenId)) + } + "customScreenExecuteConnection" -> { + val connectionId = args?.get("connectionId") as? String + runOnMain { + PurchaselyFlutterPlugin.executeCustomScreenConnection(customScreenId, connectionId) + } + result.success(true) + } + "customScreenBack" -> { + runOnMain { + PurchaselyFlutterPlugin.customScreenPresentations[customScreenId]?.back() + ?: Log.w(TAG, "Custom Screen $customScreenId is no longer available") + } + result.success(true) + } + "customScreenClose" -> { + runOnMain { + PurchaselyFlutterPlugin.customScreenPresentations[customScreenId]?.close() + ?: Log.w(TAG, "Custom Screen $customScreenId is no longer available") + } + result.success(true) + } + else -> result.notImplemented() + } + } + + private fun runOnMain(action: () -> Unit) { + val activity = activity + if (activity != null) activity.runOnUiThread(action) else action() + } + + companion object { + private const val TAG = "PurchaselyFlutter" + private const val CUSTOM_SCREEN_CHANNEL = "purchasely-custom-screen" + private const val ARG_CUSTOM_SCREEN_ID = "customScreenId" + private const val ARG_ENTRYPOINT = "entrypoint" + private const val ARG_LIBRARY_URI = "libraryUri" + + @Volatile + private var sharedEngineGroup: FlutterEngineGroup? = null + + private fun engineGroup(context: Context): FlutterEngineGroup = + sharedEngineGroup ?: synchronized(this) { + sharedEngineGroup ?: FlutterEngineGroup(context.applicationContext).also { + sharedEngineGroup = it + } + } + + fun newInstance( + customScreenId: String, + entrypoint: String, + libraryUri: String?, + ) = PurchaselyCustomScreenFragment().apply { + arguments = Bundle().apply { + putString(ARG_CUSTOM_SCREEN_ID, customScreenId) + putString(ARG_ENTRYPOINT, entrypoint) + putString(ARG_LIBRARY_URI, libraryUri) + } + } + } +} diff --git a/purchasely/android/src/main/kotlin/io/purchasely/purchasely_flutter/PurchaselyFlutterPlugin.kt b/purchasely/android/src/main/kotlin/io/purchasely/purchasely_flutter/PurchaselyFlutterPlugin.kt index 58fb1fd3..c08374a9 100644 --- a/purchasely/android/src/main/kotlin/io/purchasely/purchasely_flutter/PurchaselyFlutterPlugin.kt +++ b/purchasely/android/src/main/kotlin/io/purchasely/purchasely_flutter/PurchaselyFlutterPlugin.kt @@ -48,6 +48,7 @@ import io.purchasely.views.presentation.models.PLYTransitionType import java.text.SimpleDateFormat import java.util.* import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicLong import kotlin.collections.ArrayList import kotlin.collections.HashMap import kotlin.reflect.KClass @@ -213,6 +214,9 @@ class PurchaselyFlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware, clientPresentationClosed(args?.get("presentation") as? Map<*, *>) result.safeSuccess(true) } + "executeConnection" -> executeConnection(args, result) + "setCustomScreenProvider" -> setCustomScreenProvider(args, result) + "removeCustomScreenProvider" -> removeCustomScreenProvider(result) // --- action interceptor --- "registerInterceptor" -> registerInterceptor(args, result) @@ -710,6 +714,48 @@ class PurchaselyFlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware, val loaded = clientPresentation(presentationMap, "clientPresentationClosed") ?: return Purchasely.clientPresentationClosed(loaded) } + + private fun executeConnection(args: Map?, result: Result) { + val requestId = args?.get("requestId") as? String + val connectionId = args?.get("connectionId") as? String + val presentation = requestId?.let { loadedPresentations[it] } + if (presentation == null) { + Log.w("PurchaselyFlutter", "executeConnection: no loaded presentation for requestId=$requestId") + } else { + executePresentationConnection(presentation, connectionId) + } + result.safeSuccess(true) + } + + private fun setCustomScreenProvider(args: Map?, result: Result) { + val entrypoint = args?.get("entrypoint") as? String + if (entrypoint.isNullOrBlank()) { + result.safeError("-1", "entrypoint is required", null) + return + } + customScreenEntrypoint = entrypoint + customScreenLibraryUri = args["libraryUri"] as? String + Purchasely.setCustomScreenProvider(object : PLYCustomScreenProvider { + override fun onCustomScreenRequested( + presentation: PLYPresentationBase.Loaded, + ): PLYCustomScreen { + val customScreenId = registerCustomScreenPresentation(presentation) + return PLYCustomScreen.Fragment( + PurchaselyCustomScreenFragment.newInstance( + customScreenId, + customScreenEntrypoint, + customScreenLibraryUri, + ) + ) + } + }) + result.safeSuccess(true) + } + + private fun removeCustomScreenProvider(result: Result) { + Purchasely.setCustomScreenProvider(null) + result.safeSuccess(true) + } //endregion //region Default presentation dismiss handler @@ -1473,6 +1519,57 @@ class PurchaselyFlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware, val loadedPresentations = ConcurrentHashMap() val displayCallbacks = ConcurrentHashMap Unit>() + @Volatile + var customScreenEntrypoint: String = "purchaselyCustomScreen" + private set + @Volatile + var customScreenLibraryUri: String? = null + private set + private val customScreenCounter = AtomicLong(0) + val customScreenPresentations = ConcurrentHashMap() + + private fun registerCustomScreenPresentation(presentation: PLYPresentationBase.Loaded): String { + val id = "ply_cs_${customScreenCounter.incrementAndGet()}" + customScreenPresentations[id] = presentation + return id + } + + fun removeCustomScreenPresentation(customScreenId: String) { + customScreenPresentations.remove(customScreenId) + } + + fun customScreenPresentationToMap(customScreenId: String): Map? { + val presentation = customScreenPresentations[customScreenId] ?: return null + return presentationToMap(presentation).toMutableMap().apply { + put("customScreenId", customScreenId) + } + } + + fun executeCustomScreenConnection(customScreenId: String, connectionId: String?) { + val presentation = customScreenPresentations[customScreenId] + if (presentation == null) { + Log.w("PurchaselyFlutter", "Custom Screen $customScreenId is no longer available") + return + } + executePresentationConnection(presentation, connectionId) + } + + private fun executePresentationConnection( + presentation: PLYPresentationBase.Loaded, + connectionId: String?, + ) { + if (connectionId == null) { + presentation.execute(null) + return + } + val connection = presentation.connections.firstOrNull { it.id == connectionId } + if (connection == null) { + Log.w("PurchaselyFlutter", "No connection '$connectionId' on Custom Screen ${presentation.screenId}") + return + } + presentation.execute(connection) + } + /** * Posts a presentation lifecycle envelope onto the shared * `purchasely-presentation-events` sink. Used by the inline NativeView so @@ -1523,6 +1620,13 @@ class PurchaselyFlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware, "type" to p.type.ordinal, "height" to p.height, "plans" to p.plans.map { plan -> presentationPlanToMap(plan) }, + "metadata" to (p.metadata?.keys()?.associateWith { key -> p.metadata?.get(key) } ?: emptyMap()), + "connections" to p.connections.map { connection -> + mapOf( + "id" to connection.id, + "isDefault" to connection.default, + ) + }, ) } diff --git a/purchasely/example/lib/custom_screens.dart b/purchasely/example/lib/custom_screens.dart new file mode 100644 index 00000000..413bf356 --- /dev/null +++ b/purchasely/example/lib/custom_screens.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:purchasely_flutter/purchasely_flutter.dart'; + +class CustomScreenStep extends StatelessWidget { + const CustomScreenStep({Key? key, required this.presentation}) + : super(key: key); + + final PLYCustomScreenPresentation presentation; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xffeef2ff), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Flutter BYOS screen', + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 8), + const Text('placementId: byos'), + Text('screenId: ${presentation.screenId ?? "—"}'), + Text('flowId: ${presentation.flowId ?? "—"}'), + Text( + 'connections: ${presentation.connections.map((item) => item.id).join(", ")}', + ), + if (presentation.metadata.isNotEmpty) ...[ + const SizedBox(height: 12), + Text('metadata: ${presentation.metadata}'), + ], + const Spacer(), + for (final connection in presentation.connections) ...[ + FilledButton( + onPressed: () => presentation.execute(connection), + child: Text(connection.id ?? '(default)'), + ), + const SizedBox(height: 8), + ], + ], + ), + ), + ), + ); + } +} diff --git a/purchasely/example/lib/main.dart b/purchasely/example/lib/main.dart index 60c8349a..3c364e68 100644 --- a/purchasely/example/lib/main.dart +++ b/purchasely/example/lib/main.dart @@ -8,11 +8,24 @@ import 'package:purchasely_flutter/purchasely_flutter.dart'; import 'presentation_screen.dart'; import 'presentation_demo_screen.dart'; +import 'custom_screens.dart'; void main() { runApp(const MyApp()); } +/// Dedicated entrypoint used by the secondary Flutter engine created for a +/// CLIENT step inside a native Purchasely flow. +@pragma('vm:entry-point') +void purchaselyCustomScreen(List args) { + PurchaselyCustomScreens.run(args, (context, presentation) { + return MaterialApp( + debugShowCheckedModeBanner: false, + home: CustomScreenStep(presentation: presentation), + ); + }); +} + class MyApp extends StatefulWidget { const MyApp({Key? key}) : super(key: key); @@ -50,6 +63,10 @@ class _MyAppState extends State { return; } + await Purchasely.setCustomScreenProvider( + entrypoint: 'purchaselyCustomScreen', + ); + Purchasely.allowDeeplink(true); Purchasely.setLogLevel(PLYLogLevel.debug); @@ -301,6 +318,19 @@ class _MyAppState extends State { } } + Future displayCustomScreenFlow() async { + try { + // Placement `byos` displays flow `flow_byos`. Its second step is the + // client-authored screen `byos`, with `continue`, `close`, and + // `close_all` connections configured in the Purchasely Console. + final outcome = + await PLYPresentationBuilder.placement('byos').build().display(); + print('BYOS flow dismissed: ${outcome.closeReason}'); + } catch (e) { + print('Unable to display BYOS flow: $e'); + } + } + Future displayPresentationInline(BuildContext context) async { // Closing an inline paywall fires BOTH onCloseRequested (the ✕ asks the // host to close) and, right after the view is removed, onDismissed. Pop the @@ -428,6 +458,15 @@ class _MyAppState extends State { }, child: const Text('Display presentation'), ), + ElevatedButton( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.only(left: 20.0, right: 30.0), + backgroundColor: Colors.deepPurple, + foregroundColor: Colors.white, + ), + onPressed: displayCustomScreenFlow, + child: const Text('Display BYOS flow (byos)'), + ), ElevatedButton( style: ElevatedButton.styleFrom( padding: const EdgeInsets.only(left: 20.0, right: 30.0), diff --git a/purchasely/ios/purchasely_flutter/Classes/PurchaselyCustomScreenDelegate.swift b/purchasely/ios/purchasely_flutter/Classes/PurchaselyCustomScreenDelegate.swift new file mode 100644 index 00000000..dd6750b7 --- /dev/null +++ b/purchasely/ios/purchasely_flutter/Classes/PurchaselyCustomScreenDelegate.swift @@ -0,0 +1,24 @@ +import Flutter +import Purchasely +import UIKit + +/// Supplies a Flutter view controller for CLIENT steps inside native flows. +final class PurchaselyCustomScreenDelegate: NSObject, PLYCustomScreenViewControllerDelegate { + private static let engineGroup = FlutterEngineGroup( + name: "io.purchasely.custom-screens", + project: nil + ) + + func viewController(for presentation: PLYPresentation) -> UIViewController? { + let customScreenId = SwiftPurchaselyFlutterPlugin.registerCustomScreenPresentation(presentation) + let options = FlutterEngineGroupOptions() + options.entrypoint = SwiftPurchaselyFlutterPlugin.customScreenEntrypoint + options.libraryURI = SwiftPurchaselyFlutterPlugin.customScreenLibraryURI + options.entrypointArgs = [customScreenId] + let engine = Self.engineGroup.makeEngine(with: options) + return PurchaselyCustomScreenViewController( + engine: engine, + customScreenId: customScreenId + ) + } +} diff --git a/purchasely/ios/purchasely_flutter/Classes/PurchaselyCustomScreenViewController.swift b/purchasely/ios/purchasely_flutter/Classes/PurchaselyCustomScreenViewController.swift new file mode 100644 index 00000000..ff5441b3 --- /dev/null +++ b/purchasely/ios/purchasely_flutter/Classes/PurchaselyCustomScreenViewController.swift @@ -0,0 +1,94 @@ +import Flutter +import Purchasely +import UIKit + +/// Owns one secondary Flutter engine and its flow-step-scoped bridge. +final class PurchaselyCustomScreenViewController: FlutterViewController { + private let customScreenEngine: FlutterEngine + private let customScreenId: String + private var customScreenChannel: FlutterMethodChannel? + private var cleanedUp = false + + init(engine: FlutterEngine, customScreenId: String) { + self.customScreenEngine = engine + self.customScreenId = customScreenId + super.init(engine: engine, nibName: nil, bundle: nil) + let channel = FlutterMethodChannel( + name: "purchasely-custom-screen", + binaryMessenger: engine.binaryMessenger + ) + self.customScreenChannel = channel + channel.setMethodCallHandler { [weak self] call, result in + self?.handle(call, result: result) + } + } + + @available(*, unavailable) + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func willMove(toParent parent: UIViewController?) { + if parent == nil && self.parent != nil { + // Detach the bridge promptly, but keep the engine alive until the + // VC is fully off-screen (deinit) so the exit transition doesn't + // message an already-destroyed engine or blank the final frame. + detachBridge() + } + super.willMove(toParent: parent) + } + + deinit { + detachBridge() + customScreenEngine.destroyContext() + } + + private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + let args = call.arguments as? [String: Any] + guard args?["customScreenId"] as? String == customScreenId else { + result(FlutterError( + code: "STALE_CUSTOM_SCREEN", + message: "Custom Screen id does not match this engine", + details: nil + )) + return + } + + switch call.method { + case "getCustomScreenPresentation": + result(SwiftPurchaselyFlutterPlugin.customScreenPresentationMap(customScreenId)) + case "customScreenExecuteConnection": + let connectionId = args?["connectionId"] as? String + DispatchQueue.main.async { [customScreenId] in + guard let presentation = SwiftPurchaselyFlutterPlugin.customScreenPresentation(customScreenId) else { + return + } + SwiftPurchaselyFlutterPlugin.executeConnection( + on: presentation, + connectionId: connectionId + ) + } + result(true) + case "customScreenBack": + DispatchQueue.main.async { [customScreenId] in + SwiftPurchaselyFlutterPlugin.customScreenPresentation(customScreenId)?.back() + } + result(true) + case "customScreenClose": + DispatchQueue.main.async { [customScreenId] in + SwiftPurchaselyFlutterPlugin.customScreenPresentation(customScreenId)?.close() + } + result(true) + default: + result(FlutterMethodNotImplemented) + } + } + + private func detachBridge() { + guard !cleanedUp else { return } + cleanedUp = true + customScreenChannel?.setMethodCallHandler(nil) + customScreenChannel = nil + SwiftPurchaselyFlutterPlugin.removeCustomScreenPresentation(customScreenId) + } +} diff --git a/purchasely/ios/purchasely_flutter/Classes/SwiftPurchaselyFlutterPlugin.swift b/purchasely/ios/purchasely_flutter/Classes/SwiftPurchaselyFlutterPlugin.swift index 495e9b74..0ef4462e 100644 --- a/purchasely/ios/purchasely_flutter/Classes/SwiftPurchaselyFlutterPlugin.swift +++ b/purchasely/ios/purchasely_flutter/Classes/SwiftPurchaselyFlutterPlugin.swift @@ -38,6 +38,13 @@ public class SwiftPurchaselyFlutterPlugin: NSObject, FlutterPlugin { // invocationId -> SDK interceptor completion. Single-shot, removed on resolve. private static var pendingInterceptors: [String: (PLYInterceptResult) -> Void] = [:] + private static let customScreenLock = NSLock() + private static var customScreenCounter: UInt64 = 0 + private static var customScreenPresentations: [String: PLYPresentation] = [:] + static var customScreenEntrypoint = "purchaselyCustomScreen" + static var customScreenLibraryURI: String? + private static var customScreenDelegate: PurchaselyCustomScreenDelegate? + // The live plugin instance, so the inline NativeView can reach the shared // `purchasely-presentation-events` sink and surface the same `onDismissed` // envelope as the full-screen path. @@ -148,6 +155,12 @@ public class SwiftPurchaselyFlutterPlugin: NSObject, FlutterPlugin { case "clientPresentationClosed": clientPresentationClosed(arguments) result(true) + case "executeConnection": + executeConnection(arguments, result: result) + case "setCustomScreenProvider": + setCustomScreenProvider(arguments, result: result) + case "removeCustomScreenProvider": + removeCustomScreenProvider(result: result) // --- action interceptor --- case "registerInterceptor": @@ -545,6 +558,38 @@ public class SwiftPurchaselyFlutterPlugin: NSObject, FlutterPlugin { Purchasely.clientPresentationClosed(with: presentation) } + private func executeConnection(_ args: [String: Any]?, result: @escaping FlutterResult) { + let requestId = args?["requestId"] as? String + let connectionId = args?["connectionId"] as? String + guard let id = requestId, + let presentation = Self.loadedPresentations[id] else { + print("Purchasely", "executeConnection: no loaded presentation for requestId=\(requestId ?? "nil")") + result(true) + return + } + Self.executeConnection(on: presentation, connectionId: connectionId) + result(true) + } + + private func setCustomScreenProvider(_ args: [String: Any]?, result: @escaping FlutterResult) { + guard let entrypoint = args?["entrypoint"] as? String, !entrypoint.isEmpty else { + result(FlutterError(code: "ARG_INVALID", message: "entrypoint is required", details: nil)) + return + } + Self.customScreenEntrypoint = entrypoint + Self.customScreenLibraryURI = args?["libraryUri"] as? String + let delegate = PurchaselyCustomScreenDelegate() + Self.customScreenDelegate = delegate + Purchasely.setCustomScreenViewControllerDelegate(delegate) + result(true) + } + + private func removeCustomScreenProvider(result: @escaping FlutterResult) { + Purchasely.removeCustomScreenViewControllerDelegate() + Self.customScreenDelegate = nil + result(true) + } + // MARK: - Action interceptor private func registerInterceptor(_ args: [String: Any]?, result: @escaping FlutterResult) { @@ -640,7 +685,13 @@ public class SwiftPurchaselyFlutterPlugin: NSObject, FlutterPlugin { } private func presentationToMap(_ p: PLYPresentation, requestId: String) -> [String: Any] { - return [ + Self.presentationToMap(p, requestId: requestId, customScreenId: nil) + } + + static func presentationToMap(_ p: PLYPresentation, + requestId: String, + customScreenId: String?) -> [String: Any] { + var map: [String: Any] = [ "requestId": requestId, // Native `screenId` → wire `screenId`. The Dart factory tolerates both // keys; we send `screenId` for forward compatibility with the @@ -667,7 +718,63 @@ public class SwiftPurchaselyFlutterPlugin: NSObject, FlutterPlugin { "offerId": plan.offerId, ] }, + "metadata": p.metadata?.getRawMetadata() ?? [:], + // `p.connections` is a Set; sort by id so the array order handed to + // Dart is deterministic across displays (Android sends an ordered List). + "connections": p.connections.sorted { ($0.id ?? "") < ($1.id ?? "") }.map { connection in + [ + "id": connection.id, + // Native iOS does not expose Connection.default publicly yet. + "isDefault": false, + ] as [String : Any] + }, ] + if let customScreenId = customScreenId { + map["customScreenId"] = customScreenId + } + return map + } + + static func registerCustomScreenPresentation(_ presentation: PLYPresentation) -> String { + customScreenLock.lock() + defer { customScreenLock.unlock() } + customScreenCounter += 1 + let id = "ply_cs_\(customScreenCounter)" + customScreenPresentations[id] = presentation + return id + } + + static func removeCustomScreenPresentation(_ customScreenId: String) { + customScreenLock.lock() + customScreenPresentations.removeValue(forKey: customScreenId) + customScreenLock.unlock() + } + + static func customScreenPresentation(_ customScreenId: String) -> PLYPresentation? { + customScreenLock.lock() + defer { customScreenLock.unlock() } + return customScreenPresentations[customScreenId] + } + + static func customScreenPresentationMap(_ customScreenId: String) -> [String: Any]? { + guard let presentation = customScreenPresentation(customScreenId) else { return nil } + return presentationToMap( + presentation, + requestId: "", + customScreenId: customScreenId + ) + } + + static func executeConnection(on presentation: PLYPresentation, connectionId: String?) { + if let connectionId = connectionId { + guard let connection = presentation.connections.first(where: { $0.id == connectionId }) else { + print("Purchasely", "No connection '\(connectionId)' on Custom Screen \(presentation.screenId)") + return + } + presentation.executeConnection(connection) + } else { + presentation.executeConnection(nil) + } } private func outcomeToMap(_ outcome: PLYPresentationOutcome, diff --git a/purchasely/lib/purchasely_flutter.dart b/purchasely/lib/purchasely_flutter.dart index f018efb8..144db64e 100644 --- a/purchasely/lib/purchasely_flutter.dart +++ b/purchasely/lib/purchasely_flutter.dart @@ -22,6 +22,7 @@ import 'src/purchasely_builder.dart' show PLYLogLevel, PurchaselyBuilder; // `PLYPresentationBuilder`, `PLYPresentation`, `PLYPresentationOutcome`, `PLYTransition`, // ActionInterceptor…). export 'src/action_interceptor.dart'; +export 'src/custom_screens.dart'; export 'src/ply_models.dart'; export 'src/presentation.dart'; export 'src/presentation_builder.dart'; @@ -42,6 +43,35 @@ class Purchasely { static StreamSubscription? events; static StreamSubscription? purchases; + // --- Custom Screens --- + + /// Registers the dedicated Dart entrypoint used to render Custom Screen + /// steps inside Purchasely-managed native flows. + /// + /// The entrypoint must be a top-level `@pragma('vm:entry-point')` function + /// accepting `List` and calling `PurchaselyCustomScreens.run`. + /// Register immediately after [PurchaselyBuilder.start] completes and before + /// displaying a flow that can contain a Custom Screen. + static Future setCustomScreenProvider({ + String entrypoint = 'purchaselyCustomScreen', + String? libraryUri, + }) async { + if (entrypoint.isEmpty) { + throw ArgumentError.value(entrypoint, 'entrypoint', 'must not be empty'); + } + await _channel.invokeMethod( + 'setCustomScreenProvider', + { + 'entrypoint': entrypoint, + if (libraryUri != null) 'libraryUri': libraryUri, + }, + ); + } + + /// Removes the Custom Screen provider and restores native SDK behavior. + static Future removeCustomScreenProvider() => + _channel.invokeMethod('removeCustomScreenProvider'); + // --- SDK initialisation --- /// Start the SDK configuration chain. diff --git a/purchasely/lib/src/bridge.dart b/purchasely/lib/src/bridge.dart index fb27b408..f7bb110c 100644 --- a/purchasely/lib/src/bridge.dart +++ b/purchasely/lib/src/bridge.dart @@ -254,6 +254,19 @@ class PurchaselyBridge { ); } + Future _execute( + PLYPresentation presentation, + PLYConnection? connection, + ) async { + await _method.invokeMethod( + 'executeConnection', + { + 'requestId': presentation.requestId, + 'connectionId': connection?.id, + }, + ); + } + // --- Interceptor API ---------------------------------------------------- Future registerInterceptor( @@ -564,6 +577,11 @@ class _BridgePresentationActions extends PLYPresentationActions { @override Future back(PLYPresentation presentation) => _bridge._back(presentation); + + @override + Future execute( + PLYPresentation presentation, PLYConnection? connection) => + _bridge._execute(presentation, connection); } class _BridgePresentationRequestActions extends PLYPresentationRequestActions { @@ -596,6 +614,8 @@ class _UninitialisedPresentationActions extends PLYPresentationActions { Future close(_) => throw _err(); @override Future back(_) => throw _err(); + @override + Future execute(_, __) => throw _err(); } class _UninitialisedRequestActions extends PLYPresentationRequestActions { diff --git a/purchasely/lib/src/custom_screens.dart b/purchasely/lib/src/custom_screens.dart new file mode 100644 index 00000000..cc9f6619 --- /dev/null +++ b/purchasely/lib/src/custom_screens.dart @@ -0,0 +1,184 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter/services.dart'; + +import 'presentation.dart'; + +/// Builds the Flutter UI hosted for a Purchasely Custom Screen flow step. +typedef PLYCustomScreenBuilder = Widget Function( + BuildContext context, + PLYCustomScreenPresentation presentation, +); + +/// A presentation handle bound to the exact native Custom Screen flow step. +class PLYCustomScreenPresentation extends PLYPresentation { + PLYCustomScreenPresentation._(PLYPresentation presentation) + : super( + requestId: presentation.requestId, + screenId: presentation.screenId, + placementId: presentation.placementId, + contentId: presentation.contentId, + audienceId: presentation.audienceId, + abTestId: presentation.abTestId, + abTestVariantId: presentation.abTestVariantId, + campaignId: presentation.campaignId, + flowId: presentation.flowId, + language: presentation.language, + height: presentation.height, + type: presentation.type, + plans: presentation.plans, + metadata: presentation.metadata, + connections: presentation.connections, + customScreenId: presentation.customScreenId, + ); + + factory PLYCustomScreenPresentation.fromMap(Map map) { + final presentation = PLYPresentation.fromMap(map); + if (presentation.customScreenId == null) { + throw const FormatException( + 'Custom Screen payload is missing customScreenId', + ); + } + return PLYCustomScreenPresentation._(presentation); + } + + String get _id => customScreenId!; + + @override + Future execute([PLYConnection? connection]) => _invoke( + 'customScreenExecuteConnection', + { + 'customScreenId': _id, + 'connectionId': connection?.id, + }, + ); + + @override + Future back() => _invoke( + 'customScreenBack', + {'customScreenId': _id}, + ); + + @override + Future close() => _invoke( + 'customScreenClose', + {'customScreenId': _id}, + ); + + /// Sends a navigation call, treating a missing or already-torn-down native + /// Custom Screen (e.g. the step was popped, or an engine/registry teardown + /// race) as a benign no-op instead of surfacing an unhandled isolate error. + Future _invoke(String method, Map args) async { + try { + await PurchaselyCustomScreens._channel.invokeMethod(method, args); + } on PlatformException catch (error) { + debugPrint('Purchasely Custom Screen $method ignored: ${error.code}'); + } on MissingPluginException catch (_) { + debugPrint('Purchasely Custom Screen $method ignored: no native handler'); + } + } +} + +/// Runtime used by the dedicated Dart entrypoint of a Custom Screen. +abstract final class PurchaselyCustomScreens { + static const MethodChannel _channel = + MethodChannel('purchasely-custom-screen'); + + /// Starts the widget tree for one native Custom Screen flow step. + /// + /// The app entrypoint must accept the engine arguments and forward them: + /// + /// ```dart + /// @pragma('vm:entry-point') + /// void purchaselyCustomScreen(List args) { + /// PurchaselyCustomScreens.run(args, (context, presentation) { + /// return MyCustomStep(presentation: presentation); + /// }); + /// } + /// ``` + static void run(List entrypointArgs, PLYCustomScreenBuilder builder) { + WidgetsFlutterBinding.ensureInitialized(); + if (entrypointArgs.isEmpty || entrypointArgs.first.isEmpty) { + runApp(const _CustomScreenError( + message: 'Purchasely Custom Screen entrypoint received no id.', + )); + return; + } + runApp(_CustomScreenHost( + customScreenId: entrypointArgs.first, + builder: builder, + )); + } +} + +class _CustomScreenHost extends StatefulWidget { + const _CustomScreenHost({ + required this.customScreenId, + required this.builder, + }); + + final String customScreenId; + final PLYCustomScreenBuilder builder; + + @override + State<_CustomScreenHost> createState() => _CustomScreenHostState(); +} + +class _CustomScreenHostState extends State<_CustomScreenHost> { + late final Future _presentation = _load(); + + Future _load() async { + final raw = await PurchaselyCustomScreens._channel + .invokeMapMethod( + 'getCustomScreenPresentation', + {'customScreenId': widget.customScreenId}, + ); + if (raw == null) { + throw StateError('The native Custom Screen is no longer available.'); + } + return PLYCustomScreenPresentation.fromMap(raw); + } + + @override + Widget build(BuildContext context) { + return MediaQuery.fromView( + view: View.of(context), + child: Directionality( + textDirection: TextDirection.ltr, + child: FutureBuilder( + future: _presentation, + builder: (context, snapshot) { + final presentation = snapshot.data; + if (presentation != null) { + try { + return widget.builder(context, presentation); + } catch (error) { + return ErrorWidget.withDetails( + message: 'Custom Screen builder failed: $error', + ); + } + } + if (snapshot.hasError) { + return _CustomScreenError( + message: 'Unable to load Purchasely Custom Screen: ' + '${snapshot.error}', + ); + } + return const SizedBox.expand(); + }, + ), + ), + ); + } +} + +class _CustomScreenError extends StatelessWidget { + const _CustomScreenError({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) => Directionality( + textDirection: TextDirection.ltr, + child: Center(child: Text(message)), + ); +} diff --git a/purchasely/lib/src/presentation.dart b/purchasely/lib/src/presentation.dart index 951d0a91..9f3a78de 100644 --- a/purchasely/lib/src/presentation.dart +++ b/purchasely/lib/src/presentation.dart @@ -50,6 +50,31 @@ class PLYPresentationPlan { }; } +/// A named exit from a Purchasely Custom Screen. +class PLYConnection { + /// Connection vendor id configured in the Purchasely Console. + final String? id; + + /// Whether this is the presentation's default connection. + /// + /// The current iOS SDK does not expose this flag publicly, so it is `false` + /// on iOS until that native API is available. Calling [PLYPresentation.execute] + /// without a connection still executes the native default on both platforms. + final bool isDefault; + + const PLYConnection({this.id, this.isDefault = false}); + + factory PLYConnection.fromMap(Map map) => PLYConnection( + id: map['id'] as String?, + isDefault: map['isDefault'] as bool? ?? false, + ); + + Map toMap() => { + 'id': id, + 'isDefault': isDefault, + }; +} + /// Indirection used by [PLYPresentation.display] / [close] / [back] so the /// public API can defer to the bridge without creating a circular import. abstract class PLYPresentationActions { @@ -60,6 +85,7 @@ abstract class PLYPresentationActions { PLYPresentation presentation, PLYTransition? transition); Future close(PLYPresentation presentation); Future back(PLYPresentation presentation); + Future execute(PLYPresentation presentation, PLYConnection? connection); } class _UninitialisedActions extends PLYPresentationActions { @@ -72,6 +98,8 @@ class _UninitialisedActions extends PLYPresentationActions { Future close(_) => throw _err(); @override Future back(_) => throw _err(); + @override + Future execute(_, __) => throw _err(); } /// A loaded presentation. Returned from `PLYPresentationRequest.preload()` and @@ -100,6 +128,11 @@ class PLYPresentation { final PLYPresentationType type; final List plans; final Map metadata; + final List connections; + + /// Internal id used only while this presentation is hosted as a Custom + /// Screen inside a native Purchasely flow. + final String? customScreenId; /// Optional pre-loaded handler — fires once when the presentation has been /// shown for the first time (or with an error if display failed). @@ -130,6 +163,8 @@ class PLYPresentation { this.type = PLYPresentationType.normal, this.plans = const [], this.metadata = const {}, + this.connections = const [], + this.customScreenId, this.onPresented, this.onCloseRequested, this.onDismissed, @@ -152,6 +187,12 @@ class PLYPresentation { if (key is String) metadata[key] = value; }); + final connections = (map['connections'] as List?) + ?.whereType() + .map(PLYConnection.fromMap) + .toList() ?? + const []; + final rawType = map['type']; final typeIndex = rawType is int ? rawType @@ -174,6 +215,8 @@ class PLYPresentation { type: _typeFromInt(typeIndex), plans: plansList, metadata: metadata, + connections: connections, + customScreenId: map['customScreenId'] as String?, ); } @@ -207,6 +250,9 @@ class PLYPresentation { 'type': type.index, 'plans': plans.map((p) => p.toMap()).toList(), 'metadata': metadata, + 'connections': + connections.map((connection) => connection.toMap()).toList(), + if (customScreenId != null) 'customScreenId': customScreenId, }; /// Re-display the presentation (matches `display()` on the native SDKs). @@ -221,6 +267,11 @@ class PLYPresentation { /// Navigate to the previous flow step or dismiss the current one /// (matches `back()` on Android). Future back() => PLYPresentationActions.instance.back(this); + + /// Executes a connection's configured actions. Passing no connection asks + /// the native SDK to execute the presentation's default connection. + Future execute([PLYConnection? connection]) => + PLYPresentationActions.instance.execute(this, connection); } /// Convenience extension so a preload future can be chained directly to display: diff --git a/purchasely/pubspec.yaml b/purchasely/pubspec.yaml index e18c895e..15364d83 100644 --- a/purchasely/pubspec.yaml +++ b/purchasely/pubspec.yaml @@ -5,7 +5,7 @@ homepage: https://www.purchasely.com/ environment: sdk: ">=3.0.0 <4.0.0" - flutter: ">=1.20.0" + flutter: ">=3.10.0" dependencies: flutter: diff --git a/purchasely/test/custom_screens_test.dart b/purchasely/test/custom_screens_test.dart new file mode 100644 index 00000000..d0025a23 --- /dev/null +++ b/purchasely/test/custom_screens_test.dart @@ -0,0 +1,118 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:purchasely_flutter/purchasely_flutter.dart'; +// PurchaselyBridge (debugReset) is a test-only entry point — not exported publicly. +import 'package:purchasely_flutter/src/bridge.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const mainChannel = MethodChannel('purchasely'); + const customChannel = MethodChannel('purchasely-custom-screen'); + late TestDefaultBinaryMessenger messenger; + late List mainCalls; + late List customCalls; + + setUp(() { + messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + mainCalls = []; + customCalls = []; + messenger.setMockMethodCallHandler(mainChannel, (call) async { + mainCalls.add(call); + return true; + }); + messenger.setMockMethodCallHandler(customChannel, (call) async { + customCalls.add(call); + return true; + }); + }); + + tearDown(() { + messenger.setMockMethodCallHandler(mainChannel, null); + messenger.setMockMethodCallHandler(customChannel, null); + PurchaselyBridge.debugReset(); + }); + + test('registration forwards entrypoint configuration and removal', () async { + await Purchasely.setCustomScreenProvider( + entrypoint: 'customEntry', + libraryUri: 'package:example/custom.dart', + ); + await Purchasely.removeCustomScreenProvider(); + + expect(mainCalls[0].method, 'setCustomScreenProvider'); + expect(mainCalls[0].arguments, { + 'entrypoint': 'customEntry', + 'libraryUri': 'package:example/custom.dart', + }); + expect(mainCalls[1].method, 'removeCustomScreenProvider'); + }); + + test('presentation round trip preserves connections and metadata', () { + final presentation = PLYPresentation.fromMap({ + 'requestId': 'request-1', + 'screenId': 'custom-step', + 'type': 3, + 'metadata': {'headline': 'Welcome', 'count': 2}, + 'connections': >[ + {'id': 'next', 'isDefault': true}, + {'id': 'skip', 'isDefault': false}, + ], + }); + + expect(presentation.type, PLYPresentationType.client); + expect(presentation.metadata['headline'], 'Welcome'); + expect(presentation.connections, hasLength(2)); + expect(presentation.connections.first.id, 'next'); + expect(presentation.connections.first.isDefault, isTrue); + + final reparsed = PLYPresentation.fromMap(presentation.toMap()); + expect(reparsed.connections.first.id, 'next'); + expect(reparsed.connections.first.isDefault, isTrue); + expect(reparsed.metadata['count'], 2); + }); + + test('custom presentation routes exact id for execute back and close', + () async { + final presentation = PLYCustomScreenPresentation.fromMap( + { + 'customScreenId': 'ply_cs_7', + 'screenId': 'custom-step', + 'type': 3, + 'connections': >[ + {'id': 'next', 'isDefault': true}, + ], + }, + ); + + await presentation.execute(presentation.connections.single); + await presentation.execute(); + await presentation.back(); + await presentation.close(); + + expect(customCalls.map((call) => call.method), [ + 'customScreenExecuteConnection', + 'customScreenExecuteConnection', + 'customScreenBack', + 'customScreenClose', + ]); + expect(customCalls[0].arguments, { + 'customScreenId': 'ply_cs_7', + 'connectionId': 'next', + }); + expect(customCalls[1].arguments, { + 'customScreenId': 'ply_cs_7', + 'connectionId': null, + }); + }); + + test('custom presentation rejects payload without scoped native id', () { + expect( + () => PLYCustomScreenPresentation.fromMap( + {'screenId': 'custom-step'}, + ), + throwsFormatException, + ); + }); +} diff --git a/sdk_public_doc.md b/sdk_public_doc.md index ee9300dc..6cc7941a 100644 --- a/sdk_public_doc.md +++ b/sdk_public_doc.md @@ -29,9 +29,10 @@ guide. 9. [Custom User Attributes](#custom-user-attributes) 10. [Event Listeners](#event-listeners) 11. [Pre-fetching Screens](#pre-fetching-screens) -12. [Inline Presentations](#inline-presentations) -13. [Deeplinks Management](#deeplinks-management) -14. [Platform-Specific Features](#platform-specific-features) +12. [Custom Screens](#custom-screens) +13. [Inline Presentations](#inline-presentations) +14. [Deeplinks Management](#deeplinks-management) +15. [Platform-Specific Features](#platform-specific-features) --- @@ -628,6 +629,85 @@ try { --- +## Custom Screens + +A Custom Screen is a CLIENT step whose UI is built by your Flutter app while +the Purchasely native SDK continues to own the surrounding flow, transitions, +back stack, analytics, and configured connection actions. + +Register the provider after the SDK starts and before any flow can be shown: + +```dart +await Purchasely.apiKey('') + .runningMode(PLYRunningMode.full) + .stores([PLYStore.google]) + .start(); + +await Purchasely.setCustomScreenProvider(); +``` + +The default entrypoint name is `purchaselyCustomScreen`. It must be top-level, +kept from tree shaking, accept `List`, and start the Custom Screen +runtime: + +```dart +@pragma('vm:entry-point') +void purchaselyCustomScreen(List args) { + PurchaselyCustomScreens.run(args, (context, presentation) { + return MaterialApp( + home: Scaffold( + body: Column( + children: [ + Text(presentation.metadata['title'] as String? ?? 'Custom step'), + for (final connection in presentation.connections) + ElevatedButton( + onPressed: () => presentation.execute(connection), + child: Text(connection.id ?? 'Continue'), + ), + TextButton( + onPressed: presentation.execute, + child: const Text('Use default connection'), + ), + TextButton(onPressed: presentation.back, child: const Text('Back')), + TextButton(onPressed: presentation.close, child: const Text('Close')), + ], + ), + ), + ); + }); +} +``` + +If the entrypoint is in another library, provide its package URI: + +```dart +await Purchasely.setCustomScreenProvider( + entrypoint: 'myCustomScreenEntrypoint', + libraryUri: 'package:my_app/custom_screens.dart', +); +``` + +Call `removeCustomScreenProvider()` to restore the native SDK's no-provider +behavior. + +Important runtime constraints: + +- Each Custom Screen uses a secondary Flutter engine and dedicated Dart isolate. +- App plugins are not auto-registered on that secondary engine; the Custom + Screen runtime exposes only the dedicated Purchasely navigation channel. +- Main-isolate Provider, Bloc, Riverpod, GetIt, Navigator, and inherited themes + are unavailable. The builder should provide its own `MaterialApp` or theme. +- Prefer presentation `metadata`, backend state, or persistent platform storage + for configuration shared with the main app. +- Register on every launch before deeplinks or campaigns can open an eligible + flow. Android flow restoration may request the step again. +- Custom Screen hosting supports CLIENT steps inside native flows only. It does + not support `PLYPresentationView` inline hosting or standalone native CLIENT + presentations. +- On the current iOS native SDK, `PLYConnection.isDefault` is reported as + `false`; calling `presentation.execute()` without an argument still executes + the native default connection. + ## Inline Presentations To render a presentation inline (embedded) inside your widget tree — as opposed