• The custom components implementation is explained in its own section.
• The implementation for providing a custom view for the header of the Stepper component is detailed in its own section.
• The implementation for providing a custom loader is explained in its own section.
• Collecting analytics events from the SDK is explained in its own section.
• Handling the start of a new process while in a running process is explained in its own section.
class MyApplication : Application(), FlowxOwner { override val flowx: Lazy<Flowx> = lazy { Flowx.getInstance() } override fun onCreate() { super.onCreate() initFlowXSdk() } private fun initFlowXSdk() { Flowx.getInstance().init( context = applicationContext, config = object : Config { override val baseUrl = "URL to FlowX backend", override val imageBaseUrl = "URL to FlowX CMS Media Library", override val enginePath = "some_path", override val language = "en", override val locale = Locale.getDefault(), override val validators: Map<String, (String) -> Boolean>? = mapOf("exact_25_in_length" to { it.length == 25 }), override val logEnabled: Boolean get() = 0 != applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE, }, customComponentsProvider = object : CustomComponentsProvider {...}, customStepperHeaderProvider = object : CustomStepperHeaderProvider {...}, customLoaderProvider = object : CustomLoaderProvider {...}, analyticsCollector = { event -> // Process / Send the event to some specialized Analytics platform }, onNewProcessStarted = { processInstanceUuid -> // Send a broadcast message to notify the Activity currently displaying the running process. // The Activity should handle the broadcast to reload and display the newly started process identified by `processInstanceUuid`. } ) }}
The configuration properties that should be passed as Config data for the config parameter above are:
Name
Description
Type
Requirement
baseUrl
URL to connect to the FlowX back-end environment
String
Mandatory
imageBaseUrl
URL to connect to the FlowX Media Library module of the CMS
String
Mandatory
enginePath
URL path segment used to identify the process engine service
String
Mandatory
language
The language used for retrieving enumerations and substitution tags
String
Optional. Defaults to en.
locale
The locale used for date, number and currency formatting
The custom validators map is a collection of lambda functions, referenced by name (i.e. the value of the key in this map), each returning a Boolean based on the String which needs to be validated.
For a custom validator to be evaluated for a form field, its name must be specified in the form field process definition.
By looking at the example from above:
Report incorrect code
Copy
Ask AI
mapOf("exact_25_in_length" to { it.length == 25 })
if a form element should be validated using this lambda function, a custom validator named "exact_25_in_length" should be specified in the process definition.
To be able to use the SDK, authentication is required. Therefore, before calling any other method on the singleton instance, make sure that the access token is set by calling:
Whenever the access token changes based on your own authentication logic, it must be updated in the renderer by calling the setAccessToken method again.
Passing null or empty string ("") as an argument to the setAccessToken method clears the token
Prior setting up the theme, make sure the access token was set. Check the authentication section for details.
To be able to use styled components while rendering a process, the theming mechanism must be invoked by calling the suspend-ing setupTheme(...) method over the singleton instance of the SDK:
UUID string identifier of the workspace that contains the theme to be loaded
String
Mandatory. Should not be empty
themeUuid
UUID string of the theme configured in FlowX Designer
String
Mandatory. Can be empty
fallbackThemeJsonFileAssetsPath
Android asset relative path to the corresponding JSON file to be used as fallback, in case fetching the theme fails and there is no cached version available
String?
Optional. Defaults to null
appearance
Indicator for the appearance of the theme (LIGHT, DARK)
Flowx.ThemeAppearance
Options. Defaults to Flowx.ThemeAppearance.LIGHT
onCompletion
@MainThread invoked closure, called when setting up the theme completes
() -> Unit
Mandatory
If the themeUuid parameter value is empty (""), no theme will be fetched, and the mechanism will rely only on the fallback file, if set.
If the fallbackThemeJsonFileAssetsPath parameter value is null, there will be no fallback mechanism set in place, meaning if fetching the theme fails, the redered process will have no style applied over it’s displayed components.
The SDK caches the fetched themes, so if a theme fetch fails, a cached version will be used, if available. Otherwise, it will use the file given as fallback.
The fallbackThemeJsonFileAssetsPath always search for files under your project’s assets/ directory, meaning the example parameter value is translated to file://android_asset/theme/a_fallback_theme.json before being evaluated.
Do not start or resume a process before the completion of the theme setup mechanism.
Prior starting a process, make sure the authentication and theming were correctly set up
After performing all the above steps and all the prerequisites are fulfilled, a new instance of a FlowX process can be started, by using the startProcess function:
class ProcessActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) ... setContent { Flowx.getInstance().continueProcess( processUuid = "some process UUID string", isModal = true, onProcessEnded = { // NOTE: possible processing could involve doing something in the container app (i.e. navigating to a different screen) }, closeModalFunc = { processName -> // NOTE: possible handling could involve doing something differently based on the `processName` value }, ).invoke() } } ...}
The closeModalFunc parameter is a function defined within the CloseModalProcessScope context.This gives the ability to query for substitution tags or media library items in order to use them when handling this callback (i.e. showing an snackbar or an alert).
Report incorrect code
Copy
Ask AI
interface CloseModalProcessScope { fun replaceSubstitutionTag(key: String): String fun getMediaResourceUrl(key: String): String?}
All substitution tags will be retrieved by the SDK before starting the process and will be stored in memory.Whenever the container app needs a substitution tag value for populating the UI of the custom components, it can request the substitution tag, through the CustomComponentScope context, using the method above, by providing the key.It returns:
the key’s counterpart, if the key is valid and found
the empty string, if the key is valid, but not found
the unaltered string, if the key has the wrong format (i.e. not starting with @@)
All media items will be retrieved by the SDK before starting the process and will be stored in memory.Whenever the container app needs a media item url for populating the UI of the custom components, it can request the url, through the CustomComponentScope context, using the method above, by providing the key.It returns the URL string of the media resource, or null, if not found.
The container application should decide which custom component view to provide using the componentIdentifier configured in the UI designer.A custom component receives data to populate the view and actions available to execute, as described below.
It can also be validated and provide data back into the process when executing an action.To handle custom components, an implementation of the CustomComponentsProvider interface should be passed as a parameter when initializing the SDK:
Report incorrect code
Copy
Ask AI
interface CustomComponentsProvider { fun provideCustomComponent(componentIdentifier: String): CustomComponent?}
The implementation for providing a custom component is based on creating and binding a user defined @Composable function, through the CustomComponent interface:
Report incorrect code
Copy
Ask AI
interface CustomComponent { /** * Returns the [Composable]s for every custom component identifier defined in the FlowX Designer */ val composable: @Composable CustomComponentScope.() -> Unit /** * This will be called when data is available for the custom component i.e. when the * User Task that contains the custom component is displayed. * * @param data used to populate the custom component */ fun populateUi(data: Any?) /** * This will be called when actions are available for the custom component i.e. when the * User Task that contains the custom component is displayed. * * @param actions that need to be attached to the custom component (e.g. onClick events) */ fun populateUi(actions: Map<String, CustomComponentAction>) /** * This will be called when executing an action, when the platform needs to know if the specified/marked components are valid. * Defaults to `true`. */ fun validate(): Boolean = true /** * This will be called when executing an action, on computing the data to be sent as body on the network request. * Returning `null` (i.e. default) means it does not contribute with any data to be sent. */ fun saveData(): JSONObject? = null}
The value for the data parameter received in the populateUi(data: Any?) could be:
Boolean
String
java.lang.Number
org.json.JSONObject
org.json.JSONArray
The appropriate way to check and cast the data accordingly to the needs must belong to the implementation details of the custom component.
Both validation and providing data back into process are optional, and, based on the needs, it may be included in the implementation or not.
The composable property of the CustomComponent is a @Composable function which may be defined and run only within the context of a CustomComponentScope receiver.
Report incorrect code
Copy
Ask AI
val composable: @Composable CustomComponentScope.() -> Unit
Report incorrect code
Copy
Ask AI
interface CustomComponentScope { fun executeAction(action: CustomComponentAction, params: JSONObject? = null) fun replaceSubstitutionTag(key: String): String fun getMediaResourceUrl(key: String): String? suspend fun getEnumeration(name: String, parentName: String? = null): FxEnumeration?}
This allows calling predefined SDK methods for executing actions, querying for substitution tags, media resource URLs or obtaining enumerations data, directly from the custom component through the scope itself.
The custom components which the container app provides may contain FlowX actions available for execution.
These actions are received through the actions parameter of the populateUi(actions: Map<String, CustomComponentAction>) method.
In order to run an action (i.e. on a click of a button in the custom component) you need to call the executeAction method, through the CustomComponentScope context:
Report incorrect code
Copy
Ask AI
fun executeAction(action: CustomComponentAction, params: JSONObject? = null)
Parameters
Name
Description
Type
Requirement
action
Action object extracted from the actions received in the custom component
All substitution tags will be retrieved by the SDK before starting the process and will be stored in memory.Whenever the container app needs a substitution tag value for populating the UI of the custom components, it can request the substitution tag, through the CustomComponentScope context, using the method above, by providing the key.It returns:
the key’s counterpart, if the key is valid and found
the empty string, if the key is valid, but not found
the unaltered string, if the key has the wrong format (i.e. not starting with @@)
All media items will be retrieved by the SDK before starting the process and will be stored in memory.Whenever the container app needs a media item url for populating the UI of the custom components, it can request the url, through the CustomComponentScope context, using the method above, by providing the key.It returns the URL string of the media resource, or null, if not found.
suspend fun getEnumeration(name: String, parentName: String? = null): FxEnumeration?
Whenever the container app needs an enumeration data for populating the UI of the custom components, it can request the url, through the CustomComponentScope context, using the method above, by providing the name (and the parentName, if there’s a hierarchy defined and the desired enumeration data (name is child of parentName).It returns the enumeration data, as an FxEnumeration object, or null, if not found.
Report incorrect code
Copy
Ask AI
interface FxEnumeration { val name: String val items: List<Item> val parentName: String? interface Item { val code: String val content: String? val childNomenclatorName: String? val order: Int }}
Among multiple existing custom components, there may be one that:
allows the input of a value representing an age
the value should be validated (e.g. to be at least 35 years old)
the value will be passed back into the process
execute an action, through the CustomComponentScope context, to skip setting the age
Report incorrect code
Copy
Ask AI
class FxCustomComponentsProvider : CustomComponentsProvider { override fun provideCustomComponent(componentIdentifier: String): CustomComponent? = when (componentIdentifier) { when (componentIdentifier) { "other-custom-component-identifier" -> OtherCustomComponent() "age" -> AgeCustomComponent() else -> null } }}private class AgeCustomComponent() : CustomComponent { private val data: MutableStateFlow<Any?> = MutableStateFlow(null) private var actions: MutableMap<String, CustomComponentAction> = mutableMapOf() private val viewModel = AgeViewModel(data = data, actions = actions) override val composable: @Composable CustomComponentScope.() -> Unit get() = @Composable { Age(viewModel = viewModel) } override fun populateUi(data: Any?) { this@AgeCustomComponent.data.update { _ -> data } } override fun populateUi(actions: Map<String, CustomComponentAction>) { this@AgeCustomComponent.actions.apply { clear() putAll(actions) } } override fun validate(): Boolean = viewModel.isValid() override fun saveData(): JSONObject? = viewModel.buildDataToSave()}@Composableprivate fun CustomComponentScope.Age(viewModel: AgeViewModel) { viewModel.setFlowxScope(this@Age) // IMPORTANT: keep a reference to the custom context/scope in order to access the available predefined SDK methods val age by viewModel.ageFlow.collectAsState() val error by viewModel.errorFlow.collectAsState() val isError by remember(error) { mutableStateOf(error.isNotBlank()) } Column { OutlinedTextField( value = age, onValueChange = { viewModel.updateAge(it) }, modifier = Modifier.fillMaxWidth(), label = { Text("Age") }, ) if (isError) { Text( modifier = Modifier.fillMaxWidth(), text = error, style = TextStyle(fontSize = 12.sp), color = Color.Red, textAlign = TextAlign.Start, ) } TextButton( onClick = { viewModel.executeSkipAction() } ) { Text(text = "Skip") } }}private class AgeViewModel( private val data: MutableStateFlow<Any?> = MutableStateFlow(null), private val actions: Map<String, CustomComponentAction> = emptyMap(),) : ViewModel() { private lateinit var flowxScope: CustomComponentScope // IMPORTANT: keeps the custom context/scope which allows to call the available predefined methods exposed to custom component private val _ageFlow = MutableStateFlow("") val ageFlow: StateFlow<String> = _ageFlow.asStateFlow() private val _error = MutableStateFlow("") val errorFlow: StateFlow<String> = _error.asStateFlow() fun updateAge(text: String) { _ageFlow.value = text } fun setFlowxScope(scope: CustomComponentScope) { flowxScope = scope } fun isValid(): Boolean = ageFlow.value.toIntOrNull().let { when { it == null -> false.also { _error.update { "Unrecognized format" } } it < 35 -> false.also { _error.update { "You have to be at least 35 years old" } } else -> true.also { _error.update { "" } } } } fun buildDataToSave(): JSONObject? = ageFlow.value.takeUnless { it.isBlank() } ?.let { JSONObject( """ { "app": { "age": "$it" } } """.trimIndent() ) } fun executeSkipAction() { actions["skipAction"]?.let { if (this@AgeViewModel::flowxScope.isInitialized) { // IMPORTANT: if the custom context/scope was initialized, the action can be safely executed flowxScope.executeAction( action = it, params = JSONObject() // e.g. JSONObject("{\"someParameter\": \"someValue\"}") ) } } ?: println("AgeCustomComponent: `skipAction` action was not found") }}private class OtherCustomComponent() : CustomComponent { override val composable: @Composable (CustomComponentScope.() -> Unit) get() = @Composable { /* add some @Composable implementation */ } override fun populateUi(data: Any?) { // extract the necessary data to be used for displaying the custom components } override fun populateUi(actions: Map<String, CustomComponentAction>) { // extract the available actions that may be executed from the custom components } // Optional override, defaults to `true`. // Here one can pass validation logic from viewModel (e.g. by calling `viewModel.isValid()`) override fun validate(): Boolean = true // Optional override, defaults to `null`. // Here one can pass data to save from viewModel (e.g. by calling `viewModel.getDataToSave()`) override fun saveData(): JSONObject? = null}
The container application can opt for providing a custom view in order to be used, for all the Stepper components, as a replacement for the built-in header.
The custom view receives data to populate its UI, as described below.To provide a custom header for the Stepper, an implementation of the CustomStepperHeaderProvider interface should be passed as a parameter when initializing the SDK:
Report incorrect code
Copy
Ask AI
interface CustomStepperHeaderProvider { fun provideCustomStepperHeader(): CustomStepperHeader?}
To provide the custom header view as a @Composable function, you have to implement the CustomStepperHeader interface:
Report incorrect code
Copy
Ask AI
interface CustomStepperHeader { /** * Returns the [Composable]s used to render the stepper header. * The received argument contains the stepper header necessary data to render the view. */ val composable: @Composable (data: Data) -> Unit interface Data { // title for the current step; can be empty or null val stepTitle: String? // title for the current selected substep; optional; // can be empty ("") if not defined or `null` if currently there is no selected substep val substepTitle: String? // 1-based index of the current step val step: Int // total number of steps val totalSteps: Int // 1-based index of the current substep; can be `null` when there are no defined substeps val substep: Int? // total number of substeps in the current step; can be `null` or `0` val totalSubsteps: Int? }}
The container application can decide to provide custom loaders to be displayed at certain moments based on a given predefined actionName.
To provide custom loaders, an implementation of the CustomLoaderProvider interface should be passed as a parameter when initializing the SDK:
Report incorrect code
Copy
Ask AI
interface CustomLoaderProvider { fun provideCustomLoader(actionName: String?): CustomLoader?}
The possible values for the actionName parameter are:
startProcess - received for overriding the loader displayed when starting a new process
reloadProcess - received for overriding the loader displayed when resuming an existing process
whatever string value representing the name of an action as defined at process definition time - received for overriding the loader displayed while that action is executed
Returning an implementation of a CustomLoader replaces the built-in platform loader with the provided one for the specified use cases.
Returning null keeps the built-in platform loader for the specified use cases.
The implementation for providing a custom loader is based on creating and binding a user defined @Composable function, through the CustomLoader interface:
Report incorrect code
Copy
Ask AI
interface CustomLoader { val composable: @Composable CustomLoaderScope.() -> Unit}
The composable property of the CustomLoader is a @Composable function which may be defined and run only within the context of a CustomLoaderScope receiver.
Report incorrect code
Copy
Ask AI
val composable: @Composable CustomLoaderScope.() -> Unit
Report incorrect code
Copy
Ask AI
interface CustomLoaderScope { fun replaceSubstitutionTag(key: String): String fun getMediaResourceUrl(key: String): String?}
This allows calling predefined SDK methods for querying for substitution tags and media resource URLs directly from the custom loader through the scope itself.
class FxCustomLoaderProvider : CustomLoaderProvider { override fun provideCustomLoader(actionName: String?): CustomLoader? = when (actionName) { "startProcess" -> MyCustomLoader(backgroundColor = Color.Black.copy(alpha = 0.38f), indicatorColor = Color.Red) "reloadProcess" -> MyCustomLoader(backgroundColor = Color.Yellow.copy(alpha = 0.38f), indicatorColor = Color.Green) "action1" -> ActionCustomLoader() "action2" -> ComplexCustomLoader() else -> null }}class MyCustomLoader(val backgroundColor: Color, val indicatorColor: Color) : CustomLoader { override val composable: @Composable (CustomLoaderScope.() -> Unit) get() = @Composable { Box( modifier = Modifier .fillMaxSize() .background(color = backgroundColor), contentAlignment = Alignment.Center, ) { CircularProgressIndicator(color = indicatorColor) BackHandler(enabled = true) {} // block back navigation } }}class ActionCustomLoader() : CustomLoader {...}class ComplexCustomLoader() : CustomLoader { private val animatedViewModel = AnimatedLoaderViewModel(...) override val composable: @Composable (CustomLoaderScope.() -> Unit) get() = @Composable { AnimatedLoader(viewModel = animatedViewModel) }}@Composableprivate fun CustomLoaderScope.AnimatedLoader( viewModel: AnimatedLoaderViewModel) { viewModel.setFlowxScope(this@AnimatedLoader) // IMPORTANT: keep a reference to the custom context/scope in order to access the available predefined SDK methods val state by viewModel.loaderState.collectAsStateWithLifecycle() Box { // use state to build what is to be displayed }}private class AnimatedLoaderViewModel(...) : ViewModel() { private lateinit var flowxScope: CustomLoaderScope // IMPORTANT: keeps the custom context/scope which allows to call the available predefined methods exposed to custom component val loaderState: StateFlow<AnimatedLoaderState?> = someData .process { if (this@AnimatedLoaderViewModel::flowxScope.isInitialized) { // IMPORTANT: if the custom context/scope was initialized, the substitution tag can be safely queried flowxScope.replaceSubstitutionTag(it) } else { it } } .stateIn(...) fun setFlowxScope(scope: CustomLoaderScope) { flowxScope = scope }}
To be able to collect analytics events from the SDK, an implementation for the AnalyticsCollector functional interface may be provided when initializing the SDK:
Report incorrect code
Copy
Ask AI
fun interface AnalyticsCollector { fun onEvent(event: Event)}
There are two types of events, Screen and Action, both of them containing some Data and an optional CustomPayload, as defined at process definition time.The Event is structured like this:
Report incorrect code
Copy
Ask AI
sealed interface Event { interface Screen : Event { val data: Screen.Data interface Data { val value: String val customPayload: CustomPayload? } } interface Action : Event { val data: Action.Data interface Data { val value: String val screen: String? val component: String? val label: String? val customPayload: CustomPayload? } } sealed interface CustomPayload { interface Object : CustomPayload { val data: JSONObject } interface Array : CustomPayload { val data: JSONArray } }}
When an action of type START_PROJECT is executed, the onNewProcessStarted lambda provided in the Flowx.getInstance().init(...) function is invoked.This callback provides the UUID of the newly started process, which can be used to resume the process by calling the Flowx.getInstance().continueProcess(...) method.It is the responsibility of the container application’s developer to implement the necessary logic for displaying the appropriate UI for the newly started process.
One way to handle this is to send a broadcast message to notify the Activity currently displaying the running process.
The Activity should handle the broadcast to reload and display the newly started process identified by processInstanceUuid (received in the broadcast intent).
Report incorrect code
Copy
Ask AI
Flowx.getInstance().init( ... onNewProcessStarted = { processInstanceUuid -> applicationContext.sendBroadcast( Intent("some.intent.filter.indentifier").apply { putExtra("processInstanceUuid", processInstanceUuid) setPackage("your.application.package") } ) } ...)class ProcessActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) ... setContent { val yourBroadcastReceiver = remember { YourBroadcastReceiver(handler = { processInstanceUuid -> /* do your own logic to refresh `ProcessContent()` */ }) } val context = LocalContext.current LifecycleStartEffect(true) { ContextCompat.registerReceiver(context.applicationContext, yourBroadcastReceiver, IntentFilter("some.intent.filter.indentifier"), ContextCompat.RECEIVER_NOT_EXPORTED) onStopOrDispose { runCatching { context.applicationContext.unregisterReceiver(yourBroadcastReceiver) } } } ProcessContent() } } @Composable private fun ProcessContent(...) {...}}class YourBroadcastReceiver(private val handler: (String) -> Unit) : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { intent?.extras?.getString("processInstanceUuid")?.let { processUuid -> handler.invoke(processUuid) } }}
The SDK ships with its own consumer-rules.pro file containing the required keep rules for correct functionality.
These rules are automatically merged into the container app’s ProGuard/R8 configuration and applied during code shrinking and obfuscation.
The container app may add additional rules if its specific logic requires them.