GenUI Meets the VGV Architecture

What the Official GenUI Samples Don't Show You

14 min read

GenUI with VGV architecture — side-by-side Flutter device mockups showing a plain chat interface transforming into a production-grade financial dashboard using Flutter's GenUI SDK

Flutter’s GenUI SDK is a framework for building generative UI — you define a catalog of widgets with JSON schemas, and an LLM decides which widgets to render, with what data, at runtime. The official samples are intentionally simple: they teach you how GenUI works, not how to architect a Flutter app around it. That’s the right call for a learning resource, but it leaves a gap when you’re building for production.

At Very Good Ventures, we built a Life Goal Simulator — a GenUI-powered financial planning assistant for Google Cloud Next 2026 that walks users through a personalized, multi-step conversation, rendering custom financial widgets in real time. The simulator uses the same GenUI SDK as the official samples, but the architecture we applied looks quite different.

This post walks through how we structured it, why we made the choices we did, and how VGV’s architecture principles apply to this new category of AI-driven UI.

The Starting Point: How the Samples Work

The official GenUI travel_app sample is a great way to learn the SDK. Its architecture is intentionally flat — everything lives in a single StatefulWidget:

// travel_planner_page.dart — the entire app in one file
class _TravelPlannerPageState extends State<TravelPlannerPage> {
  late final SurfaceController _surfaceController;
  late final A2uiTransportAdapter _adapter;
  late final Conversation _uiConversation;
  late final GoogleGenerativeAiClient _client;
  final _messages = ValueNotifier<List<ChatMessage>>([]);
  final _isProcessing = ValueNotifier<bool>(false);
  // ... setup, streaming, rendering all in one place
}

The widget creates the AI client, manages the conversation, handles streaming, and renders the UI. State lives in ValueNotifier. There’s no repository, no state management library, and no dependency injection. For understanding GenUI, this is ideal: read one file and you can understand the entire flow.

But in production, this structure creates problems:

  • Testability: You can’t unit test the conversation logic without rendering the entire widget tree.
  • Swappability: Changing the AI provider means rewriting the presentation layer.
  • Complexity management: As you add error handling, loading states, page navigation, and 26 custom widgets, one file becomes unmanageable.
  • Team collaboration: Multiple developers can’t work on the AI integration and the UI simultaneously without constant merge conflicts.

VGV’s Layered Architecture

VGV applies a four-layer architecture with strict unidirectional data flow — the same structure we use across every Flutter project.

VGV four-layer Flutter architecture diagram — Presentation, Business Logic, Repository, and Data layers with unidirectional data flow arrows, the architectural pattern applied to Flutter's GenUI SDK

Each layer only talks to the one directly below it. The presentation layer never touches the API client. The Bloc never imports Flutter (keeping it framework-agnostic). The repository never depends on other repositories.

This isn’t a GenUI-specific pattern — it’s the same architecture we use for every Flutter project. The insight is that GenUI doesn’t require a new architecture. The LLM is just another data source, and the GenUI SDK is just another API client. The same layered separation that works for REST APIs and databases works for generative AI.

Here’s how the Flutter GenUI architecture maps to the Life Goal Simulator:

lib/simulator/
├── view/               # Presentation layer
│   ├── simulator_page.dart
│   ├── simulator_view.dart
│   └── widgets/
├── bloc/               # Business logic layer
│   ├── display_message.dart
│   ├── simulator_bloc.dart
│   ├── simulator_event.dart
│   └── simulator_state.dart
├── repository/         # Repository layer
│   ├── simulator_repository.dart
│   └── simulator_conversation_event.dart
├── catalog/            # GenUI component definitions
│   ├── finance_catalog.dart
│   └── items/          # 26 custom catalog items
└── prompt/             # LLM prompt construction
    └── prompt.dart

To see how this works, let’s trace a message through each layer, starting from the bottom.

The Repository: Wrapping the GenUI SDK

SimulatorRepository dependency injection diagram — ChatModel, Catalog, SurfaceController, and ErrorReportingRepository injected into the central repository, which emits a typed event stream for the Bloc layer

The SimulatorRepository is where GenUI’s moving parts disappear behind a two-method API. It receives the Catalog and SurfaceController from the presentation layer (since those are rendering concerns), then wires them together with the A2uiTransportAdapter and PromptBuilder behind a clean interface:

class SimulatorRepository {
  SimulatorRepository({
    required ChatModel chatModel,
    required ErrorReportingRepository errorReporting,
    required Catalog catalog,
    required SurfaceController surfaceController,
  });

  Stream<SimulatorConversationEvent> get events => _controller.stream;

  Future<void> startConversation() async { /* ... */ }

  Future<void> sendMessage(String text) async { /* ... */ }

  Future<void> dispose() async { /* ... */ }
}

That’s the entire public API. Every dependency is injected:

  • ChatModel is the abstract interface from Dartantic. The SimulatorRepository doesn’t know whether it’s talking to Firebase AI, Google AI, Anthropic, or any other provider — it just calls sendStream(). The SimulatorPage decides which concrete implementation to use (currently FirebaseAIChatModel), making provider swaps a one-line change.
  • Catalog and SurfaceController are rendering concerns created by the SimulatorPage — the repository uses the catalog for prompt building and the controller for conversation wiring, but doesn’t own them.

startConversation() composes the system prompt, creates the transport adapter, and starts the conversation. sendMessage() sends a user message. The repository exposes a Stream<SimulatorConversationEvent> that the Bloc subscribes to.

This split is deliberate. The catalog defines what widgets can render — a presentation concern. The SurfaceController renders surfaces — also presentation. The ChatModel is the AI backend — a data concern owned by the composition root. The repository wires them together but creates none of them, staying focused on conversation management.

Domain Event Mapping

The key design decision is the domain event mapping. GenUI’s internal ConversationEvent types are implementation details — part of the A2UI protocol (Google’s Agent-to-UI standard) that the SDK uses under the hood. The repository translates them into domain-specific sealed classes:

sealed class SimulatorConversationEvent {
  const SimulatorConversationEvent();
}

final class SimulatorConversationWaiting extends SimulatorConversationEvent {
  const SimulatorConversationWaiting({required this.isWaiting});
  final bool isWaiting;
}

final class SimulatorConversationTextReceived extends SimulatorConversationEvent {
  const SimulatorConversationTextReceived(this.text);
  final String text;
}

final class SimulatorConversationSurfaceAdded extends SimulatorConversationEvent {
  const SimulatorConversationSurfaceAdded(this.surfaceId);
  final String surfaceId;
}

final class SimulatorConversationError extends SimulatorConversationEvent {
  const SimulatorConversationError(this.message);
  final String message;
}

This mapping matters because it creates a seam. If the GenUI SDK changes its event types (and it will — it’s in alpha), only the repository needs updating. The Bloc and presentation layer are completely insulated.

Inside the repository, the streaming pipeline handles the LLM-to-GenUI bridge:

Future<void> _handleSend(ChatMessage message) async {
  _history.add(_convertDataPartsToText(message));

  final messages = [
    ChatMessage.system(_systemPrompt),
    ..._history,
  ];

  final adapter = _conversation!.transport as A2uiTransportAdapter;
  final buffer = StringBuffer();

  try {
    await for (final result in _chatModel.sendStream(messages)) {
      final text = result.output.text;
      if (text.isNotEmpty) {
        buffer.write(text);
        adapter.addChunk(text);
      }
    }
    _history.add(ChatMessage.model(buffer.toString()));
  } on Object catch (e, st) {
    if (buffer.isNotEmpty) {
      // Save whatever was streamed so retry has full context.
      _history.add(ChatMessage.model(buffer.toString()));
    } else {
      // Zero chunks arrived — pop the dangling user message so history
      // doesn't end with two consecutive user turns on the next send,
      // which Firebase AI rejects as INVALID_ARGUMENT.
      _history.removeLast();
    }
    await _errorReporting.recordError(e, st, reason: 'AI sendStream error');
    _controller.add(SimulatorConversationError('AI error: $e'));
  }
}

Each chunk from the LLM is fed to the A2uiTransportAdapter, which parses the JSON and tells the SurfaceController to update the UI. The repository manages the message history, system prompt, and the streaming lifecycle — none of which the Bloc needs to know about.

You’ll notice the repository manages _history (the list of ChatMessages) directly rather than delegating to a separate data source. In a strict layered architecture, message history could be its own data layer concern — a local database or in-memory store that the repository composes. We kept it inline because the history is tightly coupled to the streaming lifecycle: each _handleSend call appends the outgoing message, streams the response, and appends the completed response in a single transaction. Extracting it would add a layer of indirection without a clear benefit today. If we later needed persistence across sessions, or multiple features sharing the same conversation history, that’s when the extraction would earn its keep.

The Bloc: Managing Conversation State

The SimulatorBloc consumes the repository’s event stream and manages the UI state. It follows VGV’s standard Bloc conventions — if you’ve worked with flutter_bloc, these patterns will look familiar: sealed events with past-tense naming, a single state class with a status enum and copyWith.

// Events — past tense, describing what happened
sealed class SimulatorEvent {
  const SimulatorEvent();
}
final class SimulatorStarted extends SimulatorEvent { /* ... */ }
final class SimulatorMessageSent extends SimulatorEvent { /* ... */ }
final class SimulatorSurfaceReceived extends SimulatorEvent { /* ... */ }
final class SimulatorContentReceived extends SimulatorEvent { /* ... */ }
final class SimulatorLoadingChanged extends SimulatorEvent { /* ... */ }
final class SimulatorErrorOccurred extends SimulatorEvent { /* ... */ }
final class SimulatorRetried extends SimulatorEvent { /* ... */ }
// ... plus loading overlay events
// State — Equatable + status enum + copyWith
final class SimulatorState extends Equatable {
  const SimulatorState({
    this.status = SimulatorStatus.initial,
    this.pages = const [],
    this.currentPageIndex = 0,
    this.isLoading = false,
    this.pendingPageIndex,
    this.showLoadingOverlay = false,
    this.error,
  });

  final SimulatorStatus status;
  final List<List<DisplayMessage>> pages;
  final int currentPageIndex;
  final bool isLoading;
  final int? pendingPageIndex;
  final bool showLoadingOverlay;
  final String? error;

  @override
  List<Object?> get props => [
    status, pages, currentPageIndex, isLoading,
    pendingPageIndex, showLoadingOverlay, error,
  ];
}

The state models the UI as a list of pages, where each page contains a list of display messages. Display messages are themselves a sealed hierarchy:

sealed class DisplayMessage extends Equatable {
  const DisplayMessage();
}
final class UserDisplayMessage extends DisplayMessage { /* ... */ }
final class AiTextDisplayMessage extends DisplayMessage { /* ... */ }
final class AiSurfaceDisplayMessage extends DisplayMessage { /* ... */ }

This is a GenUI-specific modeling choice. In a traditional chat app, you’d have a flat list of messages. Here, because GenUI surfaces are full-screen interactive UIs (not chat bubbles), each surface gets its own page. When the AI creates a new surface, the Bloc adds a new page and animates to it:

void _onSurfaceReceived(
  SimulatorSurfaceReceived event,
  Emitter<SimulatorState> emit,
) {
  final existingPageIndex = state.pages.indexWhere(
    (page) => page.any(
      (m) => m is AiSurfaceDisplayMessage && m.surfaceId == event.surfaceId,
    ),
  );

  if (existingPageIndex != -1) {
    // Surface already exists — navigate to it
    emit(state.copyWith(currentPageIndex: existingPageIndex));
  } else {
    // New surface — create a new page
    final message = AiSurfaceDisplayMessage(event.surfaceId);
    final pages = [...state.pages, <DisplayMessage>[message]];
    emit(state.copyWith(pages: pages, currentPageIndex: pages.length - 1));
  }
}

The Bloc also handles text streaming — as the LLM responds, text chunks arrive one at a time and get concatenated into the current page’s last text message. This is a detail the presentation layer doesn’t need to think about. For more on how Bloc works with stream subscriptions like this, see How to Use Bloc with Streams and Concurrency.

The Presentation Layer: Page Creates, View Renders

The presentation follows VGV’s standard Page/View split:

SimulatorPage is the composition root. It creates the Catalog and SurfaceController (rendering concerns), injects them into the repository, and passes the SurfaceController directly to the view — keeping GenUI types out of the Bloc entirely:

class SimulatorPage extends StatefulWidget {
  @override
  State<SimulatorPage> createState() => _SimulatorPageState();
}

class _SimulatorPageState extends State<SimulatorPage> {
  late final Catalog _catalog;
  late final SurfaceController _surfaceController;

  @override
  void initState() {
    super.initState();
    _catalog = buildFinanceCatalog();
    _surfaceController = SurfaceController(catalogs: [_catalog]);
  }

  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (_) =>
          SimulatorBloc(
            simulatorRepository: SimulatorRepository(
              chatModel: FirebaseAIChatModel(...),
              errorReporting: DevErrorReportingRepository()
              catalog: _catalog,
              surfaceController: _surfaceController,
            ),
          )..add(SimulatorStarted(...)),
      child: SimulatorView(
        profileType: widget.profileType,
        surfaceHost: _surfaceController,
      ),
    );
  }
}

The SurfaceController flows from the Page to the view and to the repository — but never through the Bloc. The Bloc state contains only plain Dart types: strings, enums, lists of DisplayMessage. No GenUI imports.

SimulatorView renders the UI based on Bloc state. It uses a BlocConsumerlistener for page navigation animations, builder for the widget tree:

BlocConsumer<SimulatorBloc, SimulatorState>(
  listenWhen: (previous, current) =>
      previous.currentPageIndex != current.currentPageIndex,
  listener: (context, state) {
    _pageController.animateToPage(
      state.currentPageIndex,
      duration: const Duration(milliseconds: 1200),
      curve: Curves.easeInOutCubic,
    );
  },
  builder: (context, state) {
    // Render pages with GenUI Surface widgets
  },
)

The view never touches the repository or the AI client. It reads pages from the Bloc state, renders Surface widgets from the GenUI SDK for each AiSurfaceDisplayMessage, and dispatches events when the user interacts.

The Catalog: Domain-Specific Components as a First-Class Concern

Life Goal Simulator financial widget catalog on iPhone — metric cards, line chart, and slider components from the 26-item Flutter GenUI catalog rendered as a production financial planning UI

The official samples define their catalog inline with the page. In the Life Goal Simulator, the catalog is a separate, structured layer with 26 custom financial widgets, each defined in its own file. (For an introduction to building catalog items, see VGV’s Flutter GenUI shopping assistant tutorial.)

catalog/
├── finance_catalog.dart      # Composes the full catalog
└── items/
    ├── metric_cards_catalog_item.dart
    ├── line_chart_catalog_item.dart
    ├── pie_chart_catalog_item.dart
    ├── gcn_slider_catalog_item.dart
    ├── radio_card_catalog_item.dart
    ├── filter_bar_catalog_item.dart
    └── ... (26 items total)

Each catalog item follows a consistent structure: a JSON schema (telling the LLM what properties it can set), and a widget builder (turning that JSON into Flutter widgets):

final _schema = S.object(
  description: 'A grid of cards highlighting key financial metrics...',
  properties: {
    'cards': S.list(
      items: S.object(
        properties: {
          'label': A2uiSchemas.stringReference(
            description: 'Short label (e.g. "Fixed costs").',
          ),
          'value': A2uiSchemas.stringReference(
            description: r'Primary value (e.g. "$4,319").',
          ),
          'delta': A2uiSchemas.stringReference(
            description: 'Optional delta text (e.g. "+1.2%").',
          ),
        },
        required: ['label', 'value'],
      ),
    ),
  },
);

final metricCardsItem = CatalogItem(
  name: 'MetricCard',
  dataSchema: _schema,
  widgetBuilder: (ctx) {
    final json = ctx.data as Map<String, Object?>;
    final rawCards = json['cards']! as List;
    // ... parse JSON, build MetricCard widgets
    return MetricCardsLayout(cards: cards);
  },
);

The buildFinanceCatalog() function composes the full catalog by starting with the SDK’s basic items, removing the generic ones we don’t need, and adding our domain-specific widgets:

Catalog buildFinanceCatalog() {
  return BasicCatalogItems.asCatalog()
      .copyWithout(
        itemsToRemove: [
          BasicCatalogItems.button,
          BasicCatalogItems.checkBox,
          BasicCatalogItems.slider,
          // ... remove 15 generic widgets
        ],
      )
      .copyWith(
        catalogId: financeCatalogId,
        newItems: [
          metricCardsItem,
          lineChartItem,
          pieChartItem,
          gcnSliderItem,
          // ... 26 domain-specific widgets
        ],
        systemPromptFragments: [_financeWidgetRules],
      );
}

This separation pays off in three ways:

  1. Each catalog item is independently testable. We have unit tests for every single one — verifying schema parsing, widget rendering, event dispatching, and data model bindings. These granular tests also serve as reliable feedback signals for CI pipelines and AI-assisted development workflows.
  2. The catalog is reusable. If we build another financial planning feature, we can import the same catalog without duplicating widget definitions.
  3. Prompt engineering is co-located with the schema. The _financeWidgetRules string tells the LLM when to use each widget, while the JSON schema tells it how. Both live in the catalog layer, not scattered across the app. Co-locating prompt fragments with their schemas reduces mismatches between what the LLM is told to render and what the catalog can actually build.

Prompt Management: Separated from UI

The system prompt is another concern that the official samples embed directly in the widget. In the Life Goal Simulator, prompt construction is its own module:

class PromptBuilder {
  /// The system prompt that defines the AI's persona and rules.
  static String buildSystemPrompt() {
    return r'''
    You are a knowledgeable, empathetic life goal simulator...

    ## Conversation Flow
    You drive the conversation step by step...

    ## Summary Screen (REQUIRED)
    After gathering enough information...
    ''';
  }

  /// The initial user message from onboarding selections.
  static String buildInitialUserMessage({
    required ProfileType profileType,
    Set<FocusOption> focusOptions = const {},
    String customOption = '',
  }) {
    // Compose the opening message based on user's profile
  }
}

This separation means the team working on prompt engineering doesn’t need to touch the widget code, and the prompt can be tested in isolation — verifying that different profile types and focus options produce the expected initial messages. If you later need to update prompts without redeploying, Firebase Remote Config or Firebase AI’s prompt templates are straightforward drop-in replacements — the PromptBuilder class gives you a single integration point.

Testability: Everything in Isolation

Flutter GenUI test architecture — three independent test layers showing widget tests with mocked Bloc, Bloc tests with mocked repository, and catalog item tests requiring no LLM or network connection

Because every layer is separated, every layer is independently testable. The test directory mirrors lib/ exactly:

test/simulator/
├── bloc/           # Bloc tests with mocked repository
├── catalog/items/  # Tests for all 26 catalog items
├── prompt/         # Prompt builder tests
└── view/           # Widget tests with mocked Bloc

Bloc tests mock the repository and verify state transitions using blocTest:

blocTest<SimulatorBloc, SimulatorState>(
  'SimulatorSurfaceReceived creates a new page',
  build: () {
    when(() => repository.events).thenAnswer((_) => Stream.empty());
    return SimulatorBloc(simulatorRepository: repository);
  },
  act: (bloc) => bloc.add(const SimulatorSurfaceReceived('surface-1')),
  expect: () => [
    SimulatorState(
      pages: [
        [const AiSurfaceDisplayMessage('surface-1')]
      ],
      currentPageIndex: 0,
    ),
  ],
);

Catalog item tests verify that each widget renders correctly from JSON data, without needing an LLM, a network connection, or the rest of the app.

Prompt tests verify the prompt builder produces correct output for different onboarding selections.

In the official sample, none of this is possible without rendering the entire TravelPlannerPage and intercepting network calls.

The Comparison

ConcernOfficial SampleVGV Architecture
AI client setupWidget’s initState()Repository constructor
Conversation stateValueNotifierSimulatorBloc with sealed events/states
GenUI plumbingInline in widgetSimulatorRepository
Rendering infrastructureCreated in widgetPage creates Catalog + SurfaceController, injects into repo and view
Domain eventsRaw ConversationEventSealed SimulatorConversationEvent
Display modelsMixed ChatMessage listSealed DisplayMessage hierarchy
CatalogInline in pageSeparate catalog/ layer with per-item files
System promptString literal in widgetDedicated PromptBuilder class
PresentationOne StatefulWidgetSimulatorPage (composition root) + SimulatorView (rendering)
Bloc purityN/ANo GenUI imports — only plain Dart types in state
Unit testabilityWidget tests onlyEvery layer independently testable
Custom widgetsFew, inline26 items, each in its own file with tests

Want to see this architecture in action? Try the Life Goal Simulator live demo.

Key Takeaways

GenUI doesn’t need a special architecture. The LLM is a data source. The GenUI SDK is an API client. The repository pattern wraps it. Bloc manages state. Widgets render. The same principles that make traditional Flutter apps maintainable work here too.

The repository is the critical abstraction. GenUI introduces several interacting components — Conversation, A2uiTransportAdapter, PromptBuilder. The repository hides the conversation plumbing behind startConversation() and sendMessage(). Every dependency is injected: the ChatModel (so you can swap LLM providers), the Catalog and SurfaceController (rendering concerns owned by the presentation layer). When the SDK evolves (and it will), only one file changes.

Keep SDK types out of your Bloc. It’s tempting to thread SurfaceHost through Bloc state so the view can access it. But the Bloc shouldn’t know about GenUI’s rendering types. Instead, the Page — which is already the composition root — creates the SurfaceController and passes it to both the repository and the view directly.

Domain events create a seam. Translating GenUI’s internal events into your own sealed classes protects the rest of your app from SDK changes and makes the event flow explicit and exhaustive.

Catalog items deserve their own layer. In the Life Goal Simulator, each of the 26 financial widgets has a schema, a builder, and behavioral rules. Giving each its own file makes them independently testable and reusable across features.

Prompt engineering is code. The Life Goal Simulator’s PromptBuilder class treats system prompts and initial messages with the same rigor as any other module — separate files, testable functions, version-controlled alongside the catalog they reference.

Conclusion

The official samples are the right place to learn how GenUI works. This post covered how to architect around it. The two together give you everything you need to take Flutter’s GenUI SDK from prototype to production.


The Life Goal Simulator was built by Very Good Ventures. Check out the live demo and our engineering practices.