Compose Multiplatform SDK
Complete integration guide for the Encatch Compose Multiplatform SDK — in-app feedback and survey collection for Compose Multiplatform apps
The Encatch Compose Multiplatform SDK (com.encatch:compose-sdk) lets you collect in-app feedback and surveys from one shared commonMain Compose UI targeting Android and iOS. It depends on the Kotlin Multiplatform SDK (com.encatch:kmp-sdk) for the entire Encatch business-logic API — init, identify, track, show/dismiss forms, submit, sessions, events — and adds the one thing a pure KMP consumer wouldn't need: EncatchInlineForm, a composable that wraps the platform-native inline form view (AndroidView/UIKitView interop — no WebView reimplementation, no third-party dependency).
Under the hood, Encatch calls bridge to the two native Encatch SDKs (the Android SDK on Android, the pure-Swift iOS SDK on iOS) — a thin routing layer, not a third implementation.
No install step
The modal form host installs itself: on iOS when you call Encatch.init(...), on Android the first time EncatchInlineForm composes (via Compose's LocalContext). There is no Application.onCreate setup and no EncatchFormHost.install() call to make.
Overview
- Package:
com.encatch:compose-sdk(Maven Central) - Version: 0.1.1
- Platforms: Android (minSdk 24), iOS (
iosArm64,iosSimulatorArm64) - Repository: github.com/get-encatch/encatch-android
- License: MIT
Installation
Add the dependency to your shared module's commonMain source set:
// build.gradle.kts (shared module)
kotlin {
sourceSets {
commonMain.dependencies {
implementation("com.encatch:compose-sdk:0.1.1")
}
}
}This transitively brings in com.encatch:kmp-sdk's Encatch API — no separate dependency needed. A Compose Multiplatform customer adds only compose-sdk.
Modal-only Android apps
On Android the modal form host installs lazily the first time EncatchInlineForm composes. If your app only uses modal forms and never composes EncatchInlineForm, install the host eagerly in Application.onCreate instead: com.encatch.android.EncatchFormHost.install(this) (see the KMP SDK setup notes). On iOS the host always installs inside Encatch.init(...), so nothing is needed either way.
Quick Start
1. Initialization
Call Encatch.init once at app startup — a LaunchedEffect at your root composable is a natural place. It's a suspend function; subsequent calls (identifyUser, showForm, tracking) silently no-op until initialization completes.
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import com.encatch.sdk.Encatch
@Composable
fun App() {
LaunchedEffect(Unit) {
if (!Encatch.isInitialized) {
Encatch.init("your-api-key")
}
}
// ... your app UI ...
}Pass an optional EncatchConfig to customize SDK behavior:
import com.encatch.sdk.Encatch
import com.encatch.sdk.EncatchConfig
import com.encatch.sdk.Theme
LaunchedEffect(Unit) {
Encatch.init(
"your-api-key",
EncatchConfig(
theme = Theme.SYSTEM,
debugMode = true,
isFullScreen = false,
appVersion = "1.2.3",
onBeforeShowForm = { payload ->
// Return false to prevent the form from showing
true
},
),
)
}Prop
Type
2. Identify users
Identify the current user. The userName is required (can be a username, email, or unique identifier). Traits and options are optional.
Username format
userName must be an ASCII identifier: 1–50 characters, using only letters A–Z / a–z, digits 0–9, and ., _, @, -. Spaces and non-English characters (Unicode, accented letters, emoji, etc.) are not supported. Use an email address, internal user ID, or ASCII username — for example user@example.com or user_123. To store a display name in another language, pass it as a trait instead (e.g. set = mapOf("display_name" to JsonPrimitive("…"))).
val scope = rememberCoroutineScope()
Button(onClick = {
scope.launch { Encatch.identifyUser("user@example.com") }
}) { Text("Sign in") }Trait values are kotlinx.serialization JsonElements — use JsonPrimitive for strings, numbers, and booleans:
import com.encatch.sdk.UserTraits
import kotlinx.serialization.json.JsonPrimitive
scope.launch {
Encatch.identifyUser(
"user@example.com",
traits = UserTraits(
set = mapOf(
"name" to JsonPrimitive("Alice"),
"plan" to JsonPrimitive("team"),
),
),
)
}import com.encatch.sdk.UserTraits
import kotlinx.serialization.json.JsonPrimitive
scope.launch {
Encatch.identifyUser(
"user@example.com",
traits = UserTraits(
set = mapOf("name" to JsonPrimitive("Alice"), "plan" to JsonPrimitive("team")),
setOnce = mapOf("firstSeen" to JsonPrimitive("2026-08-05T12:00:00Z")),
increment = mapOf("loginCount" to 1.0),
decrement = mapOf("credits" to 5.0),
unset = listOf("trialEndDate"),
),
)
}Prop
Type
User traits support the following operations:
| Operation | Type | Description |
|---|---|---|
set | Map<String, JsonElement>? | Set user attributes (overwrites existing values) |
setOnce | Map<String, JsonElement>? | Set user attributes only if they don't already exist |
increment | Map<String, Double>? | Increment numeric user attributes |
decrement | Map<String, Double>? | Decrement numeric user attributes |
unset | List<String>? | Remove user attributes |
Recommended
Using the secure option with a server-generated signature is recommended to verify that identification requests come from your backend. Keep your secret key on the server only — never expose it in client-side code.
Pass a server-generated HMAC signature so Encatch can validate the request. generatedDateTimeInUtc must be milliseconds since the Unix epoch (the string form of your server's epoch-millis timestamp). When your publishable key has a session timeout, use the same value in HMAC-SHA256(userName + epochMs, secretKey). It is sent as the X-User-Signature-Time header and limits the signature's lifespan.
import com.encatch.sdk.IdentifyOptions
import com.encatch.sdk.SecureOptions
scope.launch {
Encatch.identifyUser(
"user@example.com",
options = IdentifyOptions(
secure = SecureOptions(
signature = "your-hmac-signature",
generatedDateTimeInUtc = "1741867200000", // ms since epoch (2025-03-13T12:00:00Z)
),
),
)
}3. Show a form manually
Show a specific form by slug or ID. If a matching EncatchInlineForm is composed, the form renders inline there; otherwise it presents as a modal overlay.
import androidx.compose.runtime.rememberCoroutineScope
import com.encatch.sdk.Encatch
import kotlinx.coroutines.launch
@Composable
fun FeedbackButton() {
val scope = rememberCoroutineScope()
Button(onClick = {
scope.launch { Encatch.showForm("feedback-form") }
}) { Text("Give feedback") }
}Prop
Type
Prop
Type
| ResetMode | Behavior |
|---|---|
ResetMode.ALWAYS | Reset pre-fill and response data on every form display |
ResetMode.ON_COMPLETE | Reset only after the form is completed |
ResetMode.NEVER | Never reset response data |
Pass caller context when showing a form. Context values use the ContextValue sealed class (StringValue, NumberValue, BooleanValue, DateValue):
import com.encatch.sdk.ContextValue
import com.encatch.sdk.ResetMode
import com.encatch.sdk.ShowFormOptions
scope.launch {
Encatch.showForm(
"feedback-form",
ShowFormOptions(
reset = ResetMode.ALWAYS,
context = mapOf(
"plan" to ContextValue.StringValue("team"),
"feature" to ContextValue.StringValue("checkout"),
),
),
)
}Other actions
Set the user's preferred language.
Encatch.setLocale("fr")Set the user's country.
Encatch.setCountry("FR") // ISO 3166 country codeSet the theme for forms and surveys.
import com.encatch.sdk.Theme
Encatch.setTheme(Theme.DARK)
Encatch.setTheme(Theme.LIGHT)
Encatch.setTheme(Theme.SYSTEM) // Follows system preferencescope.launch { Encatch.trackEvent("button_clicked") }Track screens as your navigation state changes — a LaunchedEffect per screen composable works well:
@Composable
fun HomeScreen() {
LaunchedEffect(Unit) { Encatch.trackScreen("Home") }
// ...
}Subscribe to form lifecycle events. on returns an unsubscribe function — there is no separate off in this API; call the returned function instead. In Compose, pair the subscription with a DisposableEffect so it unregisters with the composition:
import androidx.compose.runtime.DisposableEffect
import com.encatch.sdk.Encatch
@Composable
fun App() {
DisposableEffect(Unit) {
val unsubscribe = Encatch.on { eventType, payload ->
println("Event: ${eventType.wireValue}, formId: ${payload.formId}")
}
onDispose { unsubscribe() }
}
// ...
}| Event | Description |
|---|---|
EventType.FORM_SHOW | Fired when a form is displayed |
EventType.FORM_STARTED | Fired when a user starts interacting |
EventType.FORM_SUBMIT | Fired when a form is submitted |
EventType.FORM_COMPLETE | Fired when a form is fully completed |
EventType.FORM_CLOSE | Fired when a form is closed |
EventType.FORM_DISMISSED | Fired when a form is dismissed without completion |
EventType.FORM_ERROR | Fired when an error occurs |
EventType.FORM_SECTION_CHANGE | Fired when the visible section changes |
EventType.FORM_ANSWERED | Fired when a question is answered |
EventType.FORM_REMIND_ME_LATER | Fired when the user taps "Remind me later" |
EventType.FORM_CTA_TRIGGERED | Fired when a completion CTA is triggered on thank-you or exit screens |
Handle completion CTAs (in-app navigation, internal redirect, or external browser) via FORM_CTA_TRIGGERED. Configure actions in the form builder — see Call to action. The SDK closes the form overlay after emitting the event — your app handles in-app navigation. Event payload data is a Map<String, JsonElement>:
import com.encatch.sdk.EventType
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
DisposableEffect(Unit) {
val unsubscribe = Encatch.on { eventType, payload ->
if (eventType == EventType.FORM_CTA_TRIGGERED) {
val action = (payload.data?.get("action") as? JsonPrimitive)?.contentOrNull
val route = (payload.data?.get("route") as? JsonPrimitive)?.contentOrNull
if (action == "app_navigate") {
// Map route strings to your app's navigation state
when (route) {
"billing", "billing/upgrade" -> currentScreen = Screen.Billing
}
}
}
}
onDispose { unsubscribe() }
}For in-app navigation, the SDK closes the form overlay after emitting the event and expects your app to perform navigation. For URL redirect actions, the SDK opens the URL and closes the form automatically.
Pre-fill a form response before showing a form. questionId may be a question UUID or a question slug.
scope.launch {
Encatch.addToResponse("question_id", "pre-filled value")
Encatch.addToResponse("choice_question_id", listOf("option-a", "option-b"))
Encatch.showForm("your-form-slug")
}Inspect or clear pending pre-fills with Encatch.getPendingResponses() / Encatch.clearPendingResponses().
Dismiss the currently displayed form.
scope.launch {
Encatch.dismissForm()
// Or dismiss a specific form configuration:
Encatch.dismissForm(formConfigurationId = "config-id")
}Use onBeforeShowForm in EncatchConfig to conditionally block forms from showing. The interceptor is a suspend lambda, so it may await anything — user input, a coroutine, a network check — before answering.
import com.encatch.sdk.EncatchConfig
import com.encatch.sdk.TriggerType
Encatch.init(
"your-api-key",
EncatchConfig(
onBeforeShowForm = { payload ->
// Inspect payload.formId, payload.triggerType, payload.formConfigJson, etc.
if (payload.triggerType == TriggerType.AUTOMATIC && someCondition) {
false // Block this form
} else {
true // Allow
}
},
),
)Register a debug hook that receives every completed SDK HTTP call (request + response). Only fires when EncatchConfig.debugMode is enabled; the API key header is always masked to its last 5 characters. Assignment-style (last caller wins) and survives re-init() — set it once at app startup for in-app network inspectors.
Encatch.setOnNetworkLog { entry ->
println("${entry.method} ${entry.endpoint} -> ${entry.status} in ${entry.durationMs}ms")
}
// Pass null to clear:
Encatch.setOnNetworkLog(null)Control session lifecycle manually:
import com.encatch.sdk.StartSessionOptions
scope.launch {
Encatch.startSession()
// Skip the immediate ping or screen re-track on start:
Encatch.startSession(
StartSessionOptions(
skipImmediatePing = true,
skipImmediateTrackScreen = true,
),
)
}// Temporarily stop the 30-second background ping (not persisted)
Encatch.pauseSession()
// Resume the ping interval after pauseSession()
Encatch.resumeSession()// Fully suspend SDK activity — stops ping and dismisses open forms.
// Persists across app restarts. Re-enable with startSession().
scope.launch { Encatch.stopSession() }Reset the current user identity and clear persisted identity data. Reverts the SDK to anonymous mode. User identity is preserved across stopSession() — use resetUser() after logout.
scope.launch { Encatch.resetUser() }Wipes all persisted SDK data and resets in-memory state. Stronger than resetUser() — also clears session-stopped state and device preferences. The SDK remains initialized; call identifyUser afterward.
scope.launch { Encatch.clearAll() }The SDK sends a background ping every 30 seconds (configurable via server response) to maintain engagement sessions and check for triggered forms. Ping is suppressed while a form is visible.
Inline Forms
Inline forms are a way to show Encatch in-app feedback without a modal — the survey renders directly in your layout instead of as a full-screen overlay.
EncatchInlineForm renders a form directly inside your composition — no modal, no overlay. Place it anywhere in a Compose Multiplatform layout: a Column, a verticalScroll container, a Card, etc. It works identically on Android and iOS from commonMain.
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import com.encatch.sdk.Encatch
import com.encatch.sdk.compose.EncatchInlineForm
@Composable
fun FeedbackScreen() {
val scope = rememberCoroutineScope()
Column(Modifier.verticalScroll(rememberScrollState())) {
// ... content above ...
Button(onClick = {
scope.launch { Encatch.showForm("your-form-slug") }
}) { Text("Show form (renders inline below)") }
EncatchInlineForm(
formId = "your-form-slug", // exact match; omit for wildcard
modifier = Modifier.fillMaxWidth(),
)
// ... content below ...
}
}Calling Encatch.showForm("your-form-slug") from anywhere in the app then renders the form inside this slot instead of as a modal.
When showForm is called, the SDK resolves the presenter in this order:
- Exact match — an
EncatchInlineFormwhoseformIdmatches the payload wins. - Wildcard — an
EncatchInlineFormwithformId = null(the default) catches anything not exact-matched. - Modal fallback — the modal overlay shows the form when no inline slot is composed or none match.
// Exact slot — only showForm("nps-survey") renders here:
EncatchInlineForm(formId = "nps-survey", modifier = Modifier.fillMaxWidth())
// Wildcard slot — catches any form id not exactly claimed elsewhere:
EncatchInlineForm(modifier = Modifier.fillMaxWidth())No fixed height is required — the underlying native view self-sizes (a skeleton placeholder first, then live form:resize values from the web form) on both platforms. Give it a width and let the height follow:
Column(Modifier.verticalScroll(rememberScrollState())) {
EncatchInlineForm(
formId = "my-form",
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp)),
)
}The host layout provides scrolling and keyboard avoidance — use your scroll container plus Modifier.imePadding() as you would for any other content.
| Prop | Type | Default | Description |
|---|---|---|---|
formId | String? | null | Exact form slug/id to match. null = wildcard. |
modifier | Modifier | Modifier | Standard Compose modifier for width, clipping, padding, etc. |
Build Your Own Form UX & UI
If your feedback flow uses a fixed, predictable question set — the same fields and workflow every time — you can build the form with your own composables and submit responses through the SDK. That keeps typography, spacing, colors, and interaction patterns aligned with the rest of your app, so the survey feels like a native screen rather than an embedded web page.
The flow has three parts, all available from commonMain:
- Intercept the form with
onBeforeShowFormand returnfalse. TheShowFormInterceptorPayloadincludesformConfigJson— the JSON encoding of the full form configuration (includingquestionnaireFields), so you can render your own UI from the real form definition. - Render your own Compose UI from the payload.
- Submit with
buildSubmitRequest+Encatch.submitForm.
import com.encatch.sdk.BuildSubmitRequestOptions
import com.encatch.sdk.Encatch
import com.encatch.sdk.EncatchConfig
import com.encatch.sdk.NativeFormResponse
import com.encatch.sdk.buildSubmitRequest
// 1. Intercept: queue the blocked form as Compose state instead of letting the SDK render it
var blockedForm by mutableStateOf<BlockedForm?>(null)
suspend fun initSdk() {
Encatch.init(
"your-api-key",
EncatchConfig(
onBeforeShowForm = { payload ->
if (payload.formId == "my-native-form") {
blockedForm = BlockedForm(payload.formId, payload.formConfigJson)
false // block the SDK form — we render our own composable
} else {
true
}
},
),
)
}
// 2. Render: show your own composable when blockedForm != null
// (parse formConfigJson to build the question list)
// 3. Submit: convert your composable's answers and post them to Encatch
suspend fun submitNativeForm(formConfigurationId: String) {
val responses = listOf(
NativeFormResponse("q1", "rating", 5),
NativeFormResponse("q2", "short_answer", "Great product!"),
NativeFormResponse("q3", "multiple_choice_multiple", listOf("option-a", "option-b")),
)
val requestJson = buildSubmitRequest(
BuildSubmitRequestOptions(formConfigurationId = formConfigurationId),
responses,
)
Encatch.submitForm(requestJson)
}NativeFormResponse.value's expected shape depends on the question type: numeric scales (rating, nps, csat, opinion_scale) take a Number or numeric String; text types take String; choice and ranking types take String or List<String>; boolean types (yes_no, consent) take Boolean. All 33 Encatch question types are supported; unknown types fall back to short_answer for forward-compatibility.
Prop
Type
Everything from the Kotlin Multiplatform SDK is available unchanged, plus one composable:
| Member | Description |
|---|---|
EncatchInlineForm(formId, modifier) | Composable inline form slot (formId = null for wildcard) |
Encatch.init(apiKey, config) | Initialize the SDK (suspend) |
Encatch.identifyUser(userName, traits, options) | Identify a user (suspend) |
Encatch.showForm(formId, options) | Show a form (inline or modal) (suspend) |
Encatch.dismissForm(formConfigurationId) | Dismiss the current form (suspend) |
Encatch.trackEvent(eventName) / trackScreen(screenName) | Track events and screens (suspend) |
Encatch.setLocale(locale) / setCountry(country) / setTheme(theme) | Preferences |
Encatch.addToResponse(questionId, value) | Pre-fill a question answer |
Encatch.submitForm(requestJson) | Submit a custom native form (suspend) |
Encatch.startSession(options) / stopSession() / pauseSession() / resumeSession() | Session control |
Encatch.resetUser() / clearAll() | Identity reset / full data wipe (suspend) |
Encatch.on(callback) | Subscribe to lifecycle events; returns unsubscribe function |
Encatch.emitEvent(eventType, payload) | Emit an event to listeners |
Encatch.setOnNetworkLog(callback) | Debug hook for SDK HTTP calls (debugMode only) |
See the KMP SDK API quick reference for the complete member list and read-only getters.
- Kotlin Multiplatform SDK — the
Encatchbusiness-logic API this module builds on; use it directly if you don't need Compose UI. - Android SDK — the native Android SDK this module bridges to on Android.
- iOS SDK — the native Swift SDK this module bridges to on iOS.
Support
- Maven Central:
com.encatch:compose-sdk - Issues: github.com/get-encatch/encatch-android/issues
Was this page helpful?
Kotlin Multiplatform SDK
Complete integration guide for the Encatch Kotlin Multiplatform SDK — in-app feedback and survey collection for KMP apps targeting Android and iOS
Flutter SDK
Complete integration guide for the Encatch Flutter SDK — in-app feedback and survey collection for Flutter apps
