genui_gen_builder 0.14.0
genui_gen_builder: ^0.14.0 copied to clipboard
build_runner generator for genui_gen. Turns @GenUiWidget widgets and @GenUiData classes into A2UI components whose schema, builder and examples come from the constructor.
genui_gen_builder #
build_runner generator for
genui_gen. It turns a Flutter
widget annotated with @GenUiWidget into an
a2ui_flutter component, a
GenUiComponent, whose JSON schema, widget builder and examples are all
derived from the widget's constructor. Nothing is written by hand, so the
catalog can never drift from the widget.
A widget parameter may also be a plain data class annotated with @GenUiData,
or a List of one, so a table, a chart series or any list of value objects can
be composed by the model. A top-level function annotated with @GenUiFunction
becomes a catalog function, which is the other half of a catalog: what the
model computes a value with rather than what it composes a surface out of.
What the pair does, on one page →
Compatibility #
| Supported | |
|---|---|
| Dart SDK | >=3.10.0 <4.0.0 |
analyzer |
>=10.0.0 <15.0.0 |
| renderer | a2ui_flutter 0.2.x and 0.3.x, through genui_gen 0.15; genui 0.10.x, through genui_gen 0.14 |
The generator is written against the parts of the analyzer element model that
are stable across major versions, so a new analyzer release does not require
a new release of this package unless it removes API the generator uses.
It generates for the renderer the genui_gen it resolves is built on.
genui_gen 0.15 declares next to its annotations that it is built on
a2ui_flutter, and gets GenUiComponents and FunctionImplementations; an app
still on genui_gen 0.14 keeps getting genui's CatalogItems and
ClientFunctions, unchanged. The rest of this README describes the
a2ui_flutter output.
Install #
dependencies:
a2ui_flutter: ^0.3.0
genui_gen: ^0.15.0
dev_dependencies:
build_runner: ^2.15.0
genui_gen_builder: ^0.14.0
Add a2ui_core: ^0.7.0 as well once the app imports it, as rendering with a
MessageProcessor does. The generated part shares your file's imports and
names GenUiComponent, GenUiSchemas and, for lists, maps and data classes,
S; genui_gen exports all of them, so json_schema_builder is not a
dependency of yours.
The two packages move together. The builder emits calls to runtime helpers
as they are added, so a new builder against an old runtime generates code that
does not compile. Nothing enforces it for you: the builder deliberately does
not depend on genui_gen — it matches the annotations by name, so the
Flutter-dependent runtime never loads into the build isolate.
| Builder | Needs at least | Because it emits |
|---|---|---|
| 0.14.x | genui_gen >= 0.14.0, and 0.15.0 for a2ui_flutter |
on a2ui_flutter, GenUiComponent, GenUiSchemas, genUiWriter and genUiMissingIn; on genui, what 0.13 emits |
| 0.13.x | genui_gen >= 0.14.0 |
the genui_gen import in genui_catalog.g.dart |
| 0.8.x | genui_gen >= 0.9.0 |
GenUiClientFunction |
| 0.7.x | genui_gen >= 0.8.0 |
genUiTemplateChildren, GenUiBinding.value |
| 0.6.x | genui_gen >= 0.5.0 |
the assembled Catalog with its catalogId |
| 0.5.x | genui_gen >= 0.4.0 |
genUiValueWriter |
| 0.2.x–0.4.x | genui_gen >= 0.2.0 |
the @GenUiData decoders |
No build.yaml is needed to generate the components: the builder applies
itself to every package that depends on it and writes <file>.genui.dart next
to the source. One is needed only for the assembled catalog, which needs an id
(see The package's catalog).
Use #
// lib/widgets/product_card.dart
import 'package:flutter/material.dart';
import 'package:genui_gen/genui_gen.dart';
part 'product_card.genui.dart';
@GenUiWidget(description: 'A product card with price and image.')
class ProductCard extends StatelessWidget {
const ProductCard({
super.key,
/// Product name.
required this.title,
/// Price in USD.
required this.price,
this.imageUrl,
/// Fired when the card is tapped.
this.onTap,
});
final String title;
final double price;
final String? imageUrl;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) => /* ... */;
}
dart run build_runner build
The generated part declares final GenUiComponent productCardCatalogItem,
ready to be added to a catalog. The variable name is always
<lowerCamelClassName>CatalogItem, regardless of @GenUiWidget(name:).
The annotated file must:
- contain the
part '<file>.genui.dart';directive (the builder warns and skips the file otherwise), and - import
package:genui_gen/genui_gen.dart(plus Flutter). The generated code is apart ofyour library and reuses its imports; when one is missing the build fails with the exact import lines to add.genui_genexports every renderer type and schema name the generated part uses.
The package's catalog #
A second builder collects every generated component and function of the
package into lib/genui_catalog.g.dart, which declares both lists and, with an
id, the assembled catalog:
final List<GenUiComponent> genUiCatalogItems = <GenUiComponent>[
productCardCatalogItem,
statTileCatalogItem,
];
final List<FunctionImplementation> genUiCatalogFunctions =
<FunctionImplementation>[];
final CatalogImplementation genUiCatalog = CatalogImplementation(
id: 'com.example.app',
components: genUiCatalogItems,
functions: genUiCatalogFunctions,
instructions: genUiExampleInstructions(genUiCatalogItems),
);
The instructions show the agent an example of each component. An app composes
the catalog with the basic one through basicCatalog().derive(...), as the
generated file's doc comment shows. The file is formatted with dart_style, so
it passes dart format whatever its names do to the line lengths.
The id comes from build.yaml, because it names the catalog to the agent that
composes against it and to the clients that render it — not something a
generator can invent:
targets:
$default:
builders:
genui_gen_builder:genui_catalog:
options:
catalog_id: com.example.app
Without it there is no genUiCatalog, since an a2ui_flutter catalog cannot
exist without an id: the file declares the two lists, and the build warns and
says where to set one. An id that is not a string, or that could not survive
being written into the generated file, is a build error naming the option.
Lists the data model fills #
@GenUiProp(template: true) on a List<Widget> lets the property accept a
child template — {"componentId": "row", "path": "/rows"} — as well as a list
of ids. Its schema is A2UI's ChildList instead of a list of component ids,
and a2ui_core expands the template: one child per entry at that path, with the
entry as its own data context, so a new entry adds a child without the agent
composing anything again. Off by default, since it changes the schema the model
composes against.
Type mapping #
| Constructor parameter type | Schema | Read with |
|---|---|---|
String, String? |
GenUiSchemas.string |
props.string(k) |
int, double, num (+?) |
GenUiSchemas.number |
props.number(k), then toInt() for an int |
bool, bool? |
GenUiSchemas.boolean |
props.boolean(k) |
any enum (+?) |
GenUiSchemas.string(enumValues: ...) |
props.string(k), then Enum.values.asNameMap()[s] |
List<String> (+?) |
GenUiSchemas.stringList |
genUiAsStringList(props.value(k)) |
List<int>, List<double>, List<num> (+?) |
GenUiSchemas.list(items: S.number()) |
genUiAsNumList(props.value(k)), then toInt() / toDouble() per entry |
List<Enum> (+?) |
GenUiSchemas.list(items: S.string(enumValues: ...)) |
genUiAsStringList(props.value(k)), then the enum's name map, dropping a name it does not declare |
Map<String, V> (+?) |
GenUiSchemas.object(literal: S.object(additionalProperties: <V schema>)) |
genUiAsStringMap, genUiAsNumMap, genUiAsBoolMap or genUiAsObject on props.value(k) |
Widget, Widget? |
GenUiSchemas.componentId |
props.child(k), then buildChild(node) |
List<Widget> (+?) |
GenUiSchemas.componentIdList, or GenUiSchemas.childList with template: true |
props.children(k).map(buildChild) |
a @GenUiData class (+?) |
GenUiSchemas.object(literal: <its ObjectSchema>) |
genUiAsObject(props.value(k)), then <name>FromGenUiJson(map, genUiMissingIn(context, node, k)) |
List<@GenUiData> (+?) |
GenUiSchemas.list(items: <its ObjectSchema>) |
genUiAsObjectList(props.value(k)), then <name>FromGenUiJson per entry |
VoidCallback, void Function() (+?) |
GenUiSchemas.action |
props.action(k) |
void Function(T) with @GenUiWrites('p') |
none | genUiWriter<T>(context, props, 'p') |
a @GenUiChecked parameter |
checks, GenUiSchemas.checks() |
props.validationErrors.firstOrNull, props.isValid, or a GenUiCheckResult of both |
Key? key, super.key |
skipped | — |
| anything else | build error | — |
Each schema is one a2ui_core recognises, and that is what makes it resolve the
property before the builder runs: a dynamic value (a literal, a
{"path": ...} or a {"call": ...}) arrives as a binding props reads
through, a ComponentId as a child node, an Action as a closure, and
checks as isValid and validationErrors. For a data property the binding
applies to the whole object, so {"path": "/rows"} resolves the list and the
generated decoder runs on the result, and the schema says so, which is why an
object property is a union of the object schema, a data binding and a function
call rather than the bare object schema. GenUiSchemas.component wraps the
properties, adds accessibility and weight, which every basic component
takes, and allows no other property.
Data classes #
// lib/models/row.dart
part 'row.genui.dart';
@GenUiData(description: 'One row of the table.')
class Row {
const Row({required this.label, required this.value, this.trend});
/// Row label.
final String label;
/// Row value.
final double value;
final Trend? trend;
}
generates, into the same part file the widgets of that library use:
/// Generated schema for [Row].
final ObjectSchema rowGenUiSchema = ObjectSchema(
description: 'One row of the table.',
properties: {
'label': S.string(description: 'Row label.'),
'value': S.number(description: 'Row value.'),
'trend': S.string(enumValues: ['up', 'down', 'flat']),
},
required: ['label', 'value'],
);
/// Decodes a [Row] from the map the model produced.
Row rowFromGenUiJson(
Map<String, Object?> json, [
GenUiMissingFieldReporter? onMissing,
]) => Row(
label:
genUiAsString(json['label']) ??
genUiMissingField<String>(onMissing, 'label', ''),
value:
(genUiAsNum(json['value']) ??
genUiMissingField<num>(onMissing, 'value', 0))
.toDouble(),
trend: Trend.values.asNameMap()[genUiAsString(json['trend'])],
);
ObjectSchema(...) and not S.object(...): Schema.object is a redirecting
factory typed as Schema, so it cannot initialise an ObjectSchema variable.
Both build exactly the same schema.
Rules specific to data classes:
- Fields use the plain
S.*schemas, not the dynamicGenUiSchemasforms: the values inside a data object are literals the model emits, and the binding applies to the object as a whole. Anintfield is thereforeS.integer, which a widget property cannot be: A2UI'sDynamicNumberhas no integer form. - Nothing is cast. Every field goes through the
genUiAs*coercions exported bygenui_gen, so a model that sends"42"where a number was declared gets 42, and a field of the wrong type never throws aTypeErrorinsidebuild. - Supported field types are the scalar set above (
String,int,double,num,bool, enums, aListof any of those, and aMap<String, V>) plus nested@GenUiDataclasses and lists of them. AWidget, aList<Widget>or a callback inside a data class is a build error: a data class is data, not a component reference. - Nested schemas are inlined (by referencing the nested
ObjectSchemavariable), never$ref'd, so a component's schema carries everything it needs. A class that reaches itself is therefore a build error naming the cycle path. - A required field that is missing or malformed decodes to the same neutral
fallback the widget builder uses (
'',0,false, the first enum value,const [], a nested object decoded from an empty map), so a malformed row degrades instead of throwing insidebuild, and is reported through the optionalonMissingreporter. An optional field decodes tonull(or to its Dart default when the constructor declares one) and is never reported. - The generated widget builder passes
genUiMissingIn(context, node, k)as that reporter. A literal object was checked against the schema when its message arrived, so what it catches is bound data with a required field missing: the agent gets aVALIDATION_FAILEDerror at/rows/label, once per component, rather than a silent fallback. While the path holds nothing yet, the property falls back to empty and nothing is decoded or reported. @GenUiProp(name:),@GenUiProp(description:)and@GenUiProp(ignore:)apply to constructor parameters of a data class unchanged, and@GenUiData(constructor:)picks a named constructor.- The generated names are
<lowerCamelClassName>GenUiSchemaand<lowerCamelClassName>FromGenUiJson. - The few-shot example for a
List<@GenUiData>property holds two entries that deliberately differ: required strings are numbered (Sample label 1,Sample label 2), numbers are spread (42/43,42.5/43.5) and enum fields walk the enum instead of repeating the first value, so the example shows the model that every field varies from row to row. A standalone object property is entry 0 and keeps the same sample values 0.1 used. - A doc comment or
@GenUiProp(description:)on a property whose type is a data class is kept, so one use site can explain what the object means there without changing the shared class. On a widget property it becomes thedescriptionof the union around the object; on a field of another data class the nested schema is copied with that description in place of the class's own@GenUiData(description:). - A field with the wire key
eventorfunctionCall, on a class a property takes directly, is a build error: a2ui_core would read the object as an action and hand the widget a closure. So is a class a property takes a list of with bothconditionandmessage, which it would read as checks. A class nested inside another keeps any name.
A widget in one file may use a data class declared in another. The generated
part names the schema and decoder as plain identifiers, so the library
declaring the widget must import the one declaring the data class without a
prefix and without a show / hide combinator that filters out
<name>GenUiSchema and <name>FromGenUiJson, and that library must declare its
own part '<file>.genui.dart';. All three are checked, and the build fails with
the missing piece named rather than emitting code that does not resolve.
A data class keeps a constructor parameter called key: unlike a widget it has
no super.key to skip, so key is an ordinary field and stays in the schema.
Rules #
- A property is
requiredin the schema iff the parameter isrequiredin the constructor, has no default value and is non-nullable. - Parameters with a default value are optional in the schema; when the value
is absent the Dart default applies (the generated code emits
?? <default>). @GenUiProp(ignore: true)leaves a parameter out. It must then be optional or have a default, otherwise the build fails. An ignored positional parameter must not be followed by another positional parameter, because the later one would shift into its slot.- A
super.xparameter whose default is inherited from a superclass in another library is accepted only when that default is a plain literal; otherwise redeclare it asthis.x = <default>or ignore it. - The annotated class must be a concrete
Widget. Private classes need an explicit@GenUiWidget(name: ...), and the nameTextis reserved for the basic component the examples use. @GenUiProp(name: ...)renames the schema property; names must be unique and match[A-Za-z_][A-Za-z0-9_-]*.id,component,accessibilityandweightare reserved property names, since every component takes them. On a widget with@GenUiChecked, so arechecks,isValid,validationErrors,validationResultsandvalidationPending, where a2ui_core writes what the checks say. Rename the wire key with@GenUiProp(name: ...).- Property descriptions come from, in order:
@GenUiProp(description:)/@GenUiAction(description:), the parameter's///doc comment, the backing field's///doc comment. @GenUiPropand@GenUiActionmay be placed on the parameter or, forthis.xparameters, on the field.@GenUiAction(eventName:)only affects the generated example; the event actually dispatched is whatever the model puts in the action data.@GenUiWidget(isImplicitlyFlexible:)has nothing to set on a2ui_flutter, where a basicRoworColumnsizes a child by itsweight. The builder warns rather than dropping it silently.- Enum values use the Dart constant names verbatim. The enum must be visible without an import prefix from the annotated library.
Required-but-missing values #
A required property the agent leaves out never reaches the generated builder:
a2ui_core validates each message against the schema and rejects the component,
and the error is the agent's to see. What does reach it is a property bound to
a path that holds nothing yet. The builder never throws on one: it passes a
neutral fallback ('', 0, false, the first enum value, const [],
SizedBox.shrink(), a no-op callback, a data object decoded from an empty
map) without reporting anything, the way a basic component shows an empty
text, and the widget rebuilds once the data arrives. Bound data is not checked
against the schema, so a required field missing from a bound @GenUiData
object is the one thing reported, through genUiMissingIn.
Examples #
One example per widget is generated from the schema: required properties plus
the optional ones with an obvious sample (enums, actions, child components,
data objects, strings whose name hints at a URL or e-mail). A data property
gets one sample object and a list property gets two, built from the data
class's own fields by the same rules. Child components reference Text
components (ids child_<prop>) that are included in the example, so the
examples assume the basic catalog is part of yours.
A property that a @GenUiWrites callback writes to is bound to a path named
after it ({"path": "/enabled"}) rather than given a literal: only a bound
property can be written, and an agent copies the examples it is shown.
The examples are kept on the GenUiComponent, and the generated catalog's
instructions carry them to the agent through genUiExampleInstructions.
Diagnostics #
All problems are reported as InvalidGenerationSourceError pointing at the
offending class or parameter: unsupported types (with the hint to use
@GenUiProp(ignore: true)), an empty description, ignore on a required
parameter or on a positional one that others would shift into, an unknown
named constructor, @GenUiAction on a non-callback, duplicate or invalid
property names, non-Widget or abstract classes, two classes whose lower-camel
names collide, inherited non-literal defaults, prefixed enums and missing
imports (mentioning prefixed imports when that is the cause). For data classes:
a Widget or callback field, a cycle (with the path), no usable constructor, a
class carrying both @GenUiWidget and @GenUiData, a generic data class, a
field whose wire key would be the reserved path or call, two data classes
whose generated names collide at the use site, a data class that is not visible
unprefixed, whose library has no generated part or that is imported with a
show / hide combinator hiding its generated declarations, and an unannotated
class of your own used as a parameter (the message names it and asks for
@GenUiData). A parameter whose type does not resolve at all is reported as
such — the cause is a missing import or an ambiguous name, not the type table.
On a2ui_flutter, a reserved property name, a data object a2ui_core would read
as an action and a list it would read as checks are errors that say what to
rename, and isImplicitlyFlexible is a warning. A package whose parts were
generated for different renderers is an error from the catalog builder, which
says to regenerate with --delete-conflicting-outputs.
Generated shape #
final GenUiComponent productCardCatalogItem = GenUiComponent(
name: 'ProductCard',
schema: GenUiSchemas.component(
description: 'A product card with price and image.',
properties: {
'title': GenUiSchemas.string(description: 'Product name.'),
'price': GenUiSchemas.number(description: 'Price in USD.'),
'imageUrl': GenUiSchemas.string(),
'onTap': GenUiSchemas.action(
description: 'Fired when the card is tapped.',
),
},
required: ['title', 'price'],
),
examples: [
r'''
[
{
"id": "root",
"component": "ProductCard",
"title": "Quarterly report",
"price": 19.99,
...
}
]''',
],
builder: (context, node, props, buildChild) => ProductCard(
title: props.string('title') ?? '',
price: props.number('price') ?? 0,
imageUrl: props.string('imageUrl'),
onTap: props.action('onTap'),
),
);
Testing the builder #
The package's own tests run with plain dart test; Flutter, genui,
a2ui_flutter and genui_gen are replaced by small in-memory stubs, with a
genui_gen built on each renderer, so the generator is exercised without a
Flutter SDK.
License #
MIT. Copyright Diego Alejandro López Camacho.