Skip to content
HotLayout Docs

Quickstart for Flutter

This page is for a Flutter developer. It takes you from flutter create to a screen that a manager published in HotLayout and that your app draws. Plan for half an hour.

The Dart code blocks are fragments of the package's example/ app, which is compiled and tested with the package. A block that is not from the example says so.

Related pages: index · delivery · data dictionary · theme · localization · compatibility · widgets · quickstart for React

What you are integrating#

Piece What it is
Document One JSON file: a set of screens built from widgets. Published by the builder as published.json.
Theme One JSON file: the token names the project has (primary, md, titleM, and names of your own such as brandCoral) and the values behind them.
Data A map your app supplies: values for the paths the document is bound to (task.title, user.points).
Renderer The hotlayout_flutter package: Dart code that turns the three into Flutter widgets.

Package 0.19 reads document contract 9.19.0 (kSupportedSchemaVersion) and theme contract 4.0.1 (kSupportedThemeContractVersion), and has icon set 1.0.0 built in (kIconSetVersion). The major version must match: a 9.x renderer draws any 9.x document and no 8.x one. See step 10.

The steps:

  1. Add the dependency
  2. Bundle a fallback copy
  3. Load the theme and name the document source
  4. Load the document
  5. Draw the document
  6. Supply data
  7. Handle actions
  8. Accept what the user wrote
  9. Pick the language
  10. Versions: what a user sees

1. Add the dependency#

flutter pub add hotlayout_flutter

or in pubspec.yaml:

dependencies:
  hotlayout_flutter: ^0.19.0

The package is on pub.dev; the source is at github.com/hotlayout/hotlayout-flutter. It requires Dart SDK ^3.11.5 and brings http, intl, flutter_svg and vector_graphics_compiler.

There is one import, and nothing under src/ is public:

import 'package:hotlayout_flutter/hotlayout_flutter.dart';

The repository contains an example/ app: this page, complete, in three files. lib/main.dart there is the host; you can copy it into a new app as it is.

Icons. They are drawn from the icon set of the contract (332 names), built into the package — the same drawings the builder shows, not Material Icons. A name the renderer does not have leaves an empty box of the same size. The set adds about 65 KB to your app. The drawings are from Lucide (ISC License; some derive from Feather, MIT License): the notices are in THIRD-PARTY-NOTICES.md of the package — include them in your app's licences screen.

2. Bundle a fallback copy#

The renderer never shows an empty screen because the network failed. It draws a copy from your app bundle first and replaces it when the network answers. You provide that copy.

What to ask for#

A developer often has no access to the admin. If someone else built the screen, ask them for these four things — the rest of this page needs nothing else:

What Where it is in the admin
The address of the published document — or three ids: organization, project, document The list of documents, Copy address for the app on the row of a published document. The document id stands under the document's name in the same list.
The address of the published theme Project settings → Delivery → Address of the published theme. With the organization and project ids you can write it yourself — see step 3.
The data dictionary, data-contract.json Project settings → Data dictionary → Open the dictionary → Download data-contract.json. It lists the values and lists your app supplies.
A copy of the document and of the theme to bundle with the app Nothing to export: open the two addresses and save what they return.

To see which values one particular document reads, run the short script in What a document reads over its file.

The two files#

Put two files into your app:

  • a copy of the document you expect to show, and
  • a copy of the theme.

For a screen that is already published there is nothing to export: the two published addresses return exactly these files — open them and save the answers. To try the renderer before you have a project, take the two files of the example — example/assets/hotlayout/document.json and theme.json (the rewards-showcase fixture of the contract and its theme) — together with example/lib/backend.dart, the data that document is bound to.

# pubspec.yaml of your app
flutter:
  assets:
    - assets/hotlayout/document.json
    - assets/hotlayout/theme.json
const documentAsset = 'assets/hotlayout/document.json';
const themeAsset = 'assets/hotlayout/theme.json';

Both loaders read from rootBundle at the path you pass, so the files must be declared in your app. Always pass assetPath: the default points at a file of the package's own demo.

3. Load the theme and name the document source#

Read the theme before the first frame. Replacing colors after the screen is drawn is a visible jump; the loader has its own three-second timeout (kThemeFetchTimeout) and never throws.

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final transport = HttpDocumentTransport();

  // The theme is read BEFORE the first frame: replacing colors after the
  // screen is drawn is a visible jump. Neither call throws.
  final themeLoad = themeUrl.isEmpty
      ? await ThemeLoad.fromBundle(assetPath: themeAsset)
      : await ThemeLoad.fromNetwork(
          Uri.parse(themeUrl),
          transport: transport,
          assetPath: themeAsset,
        );
  if (themeLoad.isFallback) {
    debugPrint('theme fallback: ${themeLoad.status.name} ${themeLoad.detail ?? ''}');
  }

  const bundled = BundledDocumentSource(title: 'Bundled copy', assetPath: documentAsset);
  final DocumentSource source = documentUrl.isEmpty
      ? bundled
      : PublishedDocumentSource(
          url: Uri.parse(documentUrl),
          fallback: bundled,
          transport: transport,
        );

With empty addresses the app shows the bundled copies — enough to see your first screen. To read the published files, run with the two addresses (flutter run --dart-define=documentUrl=https://… --dart-define=themeUrl=https://…). The addresses of a published document and theme:

https://firebasestorage.googleapis.com/v0/b/ui-builder-dd4af.firebasestorage.app/o/orgs%2F<orgId>%2Fprojects%2F<projectId>%2Fdocuments%2F<documentId>%2Fpublished.json?alt=media
https://firebasestorage.googleapis.com/v0/b/ui-builder-dd4af.firebasestorage.app/o/orgs%2F<orgId>%2Fprojects%2F<projectId>%2Ftheme%2Fpublished.json?alt=media

The host and the bucket are the same for every project; replace <orgId>, <projectId> and <documentId> with your three ids and keep everything else: %2F is an encoded /, and without ?alt=media the address returns a description of the file instead of the file. The object path is orgs/{orgId}/projects/{projectId}/documents/{documentId}/published.json. Both files are readable without a token. The ready addresses are also in the admin: Copy address for the app in the list of documents, and Project settings → Delivery.

Before the first run with addresses on macOS or in an Android release build: the app needs a network permission that flutter create does not give, and without it nothing fails — every request becomes unreachable and the bundled copies are shown. Add it now: Platform notes.

This is the direct address. In production most teams serve the document from their own backend, which receives every publication by webhook; the app then reads an address of yours, and nothing else on this page changes.

ThemeLoad.status is ok when the theme you asked for is in use; any other value (assetMissing, malformed, networkUnavailable, incompatibleVersion) means a fallback. The fallback order and the meaning of each value are on the theme page.

4. Load the document#

Load in two steps: the bundled copy at once, the network after.

  Future<void> _read() async {
    // 1. Draw the bundled copy at once: no loading state.
    final seed = widget.source.seed;
    if (seed != null) _show(await seed.load());

    // 2. Ask the network. This never throws because of the network.
    _show(await widget.source.load());
  }

load() returns a DocumentLoad:

Field Meaning
document The UiDocument to draw.
status A DocumentLoadStatus — why this document is on screen.
isFallback true when the bundled copy is shown instead of the published one.
detail The reason in English, for logs. Not text for users.
signature Fingerprint of the JSON text; use it to tell "same document" from "new document".
receivedAt When this result was produced.

DocumentLoadStatus values:

Status What happened Shown
network Read from the network. published document
bundled A bundled source was read; the network was not asked. bundled copy
notPublished HTTP 404. Nothing has been published at this address yet. bundled copy
serverError Any other status code that is not 200. bundled copy
timedOut No answer within timeout (five seconds by default, kDocumentFetchTimeout). bundled copy
unreachable The request did not happen: no connection, DNS, TLS, a missing platform permission. bundled copy
malformed The answer is not a document: not JSON, or no usable screen. bundled copy
incompatible The document's major contract version differs from the renderer's. bundled copy

load() throws only when the bundled copy itself cannot be read. That is a build error — a missing asset — not a runtime condition.

A fallback is silent on screen by design, so log the status. A new UiDocument object resets the screen stack, and most re-reads bring the same document — keep the object that is already on screen when the signature did not change:

  void _show(DocumentLoad next) {
    if (!mounted) return;
    // A fallback is silent on screen by design, so say it in the log.
    debugPrint('document: ${next.status.name} ${next.detail ?? ''}');

    // A new UiDocument object resets the screen stack. Most re-reads bring
    // the same document: keep the object that is already on screen.
    final previous = _load;
    final unchanged = previous != null && previous.signature == next.signature;
    setState(() => _load = unchanged ? next.reusing(previous.document) : next);
  }

To pick up a new publication without a restart, load again when the app returns to the foreground. Only a source whose isRefreshable is true can bring something new:

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    // Back from the background: a manager may have published meanwhile.
    if (state == AppLifecycleState.resumed && widget.source.isRefreshable) {
      widget.source.load().then(_show);
    }
  }

Two details of the transport that affect your infrastructure:

  • every request gets an extra query parameter, _req=<milliseconds>-<sequence>, so a cache in between cannot serve an old publication;
  • HttpDocumentTransport sends no custom headers. This keeps the request "simple" in a browser build (no CORS preflight).

To use your own HTTP stack, implement the port. It serves the theme too:

final class AuthorizedTransport implements DocumentTransport {
  AuthorizedTransport(this.client, this.token);

  final http.Client client;
  final String Function() token;

  @override
  Future<DocumentResponse> fetch(Uri url) async {
    // Let the exception through when the request did not happen; the source
    // applies the timeout. In a web build a custom header makes the browser
    // send a CORS preflight, which your server must then allow.
    final response = await client.get(url, headers: {'Authorization': 'Bearer ${token()}'});
    return (statusCode: response.statusCode, body: utf8.decode(response.bodyBytes));
  }
}

DocumentResponse is the record ({int statusCode, String body}). Decode the body as UTF-8.

5. Draw the document#

Pick one theme from the set. The contract marks every theme light or dark, so you do not hard-code theme ids:

    // The contract marks every theme light or dark: no theme ids in app code.
    final theme = widget.themeLoad.contract.themeForBrightness(
      MediaQuery.platformBrightnessOf(context),
    );

contract.defaultTheme and contract.themeById('dark') (an unknown id gives the default theme) are the other two ways.

return Theme(
  data: theme.toMaterialTheme(),
  // The renderer draws only the document: the Scaffold and the safe area
  // are the host's.
  child: Scaffold(
    body: SafeArea(
      child: renderDocument(
        document,
        theme: theme,
        data: widget.data,
        locale: locale,
        onRecordAction: _onAction,
        onWrite: _onWrite,
        // Documents from a source you do not fully trust: no autofill hints.
        // autofill: false,
      ),
    ),
  ),
);

The parameters of renderDocument(UiDocument doc, {…}):

Parameter What you pass
theme (required) The RenderTheme you picked.
data (required) Values for the document's bindings, Map<String, Object?>. See step 6.
entry A public entry-point name from doc.entryPoints. null, or a name the document does not have, opens the initial screen.
locale The language to show. See step 9.
onRecordAction Receives every action the renderer did not handle itself, together with the record of the node that was pressed. The recommended entry. See step 7.
onAction The same without the record: (action, nodeId). Still works. When both are given, only onRecordAction is called.
onWrite, autofill What the user typed or chose, and whether text fields may give the platform autofill hints. See step 8.
onTrack, onScreenShown The analytics event of a press, and the screen-shown event. See Analytics and screen views.
now The clock "days left" counts from. See Sentences, numbers and "days left".
scopeFor Supplies the data of one screen that the record it was opened with does not have. See step 6.
labels The renderer's own words in your language. See step 9.
controller A ScreenFlowController: a handle on the document's screen stack for your own "back" control. See System back.
onSystemBack Called before the flow steps back on a system back event; return true to handle it yourself.
handleSystemBack false leaves the system back event to your Navigator altogether. true by default.

What the returned widget does and does not do:

  • It keeps its own stack of screens and performs navigation inside the document. It does not use your Navigator.
  • It draws only the document and adds no Scaffold. It reads the safe-area insets of its own context; inside your own SafeArea, as in the example, both are zero.
  • The system back button is handled, with nothing to wire: it goes one screen back inside the document, closes a modal screen, and on the document's first screen is left to your Navigator. See System back.
  • A screen with presentation: 'modal' is drawn as a layer over the previous screen: a sheet from the bottom, or a window in the middle.

If you manage screens yourself, renderScreen(ScreenEntry screen, {...}) draws one screen without the stack.

Run the app now: you should see the screen.

System back#

While the document has a screen to go back to, the Android back button and back gesture (predictive back included) and the browser's back button go one screen back inside the document — the same step as the document's own navigate.back. On the first screen of the document the event is not intercepted: your Navigator pops the route the document stands in, exactly as it would without the renderer.

Where the user is System back
a deeper screen of the document one screen back inside the document; your route stays
a modal screen of the document the modal is closed
the first screen your Navigator pops the route (or the app is left, on its root route)
a dialog or sheet of your app is open above the document your dialog is closed first; the document does not move

Two consequences of how Flutter delivers the event:

  • iOS back swipe. It pops your route from the document's first screen. On a deeper screen the swipe is disabled, as for any route that handles "back" itself — the document's own back arrow is the way back.
  • A nested Navigator (a navigator per tab) receives the system back event only if your app forwards it, usually with NavigatorPopHandler. Not forwarded, the event never reaches the document — use the controller below.

To drive "back" from your own control, give the flow a ScreenFlowController:

class _FlowWithOwnBackState extends State<FlowWithOwnBack> {
  // A handle on the document's screen stack.
  final _flow = ScreenFlowController();

  @override
  void dispose() {
    _flow.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        // The host's own arrow: one screen back inside the document, and out
        // of the flow from its first screen.
        leading: BackButton(
          onPressed: () {
            if (!_flow.goBack()) Navigator.of(context).maybePop();
          },
        ),
        // The controller notifies when the stack changes.
        title: ListenableBuilder(
          listenable: _flow,
          builder: (context, _) => Text(_flow.canGoBack ? 'Details' : 'Rewards'),
        ),
      ),
      body: renderDocument(
        widget.document,
        theme: widget.theme,
        data: widget.data,
        controller: _flow,
      ),
    );
  }
}
ScreenFlowController
canGoBack true when the document has a screen to go back to
currentScreenId the screen shown now; null while no flow is attached
goBack() one screen back; returns false and does nothing on the first screen
addListener called when either value changes

To step in before the flow goes back — unsaved input, a confirmation — pass onSystemBack. It is called only when the document has a screen to go back to:

    // Called only when the document has a screen to go back to. Return true
    // when you handled the event yourself; false lets the flow step back.
    onSystemBack: () {
      if (!hasUnsavedInput()) return false;
      askToDiscard();
      return true;
    },

With handleSystemBack: false the renderer leaves the event alone altogether: the system back always goes to your Navigator, and ScreenFlowController.goBack() is how you step back inside the document.

6. Supply data#

data is a map from path to value. Flat and nested keys both work, and you can mix them:

const Map<String, Object?> backendData = {
  // flat: the key is the whole path
  'user.firstName': 'Alex',
  'user.points': 1250,
  'user.level': 3,
  'user.isPremium': true,
  'user.memberSince': '2025-03-14T09:00:00.000Z',

  // nested: the path is walked segment by segment
  'campaign': {'title': 'Autumn rewards', 'endsAt': '2026-11-30T23:59:59.000Z'},

A collection is a list of records under the collection name:

  // a collection: a list of records under the collection name;
  // a record uses field names (`title`), not full paths (`task.title`)
  'tasks': [
    {
      'title': 'Make 5 card payments',
      'progress': 3,
      'target': 5,
      'completed': false,
      'rewardPoints': 100,
      'status': 'inProgress',
    },

Rules the renderer applies:

  • Keep types. A number stays a number, a boolean stays a boolean. Dates are ISO 8601 strings with time and zone.
  • A missing value is not an error. The binding shows the fallback written in the document.
  • A collection record uses field names, not full paths. The record of collection tasks has title; inside the list the document reads it as task.title.
  • A list draws at most 50 records (a contract limit).
  • A record may carry fields the dictionary does not list — an id, as the records of backend.dart do. A screen cannot be bound to them, but they reach you with the rest of the row: record['item.id'] in step 7, request.record below. The standard dictionary has no item.id; add the identifier to your project's dictionary when a screen has to show it.
  • New data: pass a new map and rebuild. The screen stack is kept.

Which paths and collections exist is defined by the project's data dictionary; which of them one document reads is in the document — see What a document reads. What your backend returns is described in the backend guide.

Data of one screen#

A list row usually opens a details screen for the record that was tapped, and the renderer does it by itself: an action carries the record of its node. A navigate from a list row opens the screen with that row's record, so the details screen shows the tapped row without any code of yours (see One screen, many records). scopeFor is for what the row does not have — the host finds the rest of the entity by the id the row carries:

  // A list row opens a details screen and carries its own record, so the
  // screen already shows the tapped row. The host adds what the row does not
  // have: it finds the full record by the id the row carries.
  Map<String, Object?> scopeFor(ScreenScopeRequest request) {
    final id = request.record?['item.id'];
    final record = id is String ? itemsById[id] : null;
    return record == null ? const {} : {'item': record};
  }

  return renderDocument(document, theme: theme, data: data, scopeFor: scopeFor);

ScreenScopeRequest is a record: ({Map<String, String> params, Map<String, Object?>? record, Object? Function(String path) read}). record is the record the screen was opened with, or null. params is what the navigation passed — for you only. read looks a path up in the data without the screen scope. Return a nested map ({'item': {...}}) or a flat one ({'item.name': ...}); return an empty map to supply nothing.

Do not put the id of a record into the params of a row to find it later: in a list template the params are the same for every row. The row carries its record itself.

The resolver is asked on every build, so data that arrives after the navigation still reaches the open screen.

A path is looked up in this order, and the first layer that has the value answers (null counts as "none"):

  1. The record — the row the screen was opened with, and the rows of the lists a node is drawn in.
  2. What the user changed in a bound input on this visit (for example the selected option of a ChoiceGroup). It lives as long as the screen stack does and is never written to the document.
  3. The map returned by scopeFor.
  4. data.

The record is stronger than the user's choice on purpose: tap the row "Bottle" while the radio stands on "Backpack", and the screen is about the bottle, whole.

navigate.params are not data: there is no params.* path. They are a free map of strings for your host (ScreenScopeRequest.params, action.params).

What the renderer expects from your data#

Value What the renderer expects
A number A number, not a string: "1850" gets no separators and no plural agreement. The limit for number and currency is ±(2⁵³ − 1).
A date An ISO 8601 string with time and zone (2026-10-31T23:59:59Z, …T23:59+03:00) that is a real calendar moment. A date without a time or a zone, 2026-02-30T…, month 13, hour 24 and an offset of +24:00 are shown as they came, and "days left" is not counted from them.
A text An empty string counts as a missing value — the fallback written in the document is shown. A string of spaces is a value.
Rounding Half away from zero over the shortest decimal form of the number you sent (1.005 → 1.01 USD, 1.0005 → 1.001). Do not round beforehand.
A fraction next to a plural word The CLDR rules of the language: French 1.5 point, English 1.5 points.

Sentences, numbers and "days left"#

A text can be a sentence with places filled from your data — {done} / {total} steps, {days} {unit} left, Only {price}. The renderer substitutes, formats and picks the plural word; there is nothing to write in your app. "Days left" is counted from the device clock when the screen is built, and the screen does not redraw itself as time passes — rebuild it when that matters. Pass now to count from your own clock, and always in tests:

renderDocument(document, theme: theme, data: data, now: serverNow);

With renderScreen, keep passing strings: document.stringsFor(locale): the table also carries the source language that untranslated plural words follow.

Buttons, icon buttons and chips#

Nothing to write in your app; this is what you will see.

  • A button can carry an icon and a size (sm / md / lg — 28 / 40 / 48 dp high with the built-in theme); a badge can carry an icon; IconButton is a round button with no visible text.
  • The label of an icon button is its accessible name: translated and bound like any text, announced by the screen reader with the role of a button, and never drawn.
  • The action of an icon button reaches you like a button's — onAction / onRecordAction.
  • The renderer does not pad a small button to 48 dp: the visible size is the one from the document, and the touch area around it is extended to 48 × 48 dp without moving neighbours. Do not wrap the document in widgets that add a minimum tap size.

7. Handle actions#

The host gets an action through onRecordAction (the action, the id of the node and the record of that node) or, when it does not need the record, through onAction, a SduiActionCallback: void Function(ButtonAction action, String nodeId). When both are given, only onRecordAction is called. ButtonAction has type (a free string) and params (Map<String, String>).

The renderer performs some action types itself and hands everything else to the host:

action.type Who performs it Reaches the host when
none the renderer: nothing happens never
navigate the renderer: opens the screen named in params['screenId'] the document has no such screen
navigate.back the renderer: goes one screen back the first screen is shown — this is "leave the flow"
navigate.section the renderer: makes the named screen the only one in the stack (bottom navigation) the document has no such screen
setValue, toggleValue the renderer: writes a value never; the write reaches onWrite — see step 8
openUrl, deeplink, close the host always, checked — see The action catalog
anything else the host always, untouched

An action type the renderer does not know is not an error: the contract says unknownAction: 'pass-to-host'. Log it and do nothing.

  // `record` is the record of the node that was tapped: the list row, or the
  // row that opened this screen. A flat map of paths, or null.
  void _onAction(ButtonAction action, String nodeId, Map<String, Object?>? record) {
    switch (action.type) {
      case ButtonAction.navigateBack:
        // "Back" on the first screen of the document: leave the flow.
        Navigator.of(context).maybePop();
      case 'share':
        _say('Your share sheet opens here');
      case 'redeem':
        // Which reward? The row says so. Check the id on your backend like
        // any user input before you spend points on it.
        _say('Redeem ${record?['item.name']} (${record?['item.id']})?');
      default:
        debugPrint('unhandled action ${action.type} from $nodeId');
    }
  }

One screen, many records#

The record of a node is a flat map of paths to values — {'item.id': 'bottle', 'item.name': 'Bottle'}. It is the record the screen was opened with, and over it the records of the list rows the node is drawn in (the inner row wins; a field that is null hides nothing). Nested maps in your collections are flattened into paths (item.task.title); lists stay values.

The action is pressed… navigate opens the screen… record the host gets
in a list row with the row's record (laid over the record of its own screen) the same record
outside a list, the target is a page without a record — "Back to catalog" from a details screen is a clean catalog the record its screen was opened with, if any
outside a list, the target is a modal sheet with the record of the screen beneath — "Redeem {item.name}?" is about the same record the same
  • A pressed row opens a screen about its own record.
  • A modal screen inherits the record of the screen beneath it. A page opened from a button outside a list does not.
  • A screen opened without a record (the initial screen, an entry, a button outside a list) reads item.* from the other layers: put the record into your data or return it from scopeFor.
  • The record belongs to a stack frame: going back drops it, and another row opens another record.
  • It is a snapshot taken when the row was pressed.
  • A value from the event is user input. The values are yours, but check an id on your backend before you act on it.

The action catalog#

Since schema 9.8.0 a manager picks an action from a list in the builder. The list is the action catalog of the contract (x-actions.catalog in screen-schema.json), and its last position is "Other action" — a free string for your app.

In the builder action.type Performed by Parameters
No action none the renderer (does nothing) —
Go to screen navigate the renderer screenId — a screen of this document; optional carryRecord: true beside type
Go back navigate.back the renderer; on the first screen — your app —
Open a web page openUrl your app url — an https:// address, literal or from data
Open an app link deeplink your app url — a link with your app's scheme (myapp://offers/42), literal or from data
Close the flow close your app —
Other action any other string (submit, share, your own) your app free params
  • The renderer never opens an address by itself. A document comes from the network; treat an address from it as user input. Open openUrl with your own code, and follow a deeplink only when its scheme is one of yours.
  • The form of an address is part of the contract (x-actions.forms): lowercase https://, no user name before the host, ASCII only, at most 2048 characters; for an app link — a lowercase scheme that is not in forbiddenSchemes (javascript, data, file, intent, http, https and others). The builder does not publish a document whose literal address fails the form.
  • action.paramBindings takes a parameter from data ({"url": "offer.url"}), read with the record of the pressed row; the literal in params is the fallback. action.track is the analytics event of the press ({"event": "offer_open"}); any action may carry it. action.carryRecord on navigate opens the next page with the same record.
  • close asks you to close the whole flow (the screen where the document is shown). It is the same request on the first screen, deeper in the stack and on a modal sheet; one step back is navigate.back.

Run only what the package marked standard#

  • Open only action.standard. params.url of an action marked invalid is a raw string of the document: never open it, never run it.
  • The form of a web address (x-actions.forms.httpsUrl) is a host name with at least two labels and a letters-only ending (example.com, punycode xn--…), an optional port from 1 to 65535, no user name. An IP address in any notation and a single-word name (localhost) are invalid: such a link is not an openUrl. The form cannot tell an internal name (intranet.corp) from an external one — which domains may be opened is your decision (keep a list of allowed domains).
  • A deeplink whose scheme is in your list is standard, but the path and the query of the link stay untrusted: myapp://open?url=javascript:… is judged by your router. tel:, sms:, mailto:, market: pass the form too; only your list of schemes holds them back. If you do not call the helper, keep such a list yourself.
  • A double click is two presses and two actions; protecting a payment from a repeat is the job of your app. A held Enter or Space is one press (keyAutoRepeat).
  • Do not build a route from action.params.screenId (router.push('/app/${screenId}')): match it against your own list of routes.
  • A package older than 0.8.0 hands openUrl and deeplink to you as an unknown action with raw params and no verdict. If your app already had its own openUrl branch, update the package before managers start using the catalog.

runStandardAction performs a checked standard action with executors you pass; call it from your onAction / onRecordAction. The package has no url_launcher in it — openBrowser is where launchUrl of your own url_launcher goes:

bool performStandard(
  ButtonAction action, {
  required void Function(String url) openBrowser,
  required void Function(String url) openAppLink,
  required VoidCallback closeFlow,
}) {
  return runStandardAction(
    action,
    openUrl: openBrowser,
    // The schemes your app owns. Without the list a `deeplink` is never run,
    // whatever the document says.
    deeplinkSchemes: const ['myapp'],
    openDeeplink: openAppLink,
    close: closeFlow,
  );
}

Analytics and screen views#

An action may carry track — the analytics event of the press. onTrack gets it exactly once per tap and before anything else the tap causes, also for a button whose action is none. onScreenShown is the screen-shown event. The renderer sends nothing anywhere itself.

  return renderDocument(
    document,
    theme: theme,
    data: data,
    onTrack: (event, nodeId, record) => log(event.event, event.params),
    onScreenShown: (shown) => log('screen_view', {
      'screen': shown.screenId,
      'cause': shown.cause.name, // entry | forward | back | switchSection
      'depth': '${shown.depth}',
      if (shown.entry != null) 'entry': shown.entry!,
    }),
  );
onScreenShown fires once when cause
the first screen is shown, or a new entry or document is given entry
a step forward (a modal sheet too) forward
a step back; a closed sheet and the system back button are the same back
a section is switched by navigate.section switchSection

It is not fired on a rebuild, on new data, on another language, on an action handed to you, or on a navigation that did not happen.

8. Accept what the user wrote#

A screen can ask: text fields, switches, checkboxes, a choice group, and buttons that write a value (setValue, toggleValue). Each writes to a data path. The renderer keeps the written value for the lifetime of the screen stack, so conditions and bound texts on the same screen follow the user at once, and it calls onWrite at the same moment.

  // What the user typed or switched in a `TextField`, `Switch`, `Checkbox` or
  // `ChoiceGroup`: the data path of the field and a String, a bool or (a number
  // `TextField`) a num; an empty String when the number was cleared. Called on
  // every change, at once. It is user input: check it on your backend like any
  // other, and throttle here if a network stands behind it.
  void _onWrite(String path, Object? value) {
    switch (value) {
      case String text:
        debugPrint('text field $path: ${text.length} characters');
      case bool on:
        debugPrint('switch or checkbox $path: $on');
      case num number:
        debugPrint('number field $path: $number');
    }
  }
Written by Type of the value
a text field, a choice group String
a switch, a checkbox bool
a number field num; an empty String when cleared
setValue, toggleValue String, bool or num, as the action says
  • Every change is written at once, on every edit. There is no delay and no "on blur"; throttling a network behind it is your job.
  • The value is user input, and so is the path. A document names the path a value is written to. This package has no list of allowed paths: you see every write in onWrite, so check the path against the ones you expect, and validate on the backend whatever the client showed.
  • Errors are yours. The renderer validates nothing: the error text of a field is a string in data under the path the document names.
  • autofill: false turns the autofill hints of the text fields off. Pass it for documents from a source you do not fully trust.

After a write, supply the data that depends on it#

The renderer writes the value and tells you. It does not fetch or invent the data that follows from the choice — that is the host's job.

The typical case: a catalog screen with a choice group bound to item.selectedId, and a "Details" button that opens a screen bound to item.name, item.price, item.image. The user picks the second product and presses the button. The renderer wrote item.selectedId and opened the details screen — and that screen reads item.name from your data. If your data still holds nothing (or the first product), the screen shows fallback texts (or the first product), and nothing fails or is logged: a fallback is a legitimate state.

So on onWrite, put the record behind the choice into data and rebuild. This block is not a fragment of example/ — it is written for this page and checked with flutter analyze against the published package:

// In the State of the screen that hosts the document. `itemsById` is your own
// catalog: the id from the list → the fields of that item.
String? _selectedId;

void _onWrite(String path, Object? value) {
  if (path == 'item.selectedId' && value is String) {
    setState(() => _selectedId = value);
  }
}

Map<String, Object?> get _data {
  // The renderer wrote the id. The host supplies the record the id stands for.
  final item = itemsById[_selectedId];
  return {...widget.data, 'item': ?item};
}

'item': ?item adds the entry only when item is not null. Pass _data as data: and _onWrite as onWrite:. When the record has to be fetched, do it in _onWrite and call setState when the answer arrives — the open screen picks the new data up on the rebuild.

The same holds for anything derived from input: a price that depends on a chosen plan, a list filtered by a switch, an error text for a typed phone number. A screen opened from a list row does not need this — the row carries its record itself (One screen, many records).

9. Pick the language#

A document carries all its languages; you show one. Pass the device locale as the platform gives it and let the document find its own language for it:

    // The document matches the device languages against its own list:
    // `ar-EG` finds `ar`. A user's choice wins over the device.
    final locale =
        widget.locale ??
        document.localization?.localeFor(WidgetsBinding.instance.platformDispatcher.locales);

document.localization is a DocumentLocalization? — null only for a document that has no localization section at all. A document with a single language can still have the section (locales: ["en"]); every device is then shown that language. localeFor walks the device's preferred languages and returns the first one the document has a language for, as the requested tag in canonical form: ar-EG on a document with ar returns ar-EG, not ar. Nothing matched: the document's default language.

  • A device locale with a region finds the base language: ar-EG shows the ar translation. Never the other way round: ar does not get a document's ar-EG.
  • locale: null, or a language the document has no match for, draws the source language.
  • The writing direction follows the language the document is actually shown in: Arabic text is laid out right-to-left inside an English app, with nothing to do on your side.
  • A user choice overrides the device: keep it in your state and pass it instead.

Four short texts are the renderer's own and do not come from the document: the accessibility label of the back arrow, the caption of an image that failed to load, and two notices shown in place of a document this renderer cannot read (see step 10). They are English by default. Pass yours with labels:

const germanLabels = RendererLabels(
  back: 'Zurück',
  imageUnavailable: 'Bild nicht verfügbar',
  updateRequired: 'Bitte aktualisieren Sie die App, um diesen Bildschirm zu sehen.',
  screenUnavailable: 'Dieser Bildschirm ist gerade nicht verfügbar.',
);

Details, right-to-left scripts and date formatting are on the localization page.

10. Versions: what a user sees#

Document What happens
same major, same or older minor The screen.
same major, newer minor (VersionVerdict.newerMinor) The screen; unknown parts degrade by the compatibility policy.
another major, read by PublishedDocumentSource The bundled copy; status is incompatible, and detail says which case it is. A user on an old app keeps the screen that shipped with it.
a newer major, passed to renderDocument directly (VersionVerdict.newerMajor) A notice instead of the screen: RendererLabels.updateRequired, by default "Update the app to see this screen."
an older major, passed to renderDocument directly (VersionVerdict.olderMajor) A neutral notice: RendererLabels.screenUnavailable, by default "This screen is not available right now."
no readable schemaVersion (VersionVerdict.unparsable) The same neutral notice.

The two notices differ because the cure differs. A newer document is read by a newer app, so the user is asked to update. An older document (an 8.x document in a 9.x renderer) is read by no newer app: it has to be republished from the builder, and the user can do nothing — so the notice gives no advice, and the reason goes to the debug log.

If you obtain a UiDocument another way — your own API, a cache, a push payload — check before drawing and show your own message:

Widget drawChecked(String json, RenderTheme theme, Map<String, Object?> data) {
  final document = UiDocument.fromJsonString(json); // throws FormatException
  final verdict = document.versionVerdict;

  if (!verdict.canRender) {
    // This renderer cannot read the document. Only a NEWER document is cured
    // by an app update; an older one has to be republished from the builder.
    debugPrint('document ${document.schemaVersion}, renderer $kSupportedSchemaVersion: $verdict');
    return Center(
      child: Text(
        verdict == VersionVerdict.newerMajor
            ? 'Update the app to see this screen.'
            : 'This screen is not available right now.',
      ),
    );
  }
  return renderDocument(document, theme: theme, data: data);
}
VersionVerdict canRender
ok yes
newerMinor yes
newerMajor no — a newer app would read the document
olderMajor no — the document has to be republished from the builder
unparsable no

Test the screen#

In a widget test, assets are read outside the test's fake clock — load the theme and pump the screen inside tester.runAsync:

    // Assets are read outside the fake clock of a widget test.
    await tester.runAsync(() async {
      final themeLoad = await ThemeLoad.fromBundle(assetPath: themeAsset);
      expect(themeLoad.status, ThemeLoadStatus.ok);

      await tester.pumpWidget(
        MaterialApp(
          home: HotLayoutScreen(
            source: const BundledDocumentSource(title: 'Bundled copy', assetPath: documentAsset),
            themeLoad: themeLoad,
            data: backendData,
          ),
        ),
      );
      await Future<void>.delayed(const Duration(milliseconds: 200));
    });
    await tester.pumpAndSettle();

For the network, give PublishedDocumentSource a fake DocumentTransport that returns a status code and a body; no HTTP mocking is needed. The whole test is example/test/widget_test.dart in the package.

Platform notes#

On two platforms a missing permission does not fail: every request becomes unreachable, the bundled copies are shown, and the network path is never exercised.

macOS. A sandboxed macOS build needs the com.apple.security.network.client entitlement in both macos/Runner/DebugProfile.entitlements and macos/Runner/Release.entitlements:

<key>com.apple.security.network.client</key>
<true/>

This is the build you are most likely to look at during development.

Android. flutter create declares android.permission.INTERNET only in the debug and profile manifests. Add it to your app's main android/app/src/main/AndroidManifest.xml, or the release build will behave like the macOS case above:

<uses-permission android:name="android.permission.INTERNET"/>

iOS. Nothing to add for HTTPS addresses.

Web. A Flutter web build is a page in a browser: the server of the document and the theme must allow cross-origin reads from your site's origin, and the transport must not add request headers. Our storage does not allow your origin by default, so a web build reads the files from your own backend — see Reading from a browser. Builds for iOS, Android and desktop are not affected.

Fonts#

A theme names its font families and their fallbacks; the package does not deliver font files. Until your app declares the family under flutter: fonts: with the same name, text is drawn in the platform's default font and nothing fails. See theme.

What to check when nothing shows up#

Work down the list; each step rules out one layer.

  1. Is the bundled copy on screen instead of the published one? Log load.status and load.isFallback. A fallback is silent by design, so without this line "published but not shown" looks like a broken app.
  2. notPublished. The address answered 404. The document was not published yet, or the organization, project or document id in the URL is wrong. A saved draft is not a publication.
  3. unreachable on every run. Check the platform notes above first. On macOS this is almost always the missing entitlement.
  4. timedOut. The default is five seconds. Check the network, then pass a longer timeout.
  5. serverError. Log detail; it contains the status code. A 403 means the address points at a file that is not public — only published.json is.
  6. malformed. The answer is not a document. Open the URL in a browser: you should see JSON with schemaVersion and screens.
  7. incompatible. The document was built for another major contract version. Update the package. See compatibility.
  8. network, but the screen did not change. The storage holds the same document as before. Compare signature values; then ask whether the change was published or only saved.
  9. The details screen shows the wrong record or fallback texts after a choice. See After a write, supply the data that depends on it.
  10. The screen is there, the values are not. Bindings are showing their fallbacks. Check that the keys of data match the paths of the data dictionary exactly, and that collections are lists under the collection name.
  11. A block is missing. A visibleIf condition did not hold. A value that is absent counts as empty: isEmpty holds, every other operator does not. See compatibility.
  12. A list is empty. The value under the collection name is not a List, or the records use full paths (task.title) instead of field names (title).
  13. Colors or fonts look wrong. Log themeLoad.status. Anything but ok means a fallback theme is shown. For fonts see the section above.
  14. A placeholder with a widget name appears in debug builds only. The document contains a widget this renderer version does not know. Its children are still drawn; in release builds the placeholder is not shown.
  15. Text is in the wrong language. Log the locale you pass and load.document.shownLocale(locale). A missing translation shows the default-language text, not an empty space.

In debug builds the renderer prints what it decided not to draw, prefixed with [hotlayout].