Skip to main content
Supported on: iOS SDK 1.0.61+ · Android SDK 1.0.33+ · Flutter SDK 1.0.3+
The AppDNA Flutter SDK delivers full onboarding parity through native platform rendering. The onboarding module lets you present server-driven onboarding flows that are configured in the AppDNA Console, with native UI primitives on each platform — SwiftUI on iOS, Jetpack Compose on Android. Flows are delivered to the SDK via the remote config bundle, so you can update onboarding experiences without shipping app updates.

Present an Onboarding Flow

Present a specific onboarding flow by ID. The method resolves to a bool — check it:
Before Flutter SDK 1.0.20 this returned Future<void>, so an unknown flow ID completed exactly like a successful presentation and the only way to find out was to read the device log. If you are on an older version, upgrade — the call itself is unchanged and source-compatible.
The Flutter API takes the flow ID as a positional argument. To present the active flow configured in remote config from the AppDNA Console, the canonical pattern is to pick an explicit flow ID at the call site — the Console controls which flow is active by toggling published flows.

If the app is ended mid-flow

On Android, if the system ends your app’s process while a flow is in the background, the flow is not restored when the user returns: AppDNA.configure runs from your Dart code, which starts only after Android has restored the onboarding screen, so the SDK cannot rebuild the flow. The screen closes at once and a warning is logged; the onboarding_flow_dismissed event is sent once configure runs in that process, and no onOnboardingDismissed callback reaches your Dart code. Present the flow again if you want the user to finish it; it starts from its first step. (A native Android app that calls configure in Application.onCreate resumes the flow on the step the user left.) On iOS, an app the system terminates in the background starts fresh: the flow is not restored, and you present it again if you want the user to finish it. Rotating the device keeps the flow on its step on both platforms, with any text typed on it but not yet submitted.

Module Access

Access the onboarding module directly to use additional methods like setDelegate and to pass an OnboardingContext:

Module Methods

The static AppDNA.presentOnboarding(flowId) is a convenience that calls AppDNA.onboarding.present(flowId) with no context.
When present() answers false. Three reasons, needing different fixes:The first two are indistinguishable from the return value alone. The third is this wrapper’s own guard: presentOnboarding resolves false immediately when there is no current Activity, without reaching the native SDK.

OnboardingContext

Pass additional context when presenting a flow:
The experimentOverrides parameter is intended for development and QA only. In production, experiment variants are assigned automatically by the AppDNA backend.

Paywalls inside onboarding flows

Steps with outcome: present_paywall open a paywall and route the user based on the result (purchase / fail / dismiss → next step). The host’s AppDNAPaywallDelegate (registered via AppDNA.paywall.setDelegate(...)) fires for these embedded paywalls identically to standalone presentations — purchase, restore, action, and post-purchase callbacks all reach the host. See the Flutter paywall docs for the full delegate surface.

AppDNAOnboardingDelegate

Implement the AppDNAOnboardingDelegate abstract class to receive onboarding flow lifecycle events. The delegate fires for every step transition and at flow completion / dismissal. For server-driven async hooks that can block or redirect step advancement, see Async Step Hooks below.

Set the Delegate

Example Implementation

Flow Settings

A flow’s own settings object applies to every step in it. All of it is authored in the Console; the SDK reads it from the published flow.

The close control

settings.dismiss_button_style sets how the ✕ that closes the flow looks. Every key is optional and unset keeps what the SDK drew before the style existed, so an existing flow is unchanged. 🔴 The tap target does not shrink with the glyph. It stays 44×44 whatever icon_size says, so a smaller ✕ is not a harder one to hit.

A celebration over the flow

settings.particle_effect draws the Rich Media particle overlay over the whole flow, including the progress bar, and never takes a tap. An onboarding flow has two moments it can celebrate: on_appear (its first step) and on_step_complete (each advance). See Rich Media for the effect’s own keys.

Advancing without a button

A step may set tap_anywhere_advances, and a tap anywhere on it then advances the flow. It is the pattern a splash screen wants: the alternative was dismiss_allowed, whose ✕ says “leave” rather than “continue”. 🔴 A tap that a control already handled is not an advance. A button, a field or a row keeps its own tap, so only an otherwise-unhandled tap advances the step. It composes with auto_advance_seconds rather than replacing it, and it goes through the same path the primary CTA uses — so the step’s routing rules, its completion event and its required-field gate are identical.

Step Types

Onboarding flows support the following step types, configured in the AppDNA Console:
Step types and their content are defined entirely in the Console. The SDK renders them natively — you do not need to build UI for individual step types from Flutter.

Selection Modes

Question steps support two selection modes: When the user comes back to a question step they already answered (with the back button), the option or options they chose are selected again and Continue is enabled, so they can move on without picking again. A pick changed there replaces the stored answer when they continue. A pick not yet submitted is kept when the device is rotated.

Apple permission strings

A permission step requests a real OS permission. On iOS, add the Info.plist key for a permission type only when your flow authors a permission step of that type (in ios/Runner/Info.plist). If the key is missing, the step skips the request safely and records the permission as unavailable.
Alarms & Reminders is the one type that is not a prompt. It asks for the exact-alarm capability, which no OS prompts for:
  • Android — on API 31+ the SDK opens the per-app Alarms & reminders Settings screen (ACTION_REQUEST_SCHEDULE_EXACT_ALARM) and reads the toggle back; below API 31 exact alarms are allowed by default, so the step reports granted without showing anything. 🔴 Your app must declare SCHEDULE_EXACT_ALARM (or USE_EXACT_ALARM) itself — the SDK’s manifest does not merge it, so without it the capability can never be granted.
  • iOS — there is no exact-alarm permission, so this type requests the notification permission instead. A host testing only on iOS sees a notification prompt, which is correct rather than a bug.
It branches like every other type, on permission_alarm = "granted" or "denied".
  • Every iOS build needs NSLocationWhenInUseUsageDescription, whether or not your flow has a location step: the SDK links CoreLocation and Google Maps.
  • From Flutter 1.0.20 (iOS SDK 1.0.82) the SDK no longer references the Contacts, EventKit, App Tracking Transparency or Photos-library APIs unless your flow uses those permission steps, so you no longer need their strings just because the SDK is linked. If App Store Connect still asks for a key, add it and tell us. On Flutter 1.0.19 and earlier the strings are still required.
  • The image-picker block uses the system photo picker, which needs no photo-library permission.
  • Android: the SDK’s own manifest merges INTERNET, ACCESS_NETWORK_STATE, WAKE_LOCK, POST_NOTIFICATIONS and VIBRATE into yours; its library dependencies (Firebase, Google Play Billing, Google Maps) may merge their own.

Permission types and their branch keys

The Console’s Permission Type select writes one of these wire names, and the SDK stores the outcome under permission_<wire name> — so this is the spelling a next-step rule has to match. Every key holds "granted" or "denied" (or is absent when the permission could not be requested at all).

When the user refuses: the Settings fallback

A permission can only be asked for once; after a refusal the OS answers “denied” without showing anything. Two step-level keys turn that dead end into a route to the system settings page: With the fallback on, a denial shows an alert titled “Permission needed” with the message “You can enable this in Settings.”, a confirm button carrying settings_fallback_label, and a Continue that dismisses it. Either choice advances the step, so the flow never traps the user; the confirm button opens the app’s own settings page first. Both keys are read from the step’s config and then from its layout object, so a flow that carries them at either level works. 🔴 permission_<type> is still written as denied when the fallback is shown, before the user has had a chance to change anything in Settings. A next-step rule branching on it sees the refusal, not the eventual state — which is the right behaviour for routing, and the wrong thing to treat as a durable permission status. Ask the OS again on the next launch if that matters.

Content Blocks

Each onboarding step is composed of content blocks that control the visual layout. Blocks are configured in the Console and rendered natively on each platform. The block string the Console emits is the same string the native SDK switches on. The following block types are available:

Layout primitives

Default text colour. These elements take their colour from the step behind them when they are not styled: a heading or text block with no style.color; an input’s label with no field_style.label_color; the typed text, placeholder and cursor of a text input (input_text, input_email, input_phone, input_url, input_number, input_textarea, input_location) with no field_style colours; and the step’s back, close and Skip controls with no authored icon colour. On a light step they are dark, as before (labels #374151). On a dark, gradient or image step they are white (labels #E5E7EB, placeholders and controls slightly translucent). A block with its own fill is judged by that fill. An authored colour always wins.

Interactive blocks

Form input blocks

Form inputs are individual blocks (not a single form block). Each input type emits its current value through the step’s inputValues map and is included in the step responses on completion.
Blocks are configured entirely in the Console. The SDK renders them automatically — no Dart code is needed unless you want to register a host-provided custom view (see Custom View Registration below).

Advanced blocks

Available on iOS SDK 1.0.67+ / Android SDK 1.0.39+.
A batch of richer, interactive blocks render through the native layer on both platforms: These wrap the native renderers — see the iOS and Android onboarding docs for each block’s field_config fields and for the onElementInteraction hook that powers the interactive ones.

Map block: route line and camera

With Interactive (pan & zoom) on and the Google provider set in Settings → Apps → Maps, the map is drawn natively (see Maps). A route’s line comes from, in order: a polyline your mapRoutes supplies; the Route from a variable, when it resolves; the authored Fixed route line; else straight segments between the stops. The polyline must use Google’s encoding, precision 5 — precision-6 polylines (polyline6, e.g. Mapbox / OSRM) are not supported: read as precision 5 their coordinates come out ten times too large, so they are usually rejected as out of range (the stops are used instead), but a route whose every point lies within 9° of the equator and 18° of the prime meridian decodes in range and draws a wrong line. Always encode at precision 5. With Auto-fit to the route and stops on (the default), the camera frames the whole route line plus the stops, with a fixed padding, and re-frames when a host mapRoutes reply arrives after the first paint; your own pan and zoom are otherwise never overridden. A single point is shown at zoom 15. With Auto-fit off, the camera centres on the authored map centre at the authored zoom. On iOS, routes that cross the antimeridian (±180° longitude) are not framed correctly: the fit does not wrap around it, so such a route is framed across the long way round the globe. Android frames with the Google Maps SDK’s bounds builder. A drag that starts on the map pans the map, while a drag anywhere else on the step scrolls the step — the two never fight for the same gesture. Pinch and double-tap zoom the map.

Rive state-machine inputs

A rive block can set the animation’s state-machine inputs from the published document, so one .riv file can draw different things without a separate asset per state.
The kind of input is taken from the value’s TYPE, because JSON has no way to say which of Rive’s three kinds is meant:
state_machine is required for inputs to apply. Rive’s setters are per state machine, so a block with inputs and no state_machine has its inputs dropped — the SDK logs a warning and the animation plays its default state. The console warns about this beside the Inputs editor.
Inputs are applied once the .riv has finished downloading, and re-applied when the authored values change.

Button Actions

button and social_login blocks emit a structured payload when tapped. The action value on the button (configured in the Console) determines what shape the payload takes and lets you route to the right backend call (sign in, register, request OTP, etc.). If the user has not completed required form fields on the step, the native SDK shows an inline validation toast and does not emit the action — same gate as the standard next button.

Flow control

Asking for a store review

A step can carry Request store review. The SDK asks the OS for its in-place review sheet as that step appears — not on a tap, and not from a survey answer.
  • The OS decides whether a sheet actually appears, and neither store reports the outcome. There is no callback and no event that says “the user saw it”.
  • The SDK rate-limits its own asks on top of the OS quota: at most 3 a year, at least 90 days apart. A request it could not make does not count against that.
  • 🔴 The step must be one the user leaves themselves. A step that advances on a timer walks out from under the sheet, and the ask is spent either way — the Console refuses that combination.
  • 🔴 Do not put a question on the step. Asking how the user feels and showing the sheet only to those who answered well is review gating; Apple’s and Google’s policies both forbid it, and the Console refuses a step that pairs the two. State something instead of asking it (“Three weeks of daily check-ins. Nice work.”), and ask everyone who reaches that moment.
For a review the user chooses to leave, use a button with the rate_app action instead — that opens your store page and invokes no system prompt.

Authentication entry

When the user taps a button with one of these actions, the SDK calls your onBeforeStepAdvance handler with the payload below. Show a “Signing in…” spinner by returning {'type': 'block', 'message': …} while async auth runs, then return {'type': 'proceed'} / {'type': 'proceedWithData', 'data': {...}} when the backend responds. A sign-in action advances only on a reply with a recognised type: {}, null, or an unknown type blocks it with “Sign-in isn’t available right now. Please try again later.” (both platforms).
If you do not implement AppDNAOnboardingDelegate, the SDK stays on the step and logs a warning rather than advancing past credential collection. Auth actions require a host to perform the actual side effect (sign in, register, send OTP, …) — silently advancing would leak credentials into the response bundle without ever calling your backend.
How long the SDK waits for a sign-in answer. For every action in the table below the plugin’s bridge waits at least 120 seconds for your onBeforeStepAdvance reply — long enough for OS sign-in sheets, account pickers, 2FA and a backend round trip — or longer if you set a larger AppDNAOptions.vetoTimeout. Every other hook is bounded by vetoTimeout (default 5 s), except that a refresh gets 8 s (a larger vetoTimeout does not extend it) and onBeforeStepRender is cut off at 3 s by the SDK. If no answer arrives in time (or your handler throws), an auth action does not advance: it blocks with “Sign-in isn’t available right now. Please try again later.” and a late reply is dropped. So:
  • Make the sign-in handler idempotent. If sign-in completed after the deadline, the user’s retry should find the session already established and answer {'type': 'proceed'} at once.
  • Answer promptly when the user cancels the OS sheet — return {'type': 'stay'} (or a block with a message). The 120 s is a ceiling, not a wait.

Account lifecycle

request_otp channel resolution

request_otp and verify_otp include a channel field so your handler knows which delivery method to call back on. The SDK resolves it in this order:
  1. Explicit — set the button’s action_value to "sms", "email", "whatsapp", or "voice" in the Console (case-insensitive).
  2. Auto-detect — if the step has exactly one phone-typed input (input_phone) → channel: "sms"; exactly one email-typed input (input_email) → channel: "email".
  3. Omitted — if the step has both an email AND a phone input (or neither), the channel key is absent from the payload. Check with stepData?['channel'] as String? and fail explicitly if null rather than guessing. Set action_value on the button to disambiguate.
The matching input field’s value is included as recipient so you don’t have to look it up from the step responses.

Handling auth actions

The advance hook returns a plain map (there is no StepAdvanceResult Dart class). Recognized shapes:
Existing next, skip, link and permission button actions are unchanged. Host apps that already implement AppDNAOnboardingDelegate keep working without changes — the default onBeforeStepAdvance returns {}, which advances every step except a sign-in action: a sign-in action (including social_login) is blocked until your handler answers it with an explicit type. Host apps with no Dart delegate get the “Sign-in isn’t available right now. Please try again later.” block when an auth action fires; for those flows, either implement the delegate or keep using next as the button action.
A flow with a sign-in step needs onBeforeStepAdvance. A delegate that does not override it — or a handler that answers a sign-in action without a recognised type — leaves the user on the sign-in step with “Sign-in isn’t available right now. Please try again later.”: they cannot get past it. Override onBeforeStepAdvance and answer every sign-in action explicitly (see Handling auth actions).

Custom View Registration

custom_view blocks are rendered by a host-native view registered on each platform, not by a Dart widget — the Flutter plugin is a thin wrapper and does not host a Flutter surface inside the native onboarding step. There is no AppDNA.registerCustomView(...) in the Dart API. Register the view on the native side and reference its identifier from the custom_view block in the Console:
  • iOS — register a SwiftUI/UIKit view factory with the native AppDNA SDK in your AppDelegate.
  • Android — register a native view factory with the AppDNA SDK in your Application.
The identifier used at registration must match the custom view identifier configured in the Console for the custom_view block. The custom_view block names which one to draw, and says what to show when the host has not registered it: 🔴 A custom_view whose key is not registered draws a PLACEHOLDER, not nothing. On a build that forgot the register call the step still renders, with [my_view] where the view should be — which is the difference between a visible mistake and a blank screen nobody can explain.

Block Styling

Every content block supports a block_style design token that controls appearance properties such as padding, margin, background color, corner radius, border, shadow, and opacity. Block styles are configured in the Console and applied automatically by the native renderers.

Block layout and positioning

Every content block carries the same size and placement keys, whatever its type. They are read by both SDKs and by the console preview, so a block positioned in the editor lands in the same place on a device. 🔴 A percentage is of the block’s CONTAINER, not of the screen. element_width: "50%" inside a row half as wide as the screen is a quarter of the screen. The renderers measure the container they are in, which is what makes the device agree with the console preview. auto means “as wide as the content” and is the one value that stops a button from filling its row — it is not the same as leaving the key unset on every block type.

Inside a stack

A stack container draws its children on top of one another, and these order and align them: overflow: "visible" is what a badge pinned to the corner of a card needs — without it the part that hangs over the edge is clipped away.

Block content and variants

Behaviour keys

collapse_on_scroll is iOS-only on an onboarding block today. Android decodes it and does not act on it, so a block authored to collapse stays put there. The paywall’s own section collapse is unaffected and works on both. Tracked as a known gap.

Media and badge styling

These sit at the top level of the block, beside element_width and friends — not inside field_config. 🔴 A badge hangs over its block’s edge, so the container it sits in needs overflow: "visible" or the part that overhangs is clipped away — see the stack table above.

Visibility Conditions

Blocks can be shown or hidden based on earlier answers and on bindings. A condition is configured per block in the Console, carried on the block as visibility_condition, and evaluated by the native SDK client-side before that block renders. variable names an answer key or a binding. value is a string or a number. These are block visibility operators. Step routing — which step comes next — is a different set, on next_step_rules: always, answer_equals, answer_contains, event_fired, event_not_fired, event_count_gte, event_count_lte, previous_step_equals and previous_step_in. A routing operator in a visibility condition does not match, and vice versa.

Entrance Animations

Each block supports an entrance animation that plays when the block first appears, configured per block in the Console and carried on the block as entrance_animation. Its type is one of none, fade_in, slide_up, slide_down, slide_left, slide_right, scale_up, scale_down, bounce or flip. duration_ms, delay_ms and easing are optional — each renderer has its own default, so a block carrying only type animates. easing is one of linear, ease, ease_in, ease_out, ease_in_out or spring.

Form Steps

The form step type provides native input fields for collecting structured user data. Each form can contain multiple fields with validation, conditional visibility, and custom configuration. Fields are rendered using native iOS controls (UIKit / SwiftUI) and native Android controls (Jetpack Compose).

Supported Input Types

Styling a form-step field

A field in a form step carries its styling under style — not field_style, which is the CONTENT BLOCK’s key for the same idea one surface away. 🔴 focus_border_color and error_style are STATES, so the console preview cannot show them: a read-only preview is never focused and never in error. They reach both devices; check them on a device rather than in the preview.
Requires iOS SDK 1.0.84. focus_border_color and error_style reached iOS for the first time in 1.0.84 (FormFieldChrome); Android has read both since 1.0.39. Earlier iOS versions drew the field’s resting border in every state.

Field Validation

Form fields support built-in and custom validation. Everything below is checked when the step’s Continue button is tapped, in this order, and the first failure per field is the message shown: validate_format defaults to on, which is what the Console’s switch shows. Set it to false to collect a URL-ish string without format checking. The URL rule is deliberately looser than a parser: whitespace around the value is ignored, the http:// or https:// scheme is optional and case-insensitive, and what must be present is a host of at least two dot-separated labels whose last label is two or more letters — so example.com and sub.example.co.uk:8080/path pass, and example, https://example and not a url do not. A path, query or fragment may follow and may contain anything except whitespace. 🔴 min_length and max_length are not a pair. max_length truncates the input as the user types, so it can never fail; min_length is checked on submit and produces the message above. A field with both keeps both behaviours.

Conditional Fields

Fields can depend on other fields using depends_on rules. For example, a “Company name” field can appear only when the user selects “Employed” in a previous field. Supported operators: equals, not_equals, contains, not_empty, empty, gt, lt, is_set.

Form Responses

Form field values are included in the responses map passed to onOnboardingCompleted, keyed by step ID. Each step’s value is a map of field ID to field value.

Flag CTAs

A button can record the user’s choice without leaving the flow. Set its action to Flag & continue in the Console and give it a flag key (and optionally a value, default true). Tapping it advances the step exactly like a normal Continue button — same required-field validation, same next-step rules — and writes one key. Use it when a step offers something your app must act on later: “add a private workshop”, “start the trial”, “talk to a human”. Routing to that destination mid-onboarding abandons a flow the user is halfway through; the flag lets you route once, after onboarding finishes. The flag arrives in two places. It is in the step’s own answers, alongside every other value that step collected, and it is also collected flat under flags so you don’t have to walk every step to find it:
One flag per button. A step offering three things uses three buttons, each with its own key.
The value is always a string — "true" when no value was authored — so it behaves identically across iOS, Android, Flutter and React Native.

Location Fields

The location field type provides an autocomplete search input that returns structured location data including city, state, country, coordinates, and timezone. When the user starts typing (e.g., “New York”), the native SDK debounces the input (300ms), calls the AppDNA geocoding proxy (up to iOS 1.0.83 the iOS Location content block used Apple MapKit search instead), and displays a dropdown of suggestions. The user selects a result and the SDK stores its structured data — on the releases that resolve a tapped suggestion, the data of one more lookup of that place, or only the typed text when that lookup fails (see Resolved answers below). If the user types without selecting a suggestion, the step response keeps the typed String, on both platforms and both surfaces (form-step field or content block). The keyboard’s return key is a search key (it reads Search on iOS; on Android the keyboard draws its own search key, usually a magnifier). Pressing it leaves the field: the suggestion dropdown closes, the keyboard goes away, and the typed text stays in the field. This is the same on the form step’s Location field and on the Location content block, on both platforms.

Search settings

Location search needs a geocoding key on your app. There is no shared provider key: set your own Mapbox or Google Places key in the console under Settings > Location search. Without one the proxy refuses every search, so every Location field and Location block shows no suggestions — the user can still type an address and it is stored as plain text, with no city, country, coordinates or time zone. Publishing a flow that collects locations warns you when the key is missing.Picking Google: its key’s project must have Places API (New) and the Geocoding API enabled, and the key’s API restrictions must allow both. The proxy answers a search made with an invalid or refused key with an error naming the cause, not an empty list that looks like “nothing matched” (the field shows no suggestions either way). Google’s terms forbid storing its content: on the SDK releases that resolve a tapped Google suggestion, the dropdown shows the Google Maps logo at the top of its list, directly under the field, the full answer stays on the device, and AppDNA keeps only the typed text and Google’s place ID (earlier releases show a Google-key app no suggestions and store the typed text). Google bills one Autocomplete request per search (the field searches each time typing pauses for 300 ms with at least the field’s minimum number of characters in it) and one Geocoding request per selection, with no Place Details. Your Terms and Privacy Policy must incorporate Google’s. Do not draw a Google answer on a non-Google map — for example by binding responses into a Mapbox Map element — nor a Mapbox answer on a Google map. The step server hook posts responses, a resolved Google answer included, to your own URL, and the onboarding events your onEventTracked observer receives carry the full answer too (AppDNA reduces it only on its own server): what you do with either is under your terms with Google. A Google answer you show yourself (through getLocationData, the onboarding-completed delegate, the onEventTracked observer, a hook or your own server) must carry Google’s attribution wherever it is shown without a Google map. That includes an answer you pass back into a flow, as a user trait ({{user.…}}) or in a hook reply ({{hook_data.…}}): the SDK credits Google only for the answers it stored itself, so text templated from those roots needs your own attribution. Searches are not cached. With Mapbox, on the SDK releases that resolve a tapped suggestion, it is stored through one permanent geocode (earlier releases get no suggestions from the day these releases ship, so their location field stores what the user typed — update the SDK to keep suggestions) (Mapbox’s terms forbid storing the search results themselves), which Mapbox allows only on an account with a payment method on file. If your Mapbox token lacks permanent access, only the text the user typed is stored. Mapbox remains the default.
Four settings shape the search itself. The Console sets them on a Location content block (see Configuration in Console below); a form-step Location field in a flow reads the same four keys. Every surface honours all four, on both platforms. A value outside the four location_type options is treated as unset rather than rejected: the search still works, as if no type were set (see Location Type above).
Requires iOS SDK 1.0.84. Up to iOS 1.0.83 the iOS Location content block searched Apple’s own place index, which takes no country bias, no language tag and has no city / region / country result type, and honoured none of these four settings. A selected suggestion on that surface now also carries state_code, and its time zone now comes from the proxy.

Location Data Structure

A selection contains the fields below. AppDNA.deepLinks.getLocationData(fieldId) returns them as a LocationData whose fields, except formattedAddress, are nullable: after typing without selecting it returns only formattedAddress and rawQuery (the typed text) with null coordinates and null city / state / country, and with no (or an empty) answer it returns null. A selection fills the fields that have a value, the same way on both platforms and both surfaces (form-step field or content block; up to iOS 1.0.83 the iOS content block had no stateCode). See LocationData.
On iOS, Flutter SDK 1.0.16 and earlier can crash in getLocationData after a typed (unselected) Location answer, and 1.0.17–1.0.19 do not build on iOS. Upgrade to 1.0.20.
Resolved answers. On the SDK releases that resolve a tapped suggestion, the tap looks the place up once more through AppDNA’s proxy, and the answer is what that lookup returns. With Mapbox the lookup is one permanent geocode, and the answer carries mapbox_id, an opaque marker of that (not a stable place id to look up). An address answer’s city is then the place that contains the address (for example "Lisbon"), not the street name earlier releases stored; a place, locality, region or country answer keeps its own name as city, so {{responses.<step>.<field>.city}} reads as before for those. A resolved region answer also fills its own state / state_code, and a country answer its own country / country_code, which earlier releases left empty. With Google the lookup is one Geocoding API request for the place ID the user tapped, and the answer carries place_id (never mapbox_id). Its city is the first of the place’s locality, postal town, sublocality or second-level administrative area that Google reports — a city answer’s own name, an address answer’s town — so a region or country answer has no city: its own name is its state (a region) or its country (a country). A tap the SDK cannot resolve stores the typed text — with Mapbox, no permanent access on the account; with Google, the Geocoding API not enabled on the key; with either, any other provider error or a network failure — and so does a step that moves on while the tap is still resolving (Continue on an optional field): the step’s responses carry the typed text, and the late reply is ignored. Editing after a Google selection. If the user selects a Google suggestion and then edits the field, the field restarts from the text they had typed (raw_query), and that typed text is stored: Google’s address line is never kept as typed text. What is kept is decided by the text the edit leaves in the field: when it is Google’s whole line followed by more characters, typed or pasted, those characters are appended to their typed text; any other result — a deletion, or a change anywhere inside the line, however it was made — leaves just their typed text. After that first edit the field holds ordinary typed text: whatever the user types or pastes from then on is stored as typed, even if it spells out Google’s address line, and undo cannot bring Google’s line back, because the field’s undo history is cleared on that first edit (even when it already left just their typed text) and whenever the SDK sets the field’s text.

Accessing Location Data

Location data is returned as a nested map in the responses payload to onOnboardingCompleted:
For a LocationData object instead of a map, call AppDNA.deepLinks.getLocationData("user_location") — see LocationData.

Template Engine

Location data is accessible in dynamic content templates rendered by the native SDK, on a later step, as {{responses.<step id>.<field id>.<key>}}:
On the SDK releases that resolve a tapped Google suggestion (see the note under Search settings), a step that templates any field of a Google answer (one carrying place_id) draws a small “Google Maps” credit on that step — Google requires its attribution in the same container as its content. It is drawn for you, from the step’s own content, wherever the token sits (text, option labels, overrides, bindings, and {{item.…}} in a repeat whose source is an answer path — responses.…, step.… or selected.… — holding Google answers; a repeat over hook_data, user or session is yours to attribute); an option’s bottom sheet draws its own credit. A mapbox_id or typed answer draws none. It also credits {{selected.<field_id>.label}} (or any other key the option took from a Google answer on an earlier step — not its value, which is stored as the typed text). What the step stores or sends instead of drawing takes the text the user typed (raw_query): an option’s value, a field’s default value, a markdown link’s destination in any form the renderer follows (an inline link, <…>, or a bare URL, www. address or email address), and an image, video, animation or other media URL (a key ending in _url, or a gallery image), unless the URL’s host, as written before its first {{…}} token, is one of Google’s Maps hosts (maps.googleapis.com, maps.google.com, maps.gstatic.com). Even there, a URL can carry an inner URL that is fetched from its own host (a Static Maps marker icon, for example), so two kinds of token take the typed text: a token written anywhere after a second scheme separator, read as a fetcher would read the text after decoding it — percent-decoded until no escape is left (so %3A, %253A and %25%33%41 are all a colon) and with every tab, line feed and carriage return (%09, %0A, %0D) removed — and then either a colon followed by a slash or a backslash (https://, https%3A%2F%2F, https:/, https:%5C%5C, https%253A%252F%252F, ftp:%0A//), or http: or https: in any case with no slash at all (https:x.example, ht%09tps:x.example, %2568ttps%253Ax.example); a bare colon after any word that does not end in http or https, such as markers=color:red, is not a separator, however it is encoded — and every token that follows another {{…}} token anywhere after the host, in the path or the query, because the earlier token’s value is not known from the template and could supply that scheme, such as {{hook_data.icon_base}} holding https://… in the query. The first token after the host, such as the only token after ?center=, keeps Google’s content, unless it is written after a second scheme separator. This holds wherever the SDK substitutes the answer, in an option sheet and in the older step types too, and for an option picked from a Select at any depth ({{selected.<field_id>.…}}). A {{selected.<field_id>.<key>}} whose option took that key from a Google answer resolves the same way: it credits where drawn and takes the typed text where stored or sent, including a pricing plan’s stored label. A token that stops at the picked option itself ({{selected.<field_id>}}, or a multi-select’s list) takes the typed text for each such key in those same places. On a Screen, which draws no credit, iOS resolves every Google answer and every such picked key to the typed text; Android does not resolve templates on a Screen yet, so a token there is drawn as written.

Configuration in Console

In the onboarding flow editor, add a Location block to a step from the block picker (Add Element → Location) and open its settings. Besides the field ID, label, placeholder and styling every input block has, it offers the four search settings, written to the block’s field_config: The editor saves a Bias Country only as two letters and a Language only as a language tag of two to five characters (such as de or pt-BR), and Settings > Location search applies the same rule to its Default country and Default language. Neither cuts what you type: a value that does not fit, such as AUT or english, shows an error and is not saved. A Default country or Default language saved before that rule existed is kept as it is: the page says the saved value is not valid until you correct it or clear it, and until then a search that uses it may show no suggestions, because a search the geocoding proxy refuses shows none.
Location autocomplete uses a server-side proxy, so the search adds no geocoding or places SDK to your app binary. (The native iOS and Android SDKs this package wraps do link Google Maps, which draws the Map element.) The geocoding provider (Mapbox by default) can be configured in Settings > Location search.A registered native map view (registerMapView) and the mapProvider override are native-only: set them in your iOS and Android code. A registered view draws only map blocks with Interactive (pan & zoom) on (the default); a non-interactive map draws the built-in static map (see the iOS and Android pages).

Interactive Chat Steps

The interactive_chat step type renders a conversational UI that forwards each user message to your webhook and renders the reply. This is how you integrate your own LLM, agent, or rule-based backend into an onboarding flow.

How it works

  1. User types a message in the chat step.
  2. The native SDK POSTs the conversation payload to the webhook_url configured in the Console (with any custom headers you set).
  3. Your server responds with JSON in the schema below.
  4. The native SDK renders the AI reply, quick-reply buttons, media, etc.
Turn limits, typing indicators, ratings, and quick-reply routing are handled by the native SDK — your webhook only needs to return the next reply. The conversation never round-trips through Dart. Rotating the device keeps the conversation on both platforms; on Android a reply still on its way is added to it when it arrives. On Android, if the system ends the app’s process during the chat, the conversation comes back with the flow; a reply that was still on its way is asked for again, once, with the same turn, so your webhook can receive a second request for a turn it already answered.

Request payload (SDK → your webhook)

Your headers are forwarded verbatim, except that a header templating a Google location answer receives the text the user typed (raw_query), not Google’s content. For bearer-token APIs, set the header value to Bearer YOUR_TOKEN (the SDK does not add the Bearer prefix automatically). The context object is opaque session state you control — whatever you returned in data on a previous turn gets echoed back here on every subsequent call. This is how you integrate threaded AI backends (OpenAI Assistants, hosted LLMs with threadId, etc.) without replaying the full history on every request. context is absent on turn 1 (your server hasn’t written anything yet) and accumulates across turns; later values overwrite earlier ones per-key (last-write-wins). See Threaded backends below for the full pattern.

Response schema (your webhook → SDK)

Only messages[].content is required — every other field is optional. The SDK reads messages[] to render chat bubbles, so if you omit it or return a different shape, the reply won’t render.
Returning a response like {"reply": "..."} without wrapping it in the messages array will silently fail — the SDK decodes unknown fields as null and shows nothing. Always wrap your reply in messages[{ "content": "..." }].

Full response schema

Unknown fields in your response are ignored, so you can also include your own server-side state at the top level (it just won’t round-trip — use data for anything you want back).

Minimal working example

Node.js (Express):

Threaded backends: round-tripping session state

If your AI service uses a thread/session handle (OpenAI Assistants API, a hosted LLM with conversation memory, or any third-party AI proxy that mints a session id), do not replay the full conversation history on every turn — latency and cost grow linearly with conversation length. Instead, return your session handle in data once; the SDK accumulates it and echoes it back in context on every subsequent turn. The wire contract in plain English:
  • You write to context by returning fields under data in any response.
  • You read from context off req.body.context on every request after the first.
  • Keys are merged across turns; later values overwrite earlier ones per-key.
  • The SDK never reads or rewrites your values — they are opaque to your server. You choose the keys and the shape.
  • 🔴 It does own the map, though, and a key cannot be removed. The SDK keeps an accumulating dictionary for the step and copies each field of your data into it per key, so a later response that simply omits a key leaves the earlier value in place — it is not cleared. To retire a key, return it explicitly with an empty value. The same on both platforms.
Example: integrating a threaded AI service keyed by threadId. If your backend rotates the thread (expiry, failure, migration), just return the new id in data — the SDK overwrites the cached value and uses it on the next turn automatically.
No server-side session store, no per-user cache, no history replay. The context round-trip IS your session storage.
The same pattern works for anything opaque you need per-conversation: API rate-limit bucket IDs, A/B cohort labels, tool-call state, reasoning scratchpads — whatever your AI backend needs to resume, stash it in data and read it from context on the next turn.

Console configuration

In the onboarding flow editor, add a step of type Interactive Chat, then set:

Auto-tracked events

The standard onboarding step events (onboarding_step_viewed, onboarding_step_completed) also fire for chat steps.

Async Step Hooks

The onboarding delegate supports two async hooks that let you intercept step transitions for server-side validation, dynamic content loading, or custom routing logic.

onBeforeStepAdvance

Called before the SDK advances to the next step. Return a Future<Map<String, dynamic>> describing what happens next (there is no StepAdvanceResult Dart class):

Advance result map

Use stay for “I handled it; keep the user here”
Return a stay map when your hook handled the user’s action — for example, sent a password-reset email — and you want the user to remain on the same step. This is distinct from block (error styling) and from proceed (advances).

onBeforeStepRender

Called before a step is rendered. Return a Future<Map<String, dynamic>?> config-override map to dynamically modify the step’s content, or null for no override (there is no StepConfigOverride Dart class):
Recognized keys in the override map: fieldDefaults, title, subtitle, ctaText, fieldOptions, dataContext (see Host-supplied data), mapRoutes. The SDK merges these into the step config before rendering. fieldOptions is { blockId: [option, …] } — supply a Select’s options from your own backend, with your own auth and cache. A block you do not name keeps its authored options.
layoutOverrides was removed. It was accepted by every SDK and read by none, so setting it never did anything; fieldOptions gives the real use case a typed home. Nothing can break, because nothing ever worked.
The SDK waits up to 3 seconds for this callback and then renders without the override, falling back to cached and then authored options. Do your own network work with your own timeout — a reply that arrives after the user has left the step is discarded rather than applied to a screen they are no longer looking at.
Both hooks are async — the SDK shows a loading indicator while waiting for your response. If either hook throws an error or times out, the SDK proceeds normally — except for a sign-in action in onBeforeStepAdvance, which blocks with “Sign-in isn’t available right now. Please try again later.” (see Authentication entry). The bridge waits up to AppDNAOptions.vetoTimeout (default 5 s) for each hook; onBeforeStepRender is still cut off at 3 s by the SDK, a refresh gets 8 s (a larger vetoTimeout does not extend it), and sign-in actions wait at least 120 s.

Row Direction and Distribution

The row content block supports configurable direction and distribution, set in the Console: 🔴 row_distribution has no visible effect while row_child_fill is true, which is the default. Filling children consume the whole row, so there is no free space left to distribute — iOS skips the distribution outright in that case and on Android the filled children absorb it. To use space_between and friends, set row_child_fill: false first. column_ratios is read from field_config before the top level, and each number is a flex weight: 1:2 gives the second child twice the width of the first. Children map to ratios in order, and any child beyond the last ratio gets an equal share.

Heading level and image alt text

Two more keys that apply outside the row block: 🔴 The keys are row_-prefixed. Earlier revisions of this page named direction and distribution, which exist in neither SDK — a hyphenated space-between is accepted, but a bare direction is not read at all.

Button Gradients

Buttons support gradient backgrounds configured in the Console: Gradients override the solid background_color when set.

Select Display Styles

input_select form-input blocks (and select form fields) support these display styles: The display style is configured per select field in the Console under field_config.display_style. Question-step option lists always render in a fixed grid layout; the styles above apply specifically to input_select / select.

In-step visibility

A block’s visibility_condition is evaluated against the step’s LIVE answers as well as the answers of completed steps, so a condition naming a field on the SAME step reacts while the step is on screen. Two variable forms reach a field on the current step: A live value wins over the value the same step saved on an earlier visit, and a field the user has not touched this visit still answers from what was saved — so a step re-entered by back navigation filters correctly on its first frame. This is how a category row filters the blocks beneath it: the row is an ordinary single-select authored display_style: "bubble_scroll", and each block below it carries a when_equals condition naming the row’s field_id. Nothing else is required, and no element has a filter of its own. when_not_equals and when_empty are the two operators that can take a block OFF the screen once a field holds a value; the rest only ever reveal one.

Image Tile Layouts

With display_style: "image_tiles", field_config.tile_image_layout controls where the label sits relative to the photo. Text over a photograph is only readable when the photo cooperates, so the other two layouts give the text a surface of its own. An option’s image_overlay_color tints the photo only — in image_strip and contained it stops at the text surface, so a dark scrim for legibility does not also mute the band color.

Dynamic option lists (Option Sets)

A Select can take its options from an Option Set — a named list managed outside the flow — instead of a list authored inline. Editing the set reaches devices without republishing the flows that use it, which is the point: a customer who adds an item should not need anyone to touch a flow. The list is never fetched in front of the user. The SDK resolves what to show synchronously, in this order:
  1. a cached copy of the set
  2. the embedded page — the first N items, published inside the flow config
  3. the options authored on the block
  4. nothing, and the step is skipped
Only a genuinely cold start reaches step 4, and only then is a loading state shown. A refresh runs after the first frame; if it fails the list already on screen stays.
field_options is still used when a set is bound — it is the embedded page. An app built before this feature renders those items rather than an empty list, so a set is safe to introduce without waiting for everyone to update.

The selected item

After a choice, {{selected.<field_id>.…}} resolves the option rather than just its value, so a later screen can say “You chose Sunrise Estate — Lakeside” without the author repeating the list: The value your integrations already receive is unchanged. The option payload is deliberately kept out of responses, so webhook bodies are byte-identical for anyone not using this.

Template Variables

Any text an author writes can reference an answer the user has already given: Variables resolve in every text field of every onboarding block — text, labels, placeholders, an option’s label and subtitle, image and media URLs, list items and the text inside a block’s settings. (An option’s stored value can come from data only on a Select whose options follow a host list — see Host-supplied data; a fixed option’s value stays fixed.) A reference that cannot resolve and has no fallback renders as empty: an option whose label cannot resolve is hidden, and a summary stat is hidden, because a raw token in a large numeral is more jarring than an absent card. Publishing warns about references nothing supplies. SDKs before iOS 1.0.82 / Android 1.0.54 resolve variables only in a block’s text, label, placeholder, badge and toggle label, an option’s label, subtitle and leading text, and a summary stat’s value and label — and show an unresolved reference there as the literal text the author typed.

Host-supplied data

Some screens show values only your backend knows — a recommendation, a live price, a booking slot. Your app supplies them by returning a dataContext from onBeforeStepRender, and whatever is in that dictionary becomes addressable in the Console as {{hook_data.…}}.
An author then writes {{hook_data.booking.unitPrice}} in any slot that resolves variables, or binds one of a block’s properties to a path in the block’s Dynamic Data section (stored as the block’s bindings, for example {"image_url": "hook_data.recommendations.0.imageUrl"}) to drive a value that is not a text slot — an image or video URL, a colour, a number. Paths walk dictionaries and index lists, so hook_data.recommendations.0.imageUrl resolves against a list. Two things worth knowing:
  • The SDK makes no network call for this. It hands you the step and takes back a dictionary; you fetch with your own client, your own auth and your own caching.
  • Both sides have to line up. A value you supply is only read if an author has referenced that path in the Console, and a path an author references renders empty if nothing supplies it. The Implementation Manual we generate for a flow lists both halves.

Images and other settings from your data

Text fields take a {{hook_data.…}} token directly. An image field — an Image block, a Select option’s image, a Map block’s photo or marker — has a From host data mode in the Console instead: the author picks a path and keeps the uploaded image as the fallback. The Console stores that as a data_templates entry beside the static value:
A current SDK uses your value when it resolves to an absolute https URL and the fallback otherwise; an app on an older SDK never reads data_templates and shows the fallback. That is what makes a flow safe to publish while installed apps are still on older versions.

Lists that follow your data

A Select can build its options from a list you return. In the Console, turn on Options from host data on the Select, choose the list path and fill in the template option — its label, subtitle, image and stored value each come from the list item ({{item.name}}, {{item.imageUrl}}, {{item.id}}), and its static text stays as the fallback older SDKs show.
  • One option is generated per list item, up to the limit the author sets (20 by default, at most 50). Fixed options the author added are listed after the generated ones.
  • The stored answer is the item’s value ({{item.id}} above), so responses records the venue the user picked — not a slot name. Two items with the same value show once.
  • An empty or missing list shows the author’s empty-state text, or hides the Select if they chose that. A required Select with nothing to pick never blocks the user.
  • Navigation rules cannot compare a generated answer to a fixed value — the values are whatever your app sends. Branch on “answered”, or on a fixed option.

While your data loads

The step appears immediately — nothing waits to paint. When you have set a delegate and the step references hook_data, a Select whose options depend on your data shows no generated options until your reply arrives, for at most 3 seconds, and cannot be submitted meanwhile (pressing Continue says “Loading…”); fixed options and everything else render normally. If your reply is later than 3 seconds, the Select settles without it (its empty state, if the author set one) and the late reply is ignored.

”Show more”: refresh a list in place

iOS SDK 1.0.82+ · Android SDK 1.0.54+ · Flutter and React Native: the release whose appdna_feature_parity is 1.0.82. Read Older app builds before re-authoring a live button.
A Select that builds its options from your list (above) can offer a Show more button that loads the next page without leaving the step.
  1. In the Console, add a Button and set its action to Refresh this step. Optionally fill Value sent to your app (e.g. recs_more): it reaches your handler as value, exactly as typed. Set the Select’s Max items to the longest list you will ever return (up to 50).
  2. In your app, handle onElementInteraction where action == "refresh", and return the list in dataContext, under the same key the Select repeats over, with advance: false:
What happens on the device:
  • The tapped button shows a spinner (its label hidden, its size and colours kept) until your reply lands. While any interaction on the step is in flight, the step’s other Refresh this step buttons cannot be tapped; everything else stays usable — the user can keep picking, searching, or tap Next. While the step’s first onBeforeStepRender reply is still loading, Refresh this step buttons cannot be tapped either: there is no first page yet to show more of.
  • dataContext merges into the step’s hook_data per top-level key: a key you return replaces that key’s value whole, and keys you do not return keep their value. To remove a key, set it to Dart null — e.g. {'banner': null}. Keys are names, not paths — "a.b" is a key called a.b; to change a nested value, return its whole top-level value.
  • Everything that reads the key re-resolves: the Select’s generated options, {{hook_data.…}} text, images. dataContext is honoured on the reply to every interaction (OTP, calendar, press-and-hold…), not only refresh. If the same reply also carries fieldOptions for the Select, those win over the list.
  • null changes nothing and the button works again; no toast is shown — show your own UI if a fetch fails. A delegate that does not handle refresh answers null after one bridge round trip — the spinner shows for that moment and then ends.
  • The SDK waits at most 8 seconds for a refresh reply. After that the spinner ends, nothing changes, and a later reply is ignored. The plugin’s bridge normally waits at most AppDNAOptions.vetoTimeout (default 5 seconds; honoured on both iOS and Android from Flutter 1.0.20) for any delegate callback — but a refresh reply gets 8 seconds: the bridge waits that long even when vetoTimeout is shorter, and the SDK drops a reply after 8 s even when vetoTimeout is larger. Every other interaction keeps the vetoTimeout limit.
  • A reply that arrives after the user left the step (Next or Back) is dropped whole, advance included.
  • With advance: true the reply is applied first, then the step advances through the required-field check. If the user’s pick vanished from the list, the step stays, showing the new list and the validation message.
  • The data lives in memory for this flow presentation only: it is never persisted, sent to our backend, written to the responses or tracked. Keep one reply under ~256 KB.
  • A Refresh this step button inside an option’s sheet, or on a Sound button (whose tap plays its audio), never fires. Publishing warns about both.
Accumulate — return the whole list so far. The SDK replaces the key; it never appends. Return page 1 + page 2 + … under the same key, as above: a selection that is still in the list stays selected, even at a different position. A host that returns only the new page still works, but a pick from an earlier page is no longer rendered and is cleared, and a required Select then blocks until the user picks again. max must cover the longest list. Items past the Select’s Max items (default 20, at most 50) are silently not rendered; a debug log names the block, the list’s length and max. On a revisit, return the list the user last saw. When the user goes Back and returns, onBeforeStepRender runs again, and its reply wins over earlier Show more pages for the keys it sets. Keep your paging state per step and answer with the accumulated list — otherwise the list collapses to page 1 and a pick from a later page is cleared. (On iOS the same re-run also happens when a paywall shown from the flow is dismissed.)

Older app builds

A newly published flow can reach an app build with an older SDK. What a Refresh this step button does there: Ship the app build with this SDK and a refresh handler first; re-author the button after.

Behaviour changes in this release

These apply to every flow, including flows that never use Show more:
  1. An interaction reply’s inputValuePatches merge key by key onto the current answers. Before, the whole snapshot from the moment of the tap was written back, which reverted anything the user changed while the call was in flight (OTP, calendar, press-and-hold…).
  2. A late interaction reply is dropped once the user has left the step or started a new visit to it. Before, iOS always applied it — and advance: true could complete a step the user had already left — and Android applied it during the ≤ 250 ms exit animation. A slow reply no longer blocks interactions on the next step.
  3. Android catches an exception thrown by onElementInteraction, logs it and treats it as null, instead of crashing.
  4. On Android, Refresh this step buttons inside a Row, a Stack, a Section background or a Carousel page now fire; before, they silently did nothing. On iOS, Carousel pages change the same way.
  5. Flows already using Refresh this step (since iOS 1.0.79 / Android 1.0.51) now get the 8-second deadline, the two tap locks above, the spinner and — on Flutter and React Native — the 8-second minimum bridge wait (before, the bridge’s 5-second timeout: fixed on Flutter, the vetoTimeout default on React Native).
  6. A tap on the outgoing step while it slides away is refused. Before, it reached your app carrying the incoming step’s id.
  7. fieldConfigPatches / fieldOptions applied by any interaction are dropped when the same step view is reused for a new visit inside the exit animation (a very quick Back-and-forward). A normal Back then forward already dropped them.

Host data contract

Paste a sample of your dataContext into Flow settings › Host data contract in the Console. It fills the variable picker with your real paths, drives the editor preview (a repeat Select shows one option per sample item) and lets publishing warn about paths your app does not send. It is sample data for the Console only — it is never sent to devices; do not paste real personal data. Supported on: {{hook_data.…}} in the original text slots — iOS SDK 1.0.76+ · Android SDK 1.0.48+ · Flutter SDK 1.0.14+ · React Native SDK 1.0.13+. Every other field, image data templates and lists — iOS SDK 1.0.82+ · Android SDK 1.0.54+ · Flutter and React Native: the release whose appdna_feature_parity is 1.0.82.

Progress Bar Custom Colors

The progress_bar content block supports custom color configuration: Colors are configured per block in the Console. When not set, the SDK uses the app’s primary theme color.

Per-Step Progress Visibility

The progress indicator can be hidden on specific steps while still counting them in the total progress. This is useful for splash screens, permission prompts, or transition steps where the progress bar would be distracting. Configure hide_progress per step in the Console under Step Design > Logic. When enabled, the progress bar is hidden on that step but the step still contributes to the overall progress calculation (e.g., step 3 of 5 still advances progress to 60%).
This is a per-step override of the flow-level show_progress setting. If show_progress is false at the flow level, the progress bar is hidden on all steps regardless of hide_progress.

Progress indicator style

The flow-level progress_style chooses what the indicator looks like. progress_color and progress_track_color colour it. show_progress: false hides the indicator whatever the style, and a step’s own hide_progress hides it for that step alone (above).

Form validation timing

A form step’s validation_mode decides when a field’s error appears.

What a dismissed paywall does to the flow

A paywall_trigger node’s on_dismiss decides where the flow goes when the user closes the paywall without buying. on_dismiss_target overrides all three with a named step.

Chat step turn actions

A chat step can act at a given turn. turn is 1–20 and type is one of:

The rating block

🔴 The star colours have a second rung, and it differs per platform. With filled_color unset, Android falls back to active_rating_color, then active_color; iOS falls back to field_style.fill_color. Neither alias is written by the Console, so the only documents affected are hand-written or imported ones — but a document carrying active_color tints the stars on Android and not on iOS. Set filled_color and empty_color and the platforms agree. 🔴 max_stars is clamped to a minimum of 1, deliberately: the renderer iterates 1...maxStars, and a 0 or negative value would be a range the platform refuses to build rather than an empty row. So authoring 0 gives one star, not none — to hide the control, do not add the block. As an input field (input_rating) rather than a display block, field_config.max_stars is read before the top-level max_stars, and the same clamp applies to it.

The pulsing_avatar block

An avatar with expanding rings behind it — an attention cue, usually for a coach or assistant. 🔴 pulse_ring_count is clamped to a minimum of 1, for the same reason max_stars is: the renderers iterate the ring index and stagger each ring by pulse_speed / ringCount, so 0 would be an empty range on one platform and a division by zero on the other. Authoring 0 gives one ring, not none — to remove the effect, do not use this block.

The circular_gauge block

A dial showing one value in a range. Four variants, and only the needle ones use the arrow fields. 🔴 min_label and max_label default to the numbers, not to nothing. A gauge from 0 to 100 shows “0” and “100” at its ends unless you give it words — so these are the fields for “Low”/“High” or “£0”/“£10k”, and setting one without the other leaves the number showing on the other end. animate defaults to on, unlike most optional behaviour in this SDK — a gauge sweeps unless you turn it off.

Styling a wheel or date picker

These sit at the top level of the block, not inside field_config — which is the trap, because most input styling does live in field_config. 🔴 The stroke width defaults from the colour. Set wheel_line_color alone and you get a 1pt line; set neither and there is no line at all. So a colour is sufficient to draw the divider, and a width without a colour draws nothing. wheel_orientation is the Console’s key and orientation the SDK’s older one; the prefixed key wins. Likewise wheel_line_* wins over the legacy field_config.picker_border_* pair, which iOS previously read instead, so authored line styling never appeared. Related picker fields: picker_presentation (inline · field), picker_mode (date · datetime · time), time_format (12h default · 24h), time_text_size, and picker_spacing for the gap between the time wheel and the calendar in datetime mode.

The star_background block

An ambient particle layer behind a step’s content. 🔴 particle_multicolor defaults differently depending on particle_type. Confetti cycles a fixed palette per particle because monochrome confetti does not read as confetti; everything else uses the single particle_color. So the field is only worth setting to turn confetti’s palette off, or to turn a palette on for stars. particle_speed is the Console’s spelling and speed is the SDK’s older one; the prefixed key wins and both are read, so a document carrying either works.

The divider block

A social-login block’s separator

🔴 show_divider, divider_text and divider_position belong to the social_login block, not to the divider above — they draw the “or” rule between the social buttons and whatever follows. divider_color is shared with the divider block and tints both. A separator with show_divider and no divider_text is a bare rule; add the text and it is drawn as a label breaking the rule, which is the ”──── or ────” pattern.

The pricing_card block

A plan chooser inside an onboarding step — distinct from a paywall’s plans section, and it takes no store products: the prices are strings you author. Each entry in pricing_plans takes: 🔴 This block does not talk to the store. price and period are strings, so they do not update with StoreKit or Play pricing and will not localise themselves. Use it to ask which plan someone wants during onboarding; use a paywall’s plans section when the number has to be the real charged price. The chosen id is recorded as the step’s answer, so a later next_step_rules condition can branch on it with answer_equals.

The timeline block

A vertical sequence of steps — a trial-to-paid explainer, an onboarding roadmap. Each entry in timeline_items takes title, subtitle, icon and a status of completed, current or upcoming — which is what selects between the three colours above. An item with no status is drawn as upcoming. 🔴 show_line defaults to true, so the connector is there unless you remove it — and line_color tints the connector while the three status colours tint the dots. They are four separate colours and it is easy to set the wrong one.

The progress_bar block

A progress bar inside a step’s content — distinct from the flow-level indicator at the top of the screen, which progress_style configures. label_format takes fraction (3/7 on a segmented bar, 40/100 on a continuous one), custom (your custom_label verbatim), or anything else for the default “Step 3 of 7” phrasing. 🔴 Like the page indicator, total_segments and filled_segments default to the real flow, so a bar with no configuration tracks the user. Authoring them freezes it. bar_height is capped at 24 rather than refused, so a larger value is quietly reduced.

The page_indicator block

A row of dots showing progress through the flow. It defaults to tracking the flow itself, so a block with no configuration at all works. 🔴 dot_count and active_index default to the real flow, which is the point: drop the block in and it tracks where the user is. Author them only for a decorative indicator that is not following the flow — a mock-up on a welcome screen, say — because once set they are fixed and will not move as the user advances. dot_count is clamped to 50. A larger value is reduced rather than refused, so an indicator cannot be made to iterate unboundedly.

The input_toggle block

Its colours live on field_style: 🔴 Unlike a paywall toggle, this one has a real OFF colour. The block draws its own capsule rather than using the platform switch, precisely so all three colours apply — a native switch exposes only the on-track tint. Accessibility is preserved: it still reports itself as a switch to the screen reader. field_label wins over toggle_label when both are set, so prefer field_label for consistency with every other input block.

Icons on a block

Three different ways to put an icon on a block, and they are not interchangeable. icon_ref is the portable form — the SDK maps the name to the platform’s own equivalent, so { library: "sf-symbols", name: "star.fill" } draws an SF Symbol on iOS and the closest Material icon on Android, falling back to an emoji when the name is unmapped. The other three are literal.

A social-login button’s icon

icon_style applies to social_login buttons only:

An orbiting-icons loading item

Each entry in an animated_loading block’s loading_items takes its own icon fields when the variant is orbiting_icons: icon_url, icon_bg_color, icon_size (diameter, default 48) and icon_orbit_angle (0–360). Omit the angle and the icons distribute evenly — set it on one and you are positioning that icon by hand, so set it on all of them or none.

Per-option overrides

Every option in a Select carries its own overrides, so one option can look different from the rest without restyling the block. All are optional, and each falls back to the block’s own setting — which is the point: set none and the block styles every option alike.

The tooltip caption

A select block can carry a caption under its options: 🔴 It is drawn for display_style: "grid" only. The Console offers the control in the grid section, and both renderers scope it the same way — so the same tooltip_text on a dropdown, stacked, list, bubble or image_tiles select draws nothing on either platform. That is deliberate parity, not an oversight: Android used to draw it for every style and iOS never did. The caption renders at 12pt in the field’s text colour at 50% opacity, with the icon leading it.

The selected state

A separate set applies only while the option is picked, and each falls back to its unselected counterpart when unset: 🔴 An option with NO category shows under every chip, deliberately — so adding filter chips to a Select that already had options never hides one the author had. selected_image_url and unselected_image_url are a pair: the renderer swaps the image by state and falls back to image_url whenever the side it wants is unset, so setting only one of them is valid and means “change the image in that state only”.

wheel_picker configuration

A drum of numeric values. 🔴 Two keys have both an editor spelling and an SDK spelling, and the SDK reads both. The Console writes max_value and default_value; the canonical names are max_value_picker and default_picker_value. The SDK tries the canonical one first and falls back to the editor’s, so an authored range is not lost either way — but if you hand-write a document, prefer the canonical names, and do not set both to different values expecting the editor’s to win.

date_wheel_picker configuration

The four constraints are checked independently and each has its own default message, so leaving date_validation_message unset gives the user a specific reason rather than a generic one: All four are identical on both platforms. Setting date_validation_message replaces all four with your single string, trading the per-rule specificity for wording you control.

animated_loading configuration

A simulated progress surface — the “setting up your plan” moment. It is a presentation, not a real task: loading_items drives it and each item declares how long it takes. For orbiting_icons, each entry in loading_items takes icon_url, icon_bg_color, icon_size (diameter, default 48) and icon_orbit_angle (0–360; omit it and the icons distribute evenly). 🔴 total_duration_ms is ignored whenever loading_items has entries, on both platforms. With items, each one runs for its own duration_ms (default 1000) and the overall progress is their sum; total_duration_ms only drives the bar in the no-items case, where there is nothing else to time it by. So to make a checklist faster, change the items’ durations — raising total_duration_ms does nothing. A horizontal row of image tiles. gallery_images is the list of URLs; everything else shapes how they are drawn, and the defaults are identical on both platforms. 🔴 The header is part of the gallery, not a block above it — it follows gallery_align, so the two cannot drift apart. And it obeys the same empty rule as the strip: a gallery with no images draws nothing at all, header included, rather than leaving a title stranded over empty space. gallery_fill ignores gallery_item_width: the tiles take the container’s width and crop to cover, which is what makes a single-image gallery read as a hero. With gallery_autoscroll on, the row loops continuously and the user can still drag it. 🔴 gallery_autoscroll_speed is a duration, so a larger number is slower. 20 means one full pass of the whole row every twenty seconds, whatever the number of images — add tiles and each one moves faster unless you raise it.

Authorable value reference

Every value below is one an author can set in the Console and the SDK reads off the published document. They are listed by the setting that takes them; an unset setting falls back to the renderer’s default, which is the first value unless noted.

Text and alignment

autocapitalize maps to the platform keyboard’s own setting, so words capitalises each word and characters types in caps throughout.

Images

contain fits the whole image inside the frame and may leave space; cover fills the frame and may crop; fill stretches and may distort. fit is contain under another name on the one setting that spells it that way.

Selection and input

paged snaps one page at a time; auto scrolls freely. both on an image picker offers the library and the camera.

An option’s badge

A badge sits on top of an option rather than in its layout flow, so it takes a corner: position — top_leading · top_trailing · bottom_leading · bottom_trailing · leading · trailing

Backgrounds

transparent draws nothing, so whatever is behind the surface shows through — distinct from a color background set to a transparent hex, which some renderers composite differently.

Chat steps

hard stops the conversation at max_user_turns; soft lets it run on. text draws a tappable label with no button chrome.

Combining routing conditions

A step’s next_step_rules entry may carry several conditions, and logic says how they combine: and requires every condition, or requires any one. With a single condition it has no effect.

Conditional Branching

Onboarding flows support conditional branching — the next step can change based on the user’s answer. Branching is configured entirely in the Console. Example:
To configure branching:
  1. In the Console, open your onboarding flow (Onboarding > Flows).
  2. On a question step, click Add branching rule.
  3. Map each answer option to a target step.
  4. The SDK handles routing automatically — no Dart code needed.
All answers (including branched paths) are returned in the responses map in onOnboardingCompleted.

Auto-Tracked Events

The native SDK automatically tracks the following onboarding-related events: Each event includes the flowId, stepId, and stepIndex where applicable.

Configuration in Console

Onboarding flows are managed in the AppDNA Console:
  1. Navigate to Onboarding > Flows.
  2. Create a new flow or edit an existing one.
  3. Add steps (welcome, question, value_prop, form, interactive_chat, custom) and configure their content.
  4. Set targeting rules to control which users see the flow.
  5. Publish the flow to make it available to the SDK via the config bundle.
Flows must be published in the Console before they appear in the SDK. Draft flows are not delivered to client devices.

Full Example

Paywall trigger nodes — entitlement-aware skip + restore behavior

Onboarding flows can include paywall_trigger graph nodes that present a paywall in the middle of a flow and route the user via per-outcome targets (on_success_target, on_fail_target, on_dismiss_target). Triggers are configured in the AppDNA Console and the SDK presents them automatically when the flow reaches the node.

Entitlement-aware skip (default ON)

Each paywall_trigger node carries a skip_if_subscribed flag (default true). When the native SDK reaches the trigger, it checks the user’s entitlement state first:
  • User has an active subscription → SDK skips presentation, emits an onboarding_paywall_skip event with reason=user_already_subscribed, and routes the flow via the configured skip target (see below).
  • User has no active subscription → SDK presents the paywall as before; outcome routing is unchanged.
This stops returning users from seeing a paywall they already paid for. Already-subscribed users move past the trigger silently. If you sell upsell paywalls (a higher tier to a user who already owns the base plan), open the trigger in the onboarding flow editor and uncheck Skip if user already has an active subscription. The trigger then always presents.
Existing flows authored before this version require no migration: nodes without the field default to true at read time. From Flutter you can check the same entitlement state used by the gate via:

Subscribed-user skip routing — picking the target

The console editor exposes a dedicated dropdown directly under the Skip if user already has an active subscription toggle: When skipped (user is already subscribed), go to. Four options: The native SDKs resolve the target with this chain at runtime: on_subscribed_skip_target → falls back to on_success_target → falls back to “follow downstream edge”. The fallback means flows authored without an explicit skip target that worked around this by setting on_success_target = complete_flow continue to behave identically.

Warning callout in the editor

If you open a paywall_trigger that has Skip if user already has an active subscription on but the resolved skip target is “Follow downstream edge” and the next node in the graph is another paywall_trigger, the editor displays an inline warning:
Heads up: this paywall is set to skip for subscribed users, but the next step is another paywall trigger — subscribed users will still see it. Set “When skipped” to “Complete flow” to bypass the rest of the paywall chain.
One click on Complete flow in the dropdown clears the warning and fixes the chain. This is the most common authoring trap with multi-paywall flows (main paywall → winback paywall → end); the warning surfaces it before publish.

Restore now auto-dismisses + routes to success

Tapping Restore Subscription on a paywall now:
  1. Fires onPaywallRestoreCompleted(paywallId, restoredProductIds) on your AppDNAPaywallDelegate.
  2. Auto-dismisses the native paywall surface when restoredProductIds is non-empty (i.e., the restore actually found entitlements).
  3. Routes the onboarding flow via the trigger’s on_success_target — same path as a real purchase.
When the restore call succeeds but finds NO entitlements (restoredProductIds: []) the paywall stays up so the user can attempt a fresh purchase or close manually.

Hosts that handle their own dismiss

Flutter has no host-callable paywall dismiss: the SDK owns the paywall’s lifecycle. A successful restore that finds products auto-dismisses it. To keep the paywall up instead, call AppDNA.skipNextAutoDismissOnRestore(true) before the user taps Restore — for example when you present the paywall. The paywall then stays on screen until the user closes it. Do not plan a Dart overlay (such as a “Restored — tap continue” message) for that moment: Flutter widgets render under the native paywall, so the user would not see it. onPaywallRestoreCompleted reaches Dart asynchronously, after the native SDK has already decided whether to dismiss, so setting the flag inside that callback is too late — and the flag would then carry over to the next restore. onPaywallDismissed fires exactly once.

AppDNA.identify(userId) refreshes the entitlement cache

When you call AppDNA.identify(...) after the user signs in, the native SDK silently refreshes its entitlement cache in the background. The next paywall_trigger gate then reflects the identified user’s StoreKit / Play Billing subscriptions instead of the prior anonymous user’s empty entitlement state.
If your app completes auth out-of-band (SSO callback, OAuth web flow) and you need the entitlement cache refreshed manually, call AppDNA.identify(...) again with the same user ID — the cache refresh is idempotent.

Migration risk callout

Behavioral change: paywall_trigger nodes now skip presentation for subscribed users by default. If your app intentionally relies on the legacy “always present” behavior (e.g., a confirmation / legal-acceptance screen rendered as a paywall), open the trigger node in the console editor and uncheck Skip if user already has an active subscription.
Web-side entitlements (Stripe subscriptions, manually-granted entitlements via web tooling) are NOT consulted by this gate today — only StoreKit (iOS) and Play Billing (Android) entitlements are. If your premium users have only web-side subscriptions, leave Skip if user already has an active subscription unchecked, or implement a host-side gate via AppDNAOnboardingDelegate.onBeforeStepRender that consults your own backend.

Measurement mode (wheel_picker)

wheel_picker supports an optional measurement mode for capturing a weight, height, temperature, or any custom quantity with a unit toggle. Enable it in the console by setting a measurement type (Weight, Height, Temperature, or Custom) on the wheel picker. When set, the picker renders with:
  • a unit toggle (e.g. kg ⇄ lbs, cm ⇄ in, °C ⇄ °F) that converts the value live, and
  • a measurement visual style — ruler (tick tape), gauge (radial dial), dial (rotary drum), or wheel (classic drum).
Each unit defines its own range and step; toggling converts the current value and re-clamps it to the new unit’s range.

Reported value

The SDK stores the value in the base unit (the first unit you define) so it stays stable when the user flips units. Alongside it, the SDK reports the chosen unit and the displayed value: The element-interaction callback receives { value, display_value, unit }, where value is the base-unit value. When no measurement type is set, wheel_picker behaves exactly as before.

Step-level keys

Set on a step, beside its content blocks:

Option keys

On an option inside a choice or select block:

Flow settings

On the flow’s settings, not on a step:

Compact date picker height

A date, time or datetime block drawn as the compact pill — picker_variant: "compact", or picker_presentation: "field" — takes an authored height. The wheel and calendar variants size themselves and ignore it. 🔴 A number beats the token, because input_height is the more specific of the two. A number outside 20–200 is ignored rather than drawn — a pill cannot usefully be 2 points or 2000, and a value like that is a mistake rather than an instruction.

Input font and the error border

An input’s own text size and weight, and the border it shows while the field is in error: 🔴 font_size is the first rung of a ladder, not a fourth control. The Console’s “Font Size” and “Input Text Size” sliders write field_config.font_size and field_config.input_text_size; both are honoured, and field_style.font_size wins over both when a document carries it. Three spellings of one value exist for backwards compatibility — set whichever your tooling already uses. 🔴 The error border draws only while the field IS in error, and it is drawn at 1pt when no ordinary border_width was authored, so the colour cannot be chosen and then be invisible. The inline error MESSAGE takes field_style.error_text_color independently, so the two can differ.
A date field’s validation failure is the error state the SDKs track per field. A required field left empty raises the step’s validation prompt rather than marking the field, so error_border_color does not apply to it.