# A GenUI Tutorial: Building a Flutter Home Screen Your AI Composes

> A hands-on walkthrough of catalog, transport, surface, and interactions, with real code from a working demo.

- Source: https://verygood.ventures/blog/flutter-genui-home-screen-tutorial/
- Published: 2026-05-27
- Author: John Weidner
- Tags: Flutter, GenUI, Tutorial, Dart, VGV

---

The first time you see a Flutter home screen assembled by an AI instead of by you, something clicks. You didn't write `Column(children: [...])`. You described widgets, wrote a system prompt, and Gemini picked which widgets to render and in what order. The screen feels personal because the model composed it for the user sitting in front of it, right now, for this context.

![A Happy Health Smart home screen composed by Gemini at runtime, greeting the user by name and stacking four cards tailored to the moment: a "Today's Snapshot" card with steps, water, and weight; a "Healthier Hydration" card; a "Happier Rituals" card with a button to play daily puzzles; and a "Smarter Projects" reflection card](/assets/images/blog/flutter-genui-home-screen-tutorial/happy-health-smart-home.avif)

We built [`genui_demo`](https://github.com/JohnWeidner/genui_demo) as a working example: a small wellness app whose home screen Gemini composes on every refresh from a curated Flutter widget catalog. This post walks through the four pieces you need to stand up your own GenUI surface. Catalog, transport, surface, and interactions. Real code, from the repo.

We've covered the [ideas](https://verygood.ventures/blog/getting-started-with-genui/) and the [architecture](https://verygood.ventures/blog/flutter-genui-meets-the-vgv-architecture/) in earlier posts. This one is the tutorial.

## What we're building

`genui_demo` is a personal wellness app built around three pillars: happier, healthier, smarter. The home screen is not a static widget tree. On every pull-to-refresh, the app gathers the user's health snapshot, daily rhythms, and preferences into a system prompt, hands that prompt and a widget catalog to Gemini, and renders whatever composition comes back.

The stack:

- `genui: ^0.8.0` — catalog, surface, conversation, transport
- `google_generative_ai: ^0.4.7` — Gemini client
- `flutter_bloc` — state management around the GenUI service
- `flutter_secure_storage` — on-device API key

Everything runs through one service and one cubit. No Firebase, no backend. The Gemini API key lives on device.

A word on scope. This demo is built to help you see how GenUI works, not to be a reference architecture for production GenUI apps. The layering is deliberately light so each concept stays legible. If you want a full architectural breakdown with layered state, deferred navigation, and a bespoke widget catalog, read [the GenUI wizard we built for GCN](https://verygood.ventures/blog/generative-ui-flutter-wizard-gcn/) next.

## Step 1: Define the catalog

The catalog is your vocabulary. Each entry tells Gemini "here is a widget you can render, here is its JSON schema, and here is how I'll build it." The SDK ships `BasicCatalogItems` with primitives like Text, Column, Button, and TextField. You add your own custom items on top.

Here's the one custom item this demo ships. It's a "wish card" Gemini renders when it wants to show something outside the catalog:

```dart
final featureRequestCatalogItem = CatalogItem(
  name: 'FeatureRequest',
  dataSchema: S.object(
    description:
        'A polite placeholder the model renders when it wants to show '
        'something this client does not yet support.',
    properties: {
      'title': S.string(description: 'Short label. 2-8 words.'),
      'rationale': S.string(description: '1-2 sentences. Optional.'),
      'proposedShape': S.string(description: 'One-line sketch. Optional.'),
      'pillar': S.string(
        description: 'Which pillar the wish serves. Optional.',
        enumValues: _pillars,
      ),
    },
    required: ['title'],
  ),
  widgetBuilder: (itemContext) {
    final data = _FeatureRequestData.fromMap(itemContext.data as JsonMap);
    return _FeatureRequestCard(data: data);
  },
  exampleData: [() => '''...'''],
);
```

Three things to notice.

First, the schema is the contract. `json_schema_builder` gives you a fluent DSL. Descriptions matter. Gemini reads them and uses them to decide when to render your widget, so be specific and concrete.

Second, `widgetBuilder` is plain Flutter. GenUI hands you typed data and you return a widget. The extension type `_FeatureRequestData.fromMap` is a lightweight adapter that typechecks the JSON without copying it.

Third, `exampleData` teaches by example. This is the single highest-leverage field in a catalog item. When Gemini is unsure how to use your component, it reaches for your examples.

The catalog itself composes the basic items, your custom items, and any extra system prompt rules:

```dart
abstract final class AppCatalog {
  AppCatalog._();

  static Catalog build() {
    final base = BasicCatalogItems.asCatalog();
    return base.copyWith(
      newItems: [featureRequestCatalogItem],
      systemPromptFragments: [
        ...base.systemPromptFragments,
        _customCatalogRules,
      ],
    );
  }
}
```

Prompt fragments are how you teach Gemini the rules for your catalog. When to reach for `FeatureRequest`. Which event names buttons can dispatch. Treat them as part of the API.

## Step 2: Wire Gemini to a Conversation

The SDK's `Conversation` object is transport-agnostic. It speaks in ChatMessages and expects JSON chunks back. `GeminiGenUiService` in this demo is the glue that turns `google_generative_ai` into something `Conversation` can drive:

```dart
class GeminiGenUiService {
  GeminiGenUiService({
    required String apiKey,
    required this.systemPrompt,
    required Catalog catalog,
    String modelId = 'gemini-3.5-flash',
    this.onInteraction,
  }) : _model = GenerativeModel(
          model: modelId,
          apiKey: apiKey,
          systemInstruction: Content.text(systemPrompt),
        ) {
    controller = SurfaceController(catalogs: [catalog]);
    transport = A2uiTransportAdapter(onSend: _handleSend);
    conversation = Conversation(
      controller: controller,
      transport: transport,
    );
  }
  // ...
}
```

Three collaborators show up here. `SurfaceController` owns the live surfaces and data models. `A2uiTransportAdapter` converts streaming text chunks into surface operations. `Conversation` is the high-level API the app talks to through `sendRequest(message)`.

The `_handleSend` callback is where Gemini actually runs. The adapter hands you a ChatMessage. You feed it to `generateContentStream`. You pipe each chunk back with `transport.addChunk(chunkText)`:

```dart
Future<void> _handleSend(ChatMessage message) async {
  // forward any UI interactions to onInteraction
  final userContent = Content.user([TextPart(message.text)]);
  _history.add(userContent);

  final responseStream = _model.generateContentStream(_history);

  final modelBuffer = StringBuffer();
  await for (final chunk in responseStream) {
    if (_disposed) break;
    final chunkText = chunk.text;
    if (chunkText == null || chunkText.isEmpty) continue;
    modelBuffer.write(chunkText);
    transport.addChunk(chunkText);
  }
  if (!_disposed) {
    _history.add(Content.model([TextPart(modelBuffer.toString())]));
  }
}
```

Streaming is first-class. Your user sees the dashboard render progressively, widget by widget, as Gemini's JSON arrives. You get this by pushing chunks into the transport rather than waiting for the full response.

## Step 3: Render the surface

Once the service is alive, ask for a composition and wait for a surface ID:

```dart
final surfaceCompleter = Completer<String>();
_eventsSub = service.conversation.events.listen((event) {
  if (event is ConversationSurfaceAdded) {
    _rebindDataPaths(service, event.surfaceId);
    if (!surfaceCompleter.isCompleted) {
      surfaceCompleter.complete(event.surfaceId);
    }
  }
});

unawaited(service.sendUserMessage(userMessage));
final surfaceId = await surfaceCompleter.future
    .timeout(const Duration(seconds: 45));
```

The `SurfaceController` emits `ConversationSurfaceAdded` once Gemini sends the first `createSurface` chunk. Handing that surface ID to a `Surface` widget is all you need to render:

```dart
Surface(controller: service.controller, surfaceId: surfaceId)
```

### Client-owned data paths

Reactive state is where GenUI earns its keep. `DataModel` lets your app and the model share a path tree. Gemini's widgets read and write paths like `/today/captures_count` or `/drafts/note`, and your app binds Flutter streams to those same paths.

Here's how the demo exposes a live counter of today's captured notes to any widget Gemini renders:

```dart
void _rebindDataPaths(GeminiGenUiService service, String surfaceId) {
  _unbindDataPaths();
  final dataModel = service.controller.store.getDataModel(surfaceId);
  _unbindCaptures = dataModel.bindExternalState<int>(
    path: DataPath(DataPaths.todayCapturesCount),
    source: _liveData.todayCapturesCount,
  );
}
```

Any `Text` component Gemini renders with `BoundString` or `BoundNumber` on `/today/captures_count` now reflects the app's live value. Rewrite the counter on disk, call `_liveData.refresh()`, and every widget bound to that path updates on the next frame.

The seed matters too. Before the first surface is composed, the app tells Gemini what paths exist and what values they currently hold:

```dart
JsonMap _buildClientDataModelSeed() {
  return <String, Object?>{
    'today': <String, Object?>{
      'captures_count': _liveData.todayCapturesCount.value,
    },
    'drafts': <String, Object?>{
      'note': '',
    },
  };
}
```

Gemini sees the path tree in the system prompt and can render widgets bound to any of it from turn one.

## Step 4: Handle interactions

Buttons Gemini renders dispatch events back to your app. The demo listens through `onInteraction`:

```dart
GeminiGenUiService(
  apiKey: key,
  systemPrompt: systemPrompt,
  catalog: catalog,
  onInteraction: _handleInteraction,
)
```

`_handleInteraction` routes well-known event names to persistence code. Capturing a note into the event log. Recording an answered question. Marking a daily puzzle done. The constraint lives in the catalog's system prompt fragment:

```text
INTERACTION EVENT NAMES you can dispatch from Buttons:
- note_captured — TextField + submit Button capture pattern.
- answer_submitted — Slider / ChoicePicker + submit Button pattern.
- puzzle_played — "Mark done" button on a daily-puzzle card.
Any other event name you dispatch is passed through unchanged
and has NO local side effects.
```

Gemini now knows exactly which interaction names your app handles and what context payload each expects. This is the same principle as designing a REST API. A short, well-documented vocabulary beats a wide one.

Two more pieces finish the loop. First, the allowed operations policy. This demo lets Gemini create a surface once and then update it in place:

```dart
final prompt = PromptBuilder.custom(
  catalog: catalog,
  allowedOperations: SurfaceOperations.createAndUpdate(dataModel: false),
  systemPromptFragments: [...],
  clientDataModel: _buildClientDataModelSeed(),
);
```

`dataModel: false` keeps writes client-owned. Gemini can read paths but cannot rewrite them, which preserves a single source of truth for anything sensitive.

Second, the event subscription keeps listening after the first surface arrives. Follow-up turns from Gemini usually come in as `ConversationComponentsUpdated` events. The `Surface` widget handles those internally, so the cubit does not re-emit state. The UI just updates.

## Inside the app: four inspectors

When we built `genui_demo`, we wanted a first-class way to see what was happening at each layer. The drawer labeled "Inside the app" isn't a debug mode hidden behind a build flag. It's a feature, always on, because one of the app's goals is to teach how GenUI works. Four pages, one per concept, each tied to something earlier in this post.

![The "Inside the app" drawer in the demo, showing four learning pages — GenUI catalog, Conversation log, Prompt inspector, and Data model — each with a short description of what it surfaces](/assets/images/blog/flutter-genui-home-screen-tutorial/inside-drawer.avif)

### GenUI catalog

The first drawer entry lists every catalog item the model is allowed to compose, rendered with its own example payload. It uses `DebugCatalogView` from the `genui` package, so the page updates automatically the moment you register a new `CatalogItem` in `AppCatalog.build()`. When you're wondering "does the model know about this widget yet?", this page is the answer. It's also where the `exampleData` you wrote in Step 1 earns its keep — the card for each item shows exactly what Gemini sees when it's deciding whether to use your component.

### Conversation log

Every event the live `Conversation` emits lands here: `surfaceAdded`, `componentsUpdated`, `surfaceRemoved`, streamed text chunks, waiting states, and errors. Entries are newest-first and tap to expand. This is the page that makes the streaming behavior from Step 2 visible. You can watch `Streamed text` entries arrive in real time as Gemini composes, then see `Surface added` fire when the first `createSurface` chunk lands. On follow-up turns it's usually `Components updated`, which confirms the `createAndUpdate` policy from Step 4 is doing what we asked.

### Prompt inspector

The exact system prompt the app sent on the most recent refresh. The page shows:

- Capture time and character count
- The user message that kicked off the turn
- Each labeled fragment (`PERSONA`, `USER_PROFILE`, `HEALTH_CONTEXT`, `DATA_PATHS`, `SURFACE_UPDATES`, and the others) with its own Copy button
- The full joined prompt, monospace, at the bottom

This is the single most useful page for tuning behavior. When the model makes a surprising choice, the prompt inspector tells you exactly what it saw when it made it. Tweak a fragment in the cubit, refresh, and compare the new capture side-by-side with the old one.

### Data model

A live view of the active surface's `DataModel`, which is the shared path tree from Step 3. Every known path gets a row with its current value, description, and source. Below that, the raw JSON tree prints every key the surface currently contains, updated in real time.

When a note is captured and `LiveDataStore.refresh()` runs, `/today/captures_count` ticks up on this page without any reload. That's `bindExternalState` working in public. Any widget the model renders with `BoundString` or `BoundNumber` on the same path is reacting to the same values you're watching here.

The four pages line up with the four steps in this post. Catalog to Step 1. Conversation log to Step 2. Prompt inspector to the system prompt you build before handing it to the service. Data model to the paths you bind in Step 3. If you fork the repo and add one of your own catalog items, one data path, or one interaction handler, the drawer is where you'll watch your change light up.

## Make it your own

Fork the [repo](https://github.com/JohnWeidner/genui_demo), drop your Gemini API key into Settings, and run it. Then try this progression.

Add a catalog item. Pick something small: a badge, a streak card, a single-stat tile. Write the schema, write the builder, add a rule fragment that tells Gemini when to reach for it. Watch Gemini pick it up on the next refresh.

Add a data path. Expose a value from your app with `bindExternalState`. Ask Gemini to render it in a Text widget. Watch it stay in sync.

Add an interaction. Give a Button a new event name, route it in `onInteraction`, and persist something. Teach Gemini about it in a prompt fragment.

Each step is independently useful, and each one strengthens the mental model. The catalog is your vocabulary. The surface is your canvas. The data model is your shared state. Interactions are your write path. Get those four right and GenUI stops feeling like magic and starts feeling like a platform you build on.
