Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> 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)
400 changes: 400 additions & 0 deletions docs/plans/custom-screens-byos.md

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions purchasely/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
30 changes: 30 additions & 0 deletions purchasely/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> 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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, Any?>
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)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -710,6 +714,48 @@ class PurchaselyFlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware,
val loaded = clientPresentation(presentationMap, "clientPresentationClosed") ?: return
Purchasely.clientPresentationClosed(loaded)
}

private fun executeConnection(args: Map<String, Any?>?, 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<String, Any?>?, 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
Expand Down Expand Up @@ -1473,6 +1519,57 @@ class PurchaselyFlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAware,
val loadedPresentations = ConcurrentHashMap<String, PLYPresentationBase.Loaded>()
val displayCallbacks = ConcurrentHashMap<String, (PLYPresentationOutcome) -> Unit>()

@Volatile
var customScreenEntrypoint: String = "purchaselyCustomScreen"
private set
@Volatile
var customScreenLibraryUri: String? = null
private set
private val customScreenCounter = AtomicLong(0)
val customScreenPresentations = ConcurrentHashMap<String, PLYPresentationBase.Loaded>()

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<String, Any?>? {
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
Expand Down Expand Up @@ -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,
)
},
)
}

Expand Down
Loading
Loading