genui_gen 0.8.1
genui_gen: ^0.8.1 copied to clipboard
Annotations and runtime helpers to generate genui CatalogItems from your own Flutter widgets. Derive the schema from the constructor so the catalog can never drift.
genui_gen #
Annotate a Flutter widget you already have. The
genui CatalogItem an agent composes
against — JSON schema, widget builder and few-shot example — is derived from
the widget's constructor, so it cannot drift from the widget it describes.
See it end to end, with a recorded agent session you can step through →
In one screen #
You write this:
@GenUiWidget(description: 'A product card with price and image.')
class ProductCard extends StatelessWidget {
const ProductCard({super.key, required this.title, required this.price, this.onTap});
/// Product name.
final String title;
/// Price in USD.
final double price;
/// Fired when the card is tapped.
final VoidCallback? onTap;
// ...
}
build_runner derives the schema from the constructor — the property names
are the parameter names, the required list is the set of non-nullable
parameters without defaults, the descriptions come from the doc comments — and
writes the builder and the example too. The model may then send:
{
"id": "root",
"component": "ProductCard",
"title": "Noise-cancelling headphones",
"price": { "path": "/cart/0/price" },
"onTap": { "event": { "name": "onTap" } }
}
Every property takes a literal or a {"path": ...} binding, because the
generated builder composes genui's own BoundString, BoundNumber,
BoundBool, BoundList and BoundObject. Actions dispatch a
UserActionEvent exactly the way genui's core Button does.
Rename the parameter and the generated part changes in review, or the build fails. There is no second source of truth to keep in sync.
Install #
dependencies:
genui: ^0.10.0
genui_gen: ^0.8.0
dev_dependencies:
build_runner: ^2.15.0
genui_gen_builder: ^0.7.0
This package is the runtime half of the pair: the annotations, and the helpers
the generated code calls. The generator itself lives in
genui_gen_builder and belongs in
dev_dependencies.
The generated code is a part of your file and builds its schema with
S.object(...), so it needs that name in scope. genui_gen re-exports S,
Schema and ObjectSchema from json_schema_builder for exactly that, which
is why you do not depend on it directly. If S collides with another
one-letter name in a file — a generated localization class, say — import
genui_gen there with hide S.
Annotate #
lib/widgets/product_card.dart:
import 'package:flutter/material.dart';
import 'package:genui/genui.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,
required this.title,
required this.price,
this.imageUrl,
this.onTap,
});
/// Product name.
final String title;
/// Price in USD.
final double price;
/// Optional image URL.
final String? imageUrl;
/// Fired when the card is tapped.
final VoidCallback? onTap;
@override
Widget build(BuildContext context) => /* ... */ const SizedBox();
}
dart run build_runner build
Register the catalog #
The builder also writes lib/genui_catalog.g.dart, holding every annotated
item in the package, sorted by name. Registering a catalog stays one line
whatever the app grows into:
import 'genui_catalog.g.dart';
final catalog = genUiCatalog.copyWith(
newItems: BasicCatalogItems.asCatalog().items.toList(),
);
final controller = SurfaceController(catalogs: [catalog]);
The catalog's id comes from build.yaml, because it names your catalog to
everything outside the build — the agent that composes against it, the client
that renders it — which is not something a generator can invent:
targets:
$default:
builders:
genui_gen_builder:genui_catalog:
options:
catalog_id: com.example.app
Keep genui's basic catalog in the mix: the generated examples reference the
core Text component for child widgets.
What a property can be #
| Dart parameter type | Schema emitted | Passed to the constructor as |
|---|---|---|
String, String? |
A2uiSchemas.stringReference |
the resolved String |
int, double, num (+?) |
A2uiSchemas.numberReference |
converted with toInt() / toDouble() |
bool, bool? |
A2uiSchemas.booleanReference |
the resolved bool |
any enum (+?) |
A2uiSchemas.stringReference(enumValues: ...) |
E.values.asNameMap()[value] |
List<String> (+?) |
A2uiSchemas.stringArrayReference |
List<String> |
List<int>, List<double>, List<num> (+?) |
A2uiSchemas.listOrReference(items: S.number()) |
entries converted per element |
List<E> (+?) for an enum E |
listOrReference carrying the enum's names |
one E per entry; an unknown name is dropped |
a @GenUiData class (+?) |
oneOf of its object schema, a data binding and a function call |
the decoded instance |
List<T> (+?) where T is @GenUiData |
A2uiSchemas.listOrReference(items: <T schema>) |
one decoded T per entry |
Widget, Widget? |
A2uiSchemas.componentReference |
ctx.buildChild(id) |
List<Widget> (+?) |
list of component references, or a template — see below | one ctx.buildChild per id or per entry |
VoidCallback, void Function() (+?) |
A2uiSchemas.action |
a callback that dispatches a UserActionEvent |
void Function(T) marked @GenUiWrites |
nothing; it is not a property | a callback that writes the user's value into the data model |
Key? key, super.key |
skipped | not passed |
| anything else | build error naming the widget, parameter and type | — |
A required property that arrives missing or malformed does not throw during
build. The builder substitutes a fallback and reports the problem once per
component through ctx.reportError, as an A2uiValidationException, so the
model sees the widget and property names.
The full rules live in
genui_gen_builder's README.
Controls the user operates #
A plain property is read-only: the model puts a value there and the widget
displays it. A control has to report the new value back, which in A2UI means
writing it into the surface's data model — what genui's own TextField,
Slider and CheckBox do. @GenUiWrites gives an annotated widget the same
ability:
@GenUiWidget(description: 'One preference the user can turn on or off.')
class PreferenceRow extends StatelessWidget {
const PreferenceRow({
super.key,
required this.label,
required this.enabled,
@GenUiWrites('enabled') this.onChanged,
});
final String label;
final bool enabled;
final ValueChanged<bool>? onChanged;
// ...
}
The callback is not a schema property — the model never supplies it. The model
binds enabled to a path and reads the user's answer back from the same path.
Lists the data model fills #
A list of children is normally written out by the agent, one id at a time.
That works until the list is the data: five tasks today, nine tomorrow, and
a new surface composed every time one is added. Mark the property
template: true and it accepts the other shape A2UI allows:
@GenUiProp(template: true) required this.rows, // List<Widget>
{
"id": "root", "component": "TaskList", "title": "Today",
"rows": { "componentId": "task_row", "path": "/tasks" }
}
One row is built per entry at /tasks, each reading its own entry, keyed by
entry rather than by position. A new entry adds a row with nobody asked. It is
off by default, and the property still accepts a plain list of ids.
Structured data #
Scalars only get you so far. Faking a table with parallel arrays (labels,
values, trends) invites the model to emit three arrays of different
lengths. @GenUiData marks a plain Dart class as a shape the model may emit,
so a widget property can be that class or a List of it:
@GenUiData(description: 'One row of a comparison table.')
class ComparisonRow {
const ComparisonRow({required this.label, required this.value, this.trend});
/// Text shown in the first column.
final String label;
/// Numeric value shown in the second column.
final double value;
/// Direction of the change, if known.
final Trend? trend;
}
@GenUiWidget(description: 'A comparison table.')
class ComparisonTable extends StatelessWidget {
const ComparisonTable({super.key, required this.rows});
/// The rows to display.
final List<ComparisonRow> rows;
// ...
}
The object schema is inlined into the widget schema and a decoder is generated
per class. A data class holds data, not components: its fields may be the
scalar types above, other @GenUiData classes, or lists of them. Widget and
callback fields are a build error, and so is a data class that reaches itself,
because schemas are inlined rather than referenced.
Annotations #
| Annotation | Target | Purpose |
|---|---|---|
@GenUiWidget(description:, name:, constructor:, isImplicitlyFlexible:) |
class | Marks a widget as a catalog component. description is required. |
@GenUiData(description:, constructor:) |
class | Marks a plain data class a widget property may take. |
@GenUiProp(description:, name:, ignore:, template:) |
parameter or field | Overrides the schema property; ignore: true excludes it, template: true lets a List<Widget> repeat over a data path. |
@GenUiAction(eventName:, description:) |
parameter or field | Customizes a VoidCallback action. |
@GenUiWrites('property') |
parameter or field | Makes a one-argument callback write the user's value back to that property's path. |
Descriptions default to the parameter's doc comment, then the field's doc comment.
Three libraries #
| Import | What it is for |
|---|---|
package:genui_gen/genui_gen.dart |
the annotations, the runtime helpers the generated code calls, and genUiCatalogJson |
package:genui_gen/testing.dart |
record what a component exposes to a screen reader and fail when it changes; audit the catalog; diff it against what you published; weigh what it costs the prompt |
package:genui_gen/tracing.dart |
record a real agent session and replay it with no model and no network |
The catalog as a document #
Inside the app genui puts the catalog in the prompt for you. Everything outside this Flutter process needs it as a document: an agent written in Python, a second client rendering the same surfaces in SwiftUI, a review that has to answer what the model was allowed to ask for last Tuesday.
genUiCatalogJson(catalog, {title, description}) returns the A2UI
catalog.json document — the shape A2UI publishes for its own basic catalog,
with catalogId, components, functions and the $defs a renderer resolves
a component against. It throws when the catalog has no catalogId, since a
surface names the catalog it was built against.
Generate it from a test, so the checked-in file cannot fall behind the widgets
and a reviewer sees what a new @GenUiWidget exposed to the model:
test('catalog.json describes the generated catalog', () {
final file = File('catalog.json');
final json = '${genUiCatalogJsonString(genUiCatalog)}\n';
if (autoUpdateGoldenFiles) file.writeAsStringSync(json);
expect(file.readAsStringSync(), json);
});
flutter test test/catalog_json_test.dart --update-goldens
What the component exposes #
The schema half of a catalog is checked when it is generated. The other half — what the rendered component says to the person using it — has nothing checking it, and it is the half a Dart diff does not show.
recorded[item.name] = genUiRenderedSemantics();
// ...
expect(genUiSemanticsGolden(recorded, File('test/genui_semantics.json')), isNull);
GenUiExampleSurface renders an item's generated example through a real
SurfaceController, so the recording covers schema, bindings and actions
together. Re-record a deliberate change with GENUI_UPDATE_GOLDENS=1. Role,
name, value, state and actions in traversal order is the shape A2UI's rendering
cases use, so the same file also says what a renderer of your catalog on
another platform would have to reproduce.
Recording a session #
Someone reports that the confirm button did nothing. You open the code and there is no confirm button: a model composed that screen, once, from a context that will not come back.
final recorder = GenUiTraceRecorder.attach(
controller,
catalogId: genUiCatalog.catalogId,
redact: const ['/user/email'],
);
// ...
await File('bug-4821.a2ui-trace').writeAsString(recorder.build().encode());
The trace keeps every message the agent sent, the data model each time it
changed — including writes the user made that never went back to the agent —
and every action the app reported. GenUiTracePlayer and GenUiTraceView put
it back on screen at any step, with no model and no network, because A2UI
describes interfaces as data. That is how last week's session becomes this
week's regression test. redact names the paths a recording must not keep.
Checking the contract and the cost #
genUiCatalogDiff reports what changed for the model between two catalogs,
and which of those changes break a message the agent still knows how to write.
genUiSemanticsAudit reads the semantics recording and reports a control with
nothing to announce, a component that reaches assistive technology as nothing
at all, and two controls that announce themselves identically.
genUiCatalogWeight says how much of every prompt each component takes up.
Runtime helpers #
Generated code uses these; you normally do not call them yourself.
GenUiBindingsresolves a map ofGenUiBindings against aDataContextand calls a builder once with aGenUiValues. It composes genui'sBoundString,BoundNumber,BoundBool,BoundListandBoundObject, so literals,{"path": ...}data bindings and{"call": ...}function calls behave exactly as in the core catalog and rebuild when the data model changes.GenUiValues.objectandGenUiValues.objectListexpose the resolved data objects; a value of the wrong shape reads asnull, and a list entry that is not a map is skipped rather than throwing.GenUiDecoder<T>is the signature of the generated function that rebuilds a@GenUiDataclass from one resolved map.genUiActionHandler(ctx, actionData)returns aVoidCallbackthat performs an A2UI action the way the coreButtondoes:eventactions dispatch aUserActionEventwithsourceComponentIdset to the component id, andfunctionCallactions resolve through theDataContext. Returnsnullwhen the action data isnull, and never throws. Malformed action data is reported as anA2uiValidationException, so the model receives the actual message.genUiValueWriterbacks@GenUiWrites: it writes the user's value to the path the model bound the property to, or to<componentId>.<property>when the model sent a literal. A write the data model refuses is reported throughctx.reportErrorrather than thrown out of a gesture handler.genUiTemplateChildrenandgenUiTemplatePathbacktemplate: true: one child per entry at the path, each with that entry as its own data context, keyed by entry rather than by position.genUiReportMissing(ctx, component, property)reports a required property the model omitted as anA2uiValidationException, once per component instance. It stays silent for{"path": ...}and{"call": ...}bindings that have not resolved yet, because those rebuild on their own once the data model is populated.genUiAsString,genUiAsNum,genUiAsBool,genUiAsStringList,genUiAsNumList,genUiAsObjectandgenUiAsObjectListcoerce one raw JSON value the way genui'sBound*widgets coerce a widget property. Generated decoders call them instead of casting, so a model that sends a number where a string was declared degrades exactly as it would for a widget property rather than throwing aTypeErrorinsidebuild.GenUiMissingFieldReporter,genUiMissingFieldandgenUiNestedFieldcarry the same reporting down into a data object: the generated widget builder hands the decoder a reporter, so a required field the model left out of a row reaches the model asrows.labelinstead of being silently replaced.
Compatibility #
genui ^0.10.0 · Flutter >=3.35.0 · Dart >=3.10.0 <4.0.0
genui lives in flutter/genui and is
pre-1.0; its CatalogItem, A2uiSchemas and binding APIs still move between
minor versions. This package tracks genui and bumps its constraint when genui
breaks.
License #
MIT. Copyright Diego Alejandro López Camacho.