In standard app development, the User Interface (UI) is static. You write code for a button, compile it, and it remains a button forever. GenUI flips this model on its head.

With GenUI, Google’s Generative UI SDK, your application's interface becomes dynamic. You don’t hard-code widget trees. Instead, you provide an AI agent, such as Google’s Gemini, with a "kit" of UI components called a Catalog and a goal. The AI then generates the UI in real time, deciding whether to display a slider, a text field, or a complex card based on the user’s needs at that moment.

This guide takes you from zero to a fully functional AI-powered Christmas Card Generator that does more than generate text. It also generates the actual Flutter widgets to display them.

Your Christmas Holiday Card Maker will use Generative UI and AI to create personalized, high-quality Christmas cards instantly. Users provide simple inputs such as the recipient’s name, relationship, and preferred color theme, and the AI dynamically produces a festive, polished card UI complete with heartfelt copy, seasonal styling, and structured layout.

By combining Generative UI’s reactive data model with custom catalog widgets, this project will show you how you can guide AI to produce consistent, production-ready user interfaces rather than loosely assembled components.

It’s important to note that the GenUI package is currently in Alpha and is highly experimental. Because it’s in the early stages of development, here is what you should keep in mind:

  • API Stability: The classes, method signatures, and overall architecture described in this guide are likely to change as the Flutter team gathers feedback from the community.

  • Safety and Guardrails: Since the UI is generated by an LLM, there is always a non-zero chance of "hallucinations" where the AI might attempt to use widgets or properties that don't exist in your catalog.

  • Production Readiness: While GenUI is incredibly exciting for prototyping and internal tools, it requires robust error handling and fallback UIs to ensure a seamless user experience if the AI service is unavailable or returns an invalid structure.

As you work through this guide, GenUI should be understood as a collaborative system rather than an autonomous one. You’re still responsible for defining the Catalog the AI can use, reviewing how those components are assembled, and testing the resulting interface in real scenarios.

This guide demonstrates GenUI in a guided setup, where Flutter provides structure and constraints, and the AI operates within them to dynamically assemble UI. The goal is not to remove developer judgment, but to shift it from hand-writing widget trees to designing, shaping, and validating the system that produces them.

Table of Contents

  1. Prerequisites

  2. The Mental Model: How GenUI Thinks

  3. Mapping GenUI Components to the Christmas Card App

  4. Why This Architecture Works

  5. Project Overview: What We’re Building

  6. Project Structure

  7. Building the View

  8. Adding Your Own Widgets to the GenUI Catalog

  9. Screenshots:

  10. Final Thoughts

  11. References

Prerequisites

To follow this guide effectively, you need:

  1. Flutter Development Environment: Flutter SDK installed (stable channel recommended) and an IDE like VS Code or Android Studio configured.

  2. Basic Flutter knowledge: You should understand how Widgets compose (Rows, Columns, Containers) and basic state management (setState or FutureBuilder).

  3. Google AI Studio API key: We will be using Google's Gemini model. You’ll need to get a free API key from Google AI Studio.

The Mental Model: How GenUI Thinks

Before writing any code, it’s important to understand how GenUI conceptually sees your app. GenUI doesn’t think in terms of widget trees or screens. It thinks in terms of surfaces, state, and conversations.

A surface is simply a place where AI-generated UI can appear. A conversation controls how those surfaces evolve over time. The data model holds the truth, and messages move everything forward.

Here’s the full flow in one pass:

User Action
   |
   v
GenUiConversation
   |
   v
ContentGenerator (AI)
   |
   v
A2uiMessage stream
   |
   v
GenUiManager
   |
   v
DataModel + UI Surfaces
   |
   v
GenUiSurface (Flutter rebuild)

Nothing in this flow bypasses Flutter. GenUI does not render UI “outside” Flutter – it only decides what Flutter should render.

Mapping GenUI Components to the Christmas Card App

Now let’s ground this in the Christmas card generator we’ll be building. This is where GenUI really clicks.

1. GenUiConversation in the Christmas Card App

In the project we’ll be building, GenUiConversation represents the ongoing interaction between the user and the Christmas card generator.

When the user types a loved one’s name, selects a relationship, chooses a color, and taps Generate Card, your app sends that prompt through GenUiConversation.

At that moment, GenUiConversation already knows the conversation history. It knows whether this is the first card being generated or whether the user is regenerating a card with a different message. This context is what allows the AI to create unique cards for each person instead of repeating generic output.

Without GenUiConversation, every request would be stateless. With it, the app feels intentional and personal.

2. Catalog as the Design Constraint

In the Christmas card app, the Catalog defines the visual language of your cards.

You might allow the AI to use text widgets for greetings, image widgets for festive backgrounds, container widgets for layout, and buttons for regeneration or sharing. What matters is that the AI cannot escape these constraints.

This is how you ensure that:

  • Cards always look like cards

  • The AI does not invent unsupported UI

  • Your app remains visually consistent

From the AI’s perspective, the catalog is the only toolbox it’s allowed to reach into. From your perspective, it’s the safety net that keeps the UI Flutter-native and predictable.

3. DataModel as the Heart of Personalization

The DataModel is where personalization actually lives.

In the project we’ll be building, values like the recipient’s name, the greeting message, the card theme, or even animation flags live in the data model. When the user edits the name or regenerates the card, only the parts of the UI bound to those values change.

This is why GenUI feels dynamic without being inefficient. You aren’t rebuilding the entire card screen – You’re only updating what depends on the changed data.

This also means the AI doesn’t need to recreate the whole UI every time. It can simply update the data model and let Flutter do what it does best.

4. ContentGenerator as the AI Gateway

The ContentGenerator is the only part of your app that knows how to talk to the AI.

In the Christmas card example, this component sends the user’s request to the model along with system instructions like “Generate a festive Christmas card UI using the available widgets.” It then listens as the AI responds.

Because the responses arrive as streams, the UI can begin rendering as soon as the first instructions arrive. This is especially useful if you later add animations or progressive reveals to your cards.

From a design standpoint, this separation is critical. Your Flutter app never depends directly on the AI SDK. It depends on GenUI, and GenUI depends on the ContentGenerator.

5. A2uiMessage as Intent, Not UI

This is one of the most important concepts to internalize: when the AI decides to generate a Christmas card, it doesn’t send Flutter widgets. Rather, it sends A2uiMessage instructions.

One message might say “start rendering a new surface.” Another might say “update the greeting text in the data model.” Another might say “replace the background image.”

These messages are processed by the GenUiManager, which translates intent into actual UI changes. This extra layer is what prevents GenUI from becoming fragile or unpredictable.

Why This Architecture Works

What makes GenUI powerful is not that it uses AI. Plenty of tools do that. What makes it powerful is that AI never breaks Flutter’s rules, because the state is centralized, rendering is controlled, events are explicit, and updates are incremental.

In the Christmas card app, this means every card feels custom, every interaction feels responsive, and your app remains maintainable even as the AI logic grows more complex.

Once you understand this flow, you stop thinking of GenUI as “AI generating UI” and start thinking of it as AI participating in your app’s state machine.

Project Overview: What We’re Building

In this tutorial, we’ll build a Christmas Card Generator using Flutter and GenUI. The idea is simple but intuitive: a user types a name, selects a relationship and a card color description, and the AI dynamically generates a Flutter widget tree that represents a personalized Christmas card.

This project demonstrates three core GenUI ideas working together: the conversation loop, AI-driven UI rendering, and reactive state updates without manual widget wiring.

By the end, you’ll understand not just how to use GenUI, but how to structure a real Flutter app around it.

Project Structure

We’ll keep the structure intentionally simple so it’s easy to follow and extend later.

lib/
 ├── extensions/
 │    ├── loading.dart
 ├── screen/
 │    ├── components/
 │    │    ├── color_picker_list.dart       // Widget for color selection
 │    │    ├── custom_input_section.dart    // Input form fields
 │    │    ├── error_section.dart           // Error message display
 │    │   
 │    ├── data/
 │    │    └── static_list_data.dart        // Hardcoded data or constants
 │    ├── card_generator_screen.dart        // Main UI logic for generating cards
 │    └── christmas_card.dart               // The specific card widget/view
 ├── firebase_options.dart                  // Firebase configuration file
 └── main.dart                              // App entry point

Step 1: Create a New Flutter Project

Start by creating a fresh Flutter app.

flutter create genui_christmas_card
cd genui_christmas_card

This gives us a clean baseline with Material 3 support and proper platform setup.

Step 2: Configure Your Agent Provider

genui can connect to a variety of agent providers. Choose the section below for your preferred provider.

Configure Firebase AI Logic

To use the built-in FirebaseAiContentGenerator to connect to Gemini via Firebase AI Logic, follow these instructions:

  1. Create a new Firebase project using the Firebase Console.

  2. Enable the Gemini API for that project.

  3. Follow the first three steps in Firebase's Flutter Setup to add Firebase to your app.

  4. Enable Gemini Developer API

    Firebase Dashboard

Step 3: Add Dependencies

GenUI is modular. You always install the core framework, then add a content generator that knows how to talk to your AI provider.

Open pubspec.yaml and update your dependencies:

dependencies:
  flutter:
    sdk: flutter

  genui: ^0.6.0
  logging: ^1.2.0
  genui_firebase_ai: ^0.6.0
  firebase_core: ^4.3.0
  loader_overlay: ^5.0.0
  flutter_spinkit: ^5.2.2

Then fetch the packages:

flutter pub get

At this point, your project has everything it needs to generate UI dynamically.

Step 4: Get a Google Gemini API Key

GenUI itself does not provide AI models. You’ll need to connect one. To do this, go to Google AI Studio, create a new API key, and copy it.

Important note: For real production apps, never hard-code API keys. Use --dart-define, environment variables, or a backend proxy.

Step 5: App Entry Point (main.dart)

Now we’ll begin writing real code.

Replace the contents of lib/main.dart with the following:

import 'package:flutter/material.dart';
import 'package:genui_flutter/screen/christmas_card.dart';
import 'package:logging/logging.dart';
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';

void main() async{
  // Enable verbose logging so we can see exactly
  // what the AI sends back to GenUI.
  Logger.root.level = Level.ALL;
  Logger.root.onRecord.listen((record) {
    debugPrint(
      '${record.level.name}: ${record.time}: ${record.message}',
    );
  });

    WidgetsFlutterBinding.ensureInitialized();
    await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
    runApp(const ChristmasCardApp());
}

This logging setup is optional, but highly recommended. When something goes wrong, logs are often the fastest way to understand why the AI didn’t generate what you expected.

The Root App Widget

Next, we define the root widget for our app.

import 'package:flutter/material.dart';
import 'package:loader_overlay/loader_overlay.dart';
import 'card_generator_screen.dart';
import 'package:flutter_spinkit/flutter_spinkit.dart';

class ChristmasCardApp extends StatelessWidget {
  const ChristmasCardApp({super.key});

  @override
  Widget build(BuildContext context) {
    return Directionality(
      textDirection: TextDirection.ltr,
      child: LoaderOverlay(
        overlayWholeScreen: true,
        overlayWidgetBuilder: (_) {
          return const Center(
            child: SpinKitWaveSpinner(color: Colors.red, size: 50.0),
          );
        },
        child: MaterialApp(
          title: 'GenUI Christmas Card Generator',
          theme: ThemeData(
            colorScheme: ColorScheme.fromSeed(
              seedColor: Colors.red,
              primary: Colors.red,
            ),
            useMaterial3: true,
          ),
          home: const CardGeneratorScreen(),
        ),
      ),
    );
  }
}

This is standard Flutter – nothing GenUI-specific yet. The real work happens inside CardGeneratorScreen.

Step 6: The Logic Controller (Stateful Screen)

This screen is where we wire together Flutter, Firebase AI, and the GenUI logic. It handles the user inputs (Name, Relationship, Color) and orchestrates the AI generation.

class CardGeneratorScreen extends StatefulWidget {
  const CardGeneratorScreen({super.key});

  @override
  State<CardGeneratorScreen> createState() => _CardGeneratorScreenState();
}

Now the state class, which holds all GenUI logic and form state:

class _CardGeneratorScreenState extends State<CardGeneratorScreen> {
  // 1. Form State Management
  final TextEditingController nameController = TextEditingController();
  String selectedRelationship = 'Friend';
  String selectedColorName = 'Gold';
  Color selectedColorUi = Colors.amber;

  // 2. GenUI Core Components
  late final A2uiMessageProcessor _a2uiMessageProcessor;
  late final FirebaseAiContentGenerator _contentGenerator;
  late final GenUiConversation _conversation;

  // 3. UI State
  String? currentSurfaceId;
  String? errorMessage;

The application manages user inputs through a form state that allows for dynamic prompt injection, while the _a2uiMessageProcessor acts as a decoder to convert raw AI data into specific Flutter widgets.

The backend connection is handled by the FirebaseAiContentGenerator, which manages system instructions and tool catalogs, while the _conversation object serves as a conductor to manage chat history and route data between the AI and the UI.

Finally, the currentSurfaceId tracks the specific widget tree being displayed, ensuring the GenUiSurface renders the correct AI-generated content.

Step 7: Initializing GenUI and Firebase

All setup happens in initState:

  @override
  void initState() {
    super.initState();
    // 1. Setup the Processor with allowed widgets
    _a2uiMessageProcessor = A2uiMessageProcessor(
      catalogs: [CoreCatalogItems.asCatalog()],
    );

    // 2. Configure the AI personality and rules
     _contentGenerator = FirebaseAiContentGenerator(
      catalog: CoreCatalogItems.asCatalog(),
      systemInstruction: '''
          You are an expert Festive UI Designer and Holiday Copywriter.

          YOUR GOAL: Generate a high-end, visually appealing Christmas card using the `surfaceUpdate` tool, suitable for printing or digital sharing. The card should feel personalized, warm, and festive.

          DESIGN GUIDELINES:
          - Layout: Use a vertical Column inside a Container with rounded corners, generous padding, and a border. Fill the Container with a color that **mixes Red with $selectedColorName ** to create a rich, holiday-themed background.
          - Typography: Use distinct font weights (Bold for headers, normal for body). Center all text.
          - Visuals: Include seasonal icons (🎄, ✨, ❄️) as decorative elements. Place a Christmas tree emoji strategically without overcrowding the layout.
          - Personalization: Display the recipient's name prominently in the middle of the card in a visually striking way.

          COPYWRITING GUIDELINES:
          - Create a deeply personal, heartfelt holiday message (3-4 sentences) that matches the relationship type (fun for friends, romantic for spouse, warm for family).
          - Include a proper closing/signature.
          - NEVER use placeholders. Always generate the **final text ready to display**.

          OUTPUT INSTRUCTIONS:
          - Use the `surfaceUpdate` tool to construct the UI.
          - Ensure all elements (Container, text, emojis) are visually aligned and harmonious.
          - The card must feel festive, elegant, and balanced.
          ''',
    );

    // 3. Start the conversation and listen for updates
    _conversation = GenUiConversation(
      contentGenerator: _contentGenerator,
      a2uiMessageProcessor: _a2uiMessageProcessor,
      onSurfaceAdded: _onSurfaceAdded,
      onSurfaceDeleted: _onSurfaceDeleted,
    );
  }

  void _onSurfaceAdded(SurfaceAdded update) {
    setState(() {
      currentSurfaceId = update.surfaceId;
    });
  }

In the initState method, we first configure the A2uiMessageProcessor with CoreCatalogItems, giving the AI access to standard widgets. Then, we initialize FirebaseAiContentGenerator.

Notice the systemInstruction: you are giving the AI two distinct roles here; "UI Designer" and "Copywriter." You explicitly tell it to write specific content based on relationships and design centered text.

Finally, we link them in GenUiConversation and attach a listener (_onSurfaceAdded). When the AI creates a new UI, we update currentSurfaceId inside setState, which tells Flutter to draw the new card.

Step: 8 Sending a Dynamic Prompt to the AI

This method kicks off the generation, using the user's form data to build a specific prompt.

  Future<void> generateCard() async {
    if (nameController.text.trim().isEmpty) {
      setState(() {
        errorMessage = "Please enter a name first!";
      });
      return;
    }
    FocusScope.of(context).unfocus();
    setState(() {
      errorMessage = null;
      currentSurfaceId = null;
    });

    try {
      context.showLoader();
       final prompt = '''
        Create a personalized Christmas card for my $selectedRelationship, ${nameController.text}.
        Theme: Blend Red and $selectedColorName for a festive background.
        Layout: Vertical Column in a rounded Container with padding and border; place the recipient's name prominently in the center.
        Visuals: Add Christmas trees (🎄), sparkles (✨), or snowflakes (❄️) where appropriate.
        Typography: Bold headers, normal body text, all centered.
        Message: Write a warm, personal 3-4 sentence holiday greeting that fits the relationship type, ending with a proper signature.
        Design: Make it look like an elegant, festive Christmas card ready to display or share.
        ''';


      await _conversation.sendRequest(UserMessage.text(prompt));
    } catch (e) {
      debugPrint('Error: $e');
      if (mounted) {
        setState(() {
          errorMessage = "Oops! Failed to create card.\nError: $e";
        });
      }
    } finally {
      if (mounted) {
        context.hideLoader();
      }
    }
  }

The generateCard method is where prompt engineering meets code. First, it validates that a name exists. Then, it constructs a multi-line string using String Interpolation ($selectedRelationship, $selectedColorName). Instead of a generic request, you are sending a detailed brief: "Make a card for my Mom named Alice using Gold colors."

Finally, _conversation.sendRequest fires this prompt to Firebase. We wrap this in a try/catch block to handle network errors gracefully by showing the error message in the UI.

Building the View

Now we’ll render the complex UI using the helper components we created in the components/ folder. Here’s the code – but don’t worry, we’ll cover every custom component individually after this.

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('🎄 Holiday Card Maker')...),
      body: Stack(
        children: [
          Column(
            children: [
              // 1. The Input Form (Refactored into a component)
              CustomInputSection(
                nameController: nameController,
                selectedRelationship: selectedRelationship,
                selectedColorName: selectedColorName,
                selectedColorUi: selectedColorUi,
                onColorSelected: onColorSelected,
                generateCard: generateCard,
                selectRelationship: selectRelationship,
              ),

              const Divider(height: 1),

              // 2. The GenUI Drawing Area
              Expanded(
                child: Container(
                  color: Colors.grey[100],
                  child: currentSurfaceId != null
                      ? GenUiSurface(
                          host: _conversation.host,
                          surfaceId: currentSurfaceId!,
                        )
                      : const Center(child: Text('Fill in details...')),
                ),
              ),
            ],
          ),


          if (errorMessage != null)
            ErrorSection(errorMessage: errorMessage!, clearError: clearError),
        ],
      ),
    );
  }
}

In the build method, we use a Stack to allow us to float the LoadingWidget and ErrorSection on top of the main content.

Instead of writing all the input logic here, you used CustomInputSection. This keeps the main screen clean and focused on AI orchestration.

The bottom half of the screen contains the GenUiSurface. If currentSurfaceId exists, it renders the AI's widget tree using _conversation.host. If not, it shows a placeholder instruction.

At this point, you’ve seen the full build() method that renders the screen. Notice that the screen itself does very little visual work directly. Instead, it composes the UI from smaller, focused widgets and helper files. This is intentional.

Rather than cramming form fields, color selectors, error handling, and constants into a single screen file, the UI is split into clear, purpose-driven folders. Each folder represents a UI concern, not a state-management layer or architectural pattern.

In the next sections, we’ll walk through these folders one by one, showing how each piece contributes to the final screen you just built. You’ll see where reusable widgets live, where static UI data is defined, and how the main screen ties everything together without becoming cluttered.

Folder: lib/screen/data/

This folder holds the static data used to populate dropdowns and color lists.

StaticListData: lib/screen/data/static_list_data.dart

import 'package:flutter/material.dart';

class StaticListData {
  // List of relationships for the dropdown menu
  static final List<String> relationships = [
    'Husband',
    'Wife',
    'Son',
    'Daughter',
    'Grandma',
    'Grandpa',
    'Uncle',
    'Aunt',
    'Friend',
    'Relative',
    'Cousin',
    'Grandson',
    'Granddaughter',
    'Mom',
    'Dad',
  ];

  // Map of color names to actual Flutter Color objects
  static final Map<String, Color> colorOptions = {
    'Gold': Colors.amber,
    'Green': Colors.green,
    'Blue': Colors.blue,
    'Purple': Colors.deepPurple,
    'Silver': Colors.grey,
    'Yellow': Colors.yellow,
    'Pink': Colors.pink,
  };
}

This class serves as a central repository for constant data, housing the relationships list to allow for easy UI updates, such as adding "Colleague" or "Neighbor", without modifying core code, and the colorOptions map, which translates user-friendly names like "Gold" into functional Color objects like Colors.amber for styling.

Folder: lib/extensions/

This folder holds the static data used to populate dropdowns and color lists.

LoaderOverlayExtension: lib/extensions/loading.dart

import 'package:flutter/material.dart';
import 'package:loader_overlay/loader_overlay.dart';

extension LoaderOverlayExtension on BuildContext {
  void showLoader() {
    loaderOverlay.show();
  }

  void hideLoader() {
    loaderOverlay.hide();
  }
}

The LoaderOverlayExtension adds two methods to any BuildContext object: showLoader(), which displays a LoaderOverlay, and hideLoader(), which hides it. This allows you to call context.showLoader() or context.hideLoader() anywhere in your widgets without directly referencing loaderOverlay every time, improving readability and reducing boilerplate whenever a loading state needs to be displayed.

Folder: lib/screen/components/

This folder contains reusable UI components that are used specifically on screens in your app, particularly the CardGeneratorScreen. These are smaller, modular widgets that encapsulate a part of the UI, making the main screen code cleaner, easier to read, and maintainable.

ErrorSection: error_section.dart

import 'package:flutter/material.dart';

class ErrorSection extends StatelessWidget {
  final String errorMessage;
  final VoidCallback clearError;

  const ErrorSection({
    super.key,
    required this.errorMessage,
    required this.clearError,
  });

  @override
  Widget build(BuildContext context) {
    return Container(
      // High opacity background to block out the UI behind it
      color: Colors.white.withOpacity(0.95),
      child: Center(
        child: Padding(
          padding: const EdgeInsets.all(32.0),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              const Icon(Icons.error_outline, color: Colors.red, size: 60),
              const SizedBox(height: 16),
              // Displays the specific error message passed from the parent
              Text(
                errorMessage,
                textAlign: TextAlign.center,
                style: const TextStyle(fontSize: 16, color: Colors.red),
              ),
              const SizedBox(height: 20),
              // Button to dismiss the error
              ElevatedButton(
                onPressed: () {
                  clearError();
                },
                child: const Text("Try Again"),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

This robust error-handling view utilizes a large red icon and descriptive text to clearly signal an issue, while incorporating a clearError callback that triggers when the "Try Again" button is clicked to reset the parent state's errorMessage variable and dismiss the view.

ColorPickerList: color_picker_list.dart

import 'package:flutter/material.dart';

class ColorPickerList extends StatelessWidget {
  const ColorPickerList({
    super.key,
    required String selectedColorName,
    required Color selectedColorUi,
    required Map<String, Color> colorOptions,
    required this.onColorSelected,
  })  : _selectedColorName = selectedColorName,
        _colorOptions = colorOptions;

  final String _selectedColorName;
  final Map<String, Color> _colorOptions;
  final void Function(String colorName, Color colorUi) onColorSelected;

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      height: 85,
      // Horizontal scrolling list for colors
      child: ListView(
        scrollDirection: Axis.horizontal,
        physics: const BouncingScrollPhysics(),
        children: _colorOptions.entries.map((entry) {
          final isSelected = _selectedColorName == entry.key;

          return GestureDetector(
            onTap: () {
              // Pass the selected color back to the parent
              onColorSelected(entry.key, entry.value);
            },
            child: Container(
              margin: const EdgeInsets.only(right: 15),
              width: 50,
              child: Column(
                mainAxisSize: MainAxisSize.min,
                crossAxisAlignment: CrossAxisAlignment.center,
                children: [
                  // Outer ring animation
                  AnimatedContainer(
                    duration: const Duration(milliseconds: 250),
                    padding: const EdgeInsets.all(3),
                    decoration: BoxDecoration(
                      shape: BoxShape.circle,
                      // Show border only if selected
                      border: Border.all(
                        color: isSelected ? entry.value : Colors.transparent,
                        width: 2.5,
                      ),
                    ),
                    // Inner color circle
                    child: Container(
                      width: 35,
                      height: 35,
                      decoration: BoxDecoration(
                        color: entry.value,
                        shape: BoxShape.circle,
                        boxShadow: [
                          if (isSelected)
                            BoxShadow(
                              color: entry.value.withOpacity(0.3),
                              blurRadius: 6,
                              offset: const Offset(0, 3),
                            ),
                        ],
                        border: Border.all(color: Colors.white, width: 2),
                      ),
                    ),
                  ),
                  const SizedBox(height: 6),
                  // Color name label
                  Text(
                    entry.key,
                    textAlign: TextAlign.center,
                    maxLines: 1,
                    overflow: TextOverflow.ellipsis,
                    style: TextStyle(
                      fontSize: 10,
                      color: isSelected ? entry.value : Colors.grey[600],
                      fontWeight:
                          isSelected ? FontWeight.bold : FontWeight.normal,
                    ),
                  ),
                ],
              ),
            ),
          );
        }).toList(),
      ),
    );
  }
}

This horizontal list of color circles uses a ListView with scrollDirection: Axis.horizontal to allow users to swipe through various options, while an AnimatedContainer provides polished visual feedback by animating the outer border into view over 250ms when a color is tapped.

The widget also incorporates selection logic that checks the isSelected state to determine whether to display bold text and a colored border, clearly indicating the user's current choice.

CustomInputSection custom_input_section.dart

import 'package:flutter/material.dart';
import '../data/static_list_data.dart';
import 'color_picker_list.dart';

class CustomInputSection extends StatelessWidget {
  final TextEditingController nameController;
  final String selectedRelationship;
  final String selectedColorName;
  final Color selectedColorUi;
  final void Function(String colorName, Color colorUi) onColorSelected;
  final VoidCallback generateCard;
  final Function selectRelationship;

  const CustomInputSection({
    super.key,
    required this.nameController,
    required this.selectedRelationship,
    required this.selectedColorName,
    required this.selectedColorUi,
    required this.onColorSelected,
    required this.generateCard,
    required this.selectRelationship,
  });

  @override
  Widget build(BuildContext context) {
    return Container(
      decoration: BoxDecoration(
        color: Colors.white,
        boxShadow: [
          BoxShadow(
            color: Colors.black.withOpacity(0.05),
            blurRadius: 10,
            offset: const Offset(0, 5),
          ),
        ],
      ),
      child: LayoutBuilder(
        builder: (context, constraints) {
          bool isSmallScreen = constraints.maxWidth < 600;

          return Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Padding(
                padding: const EdgeInsets.symmetric(horizontal: 18.0,vertical: 20),
                child: Flex(
                  direction: isSmallScreen ? Axis.vertical : Axis.horizontal,
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Expanded(
                      flex: isSmallScreen ? 0 : 3,
                      child: SizedBox(
                        width: isSmallScreen ? double.infinity : null,
                        child: TextField(
                          controller: nameController,
                          decoration: const InputDecoration(
                            labelText: "Name (e.g., Alice)",
                            prefixIcon: Icon(Icons.person),
                            border: OutlineInputBorder(),
                            contentPadding: EdgeInsets.symmetric(
                              horizontal: 12,
                              vertical: 8,
                            ),
                          ),
                        ),
                      ),
                    ),
                    // Dynamic spacer
                    isSmallScreen
                        ? const SizedBox(height: 12)
                        : const SizedBox(width: 10),
                    Expanded(
                      flex: isSmallScreen ? 0 : 2,
                      child: SizedBox(
                        width: isSmallScreen ? double.infinity : null,
                        child: DropdownButtonFormField<String>(
                          initialValue: selectedRelationship,
                          decoration: const InputDecoration(
                            labelText: 'Relationship',
                            border: OutlineInputBorder(),
                            contentPadding: EdgeInsets.symmetric(
                              horizontal: 12,
                              vertical: 8,
                            ),
                          ),
                          items: StaticListData.relationships.map((String rel) {
                            return DropdownMenuItem(value: rel, child: Text(rel));
                          }).toList(),
                          onChanged: (val) => selectRelationship(val),
                        ),
                      ),
                    ),
                  ],
                ),
              ),
              const SizedBox(height: 20),
              Padding(
                padding: const EdgeInsets.only(left: 18.0),
                child: Text(
                  "Pick a theme color:",
                  style: TextStyle(
                    color: Colors.grey[700],
                    fontWeight: FontWeight.bold,
                  ),
                ),
              ),
              const SizedBox(height: 8),

              Padding(
                padding: const EdgeInsets.only(left: 16.0),
                child: Flex(
                  direction: isSmallScreen ? Axis.vertical : Axis.horizontal,
                  crossAxisAlignment: isSmallScreen
                      ? CrossAxisAlignment.stretch
                      : CrossAxisAlignment.center,
                  children: [
                    isSmallScreen
                        ? ColorPickerList(
                            selectedColorName: selectedColorName,
                            selectedColorUi: selectedColorUi,
                            colorOptions: StaticListData.colorOptions,
                            onColorSelected: onColorSelected,
                          )
                        : Expanded(
                            child: ColorPickerList(
                              selectedColorName: selectedColorName,
                              selectedColorUi: selectedColorUi,
                              colorOptions: StaticListData.colorOptions,
                              onColorSelected: onColorSelected,
                            ),
                          ),

                    if (isSmallScreen) const SizedBox(height: 16),

                    // Generate Button
                    Padding(
                      padding: const EdgeInsets.all(18.0),
                      child: SizedBox(
                        width: isSmallScreen ? double.infinity : null,
                        child: ElevatedButton.icon(
                          onPressed: generateCard,
                          style: ElevatedButton.styleFrom(
                            backgroundColor: Colors.red,
                            foregroundColor: Colors.white,
                            padding: const EdgeInsets.symmetric(
                              horizontal: 24,
                              vertical: 16,
                            ),
                            shape: RoundedRectangleBorder(
                              borderRadius: BorderRadius.circular(8),
                            ),
                          ),
                          icon: const Icon(Icons.auto_awesome),
                          label: const Text(
                            "Generate Card",
                            style: TextStyle(fontWeight: FontWeight.bold),
                          ),
                        ),
                      ),
                    ),
                  ],
                ),
              ),
            ],
          );
        },
      ),
    );
  }
}

As the most complex component in the architecture, this widget aggregates all inputs by utilizing a LayoutBuilder to monitor parent constraints, dynamically switching the Flex direction between Axis.horizontal for tablets and web and Axis.vertical for mobile stacking when the maxWidth is less than 600.

To ensure a seamless layout across devices, it leverages Expanded on large screens to fill the available space while using SizedBox(width: double.infinity) on smaller screens to force inputs to the full width of the device, all while maintaining clean code by integrating the ColorPickerList and StaticListData.

Adding Your Own Widgets to the GenUI Catalog

So far in this project, we’ve relied entirely on the widgets provided by CoreCatalogItems. These include common UI building blocks like Text, Column, Container, and Image, which are enough to get surprisingly rich results.

But GenUI really shines when you teach the AI about your own domain-specific widgets.

In our case, we’re not just generating arbitrary UI – we’re generating high-end, personalized Christmas cards. That makes this a perfect candidate for a custom catalog item.

Instead of hoping the AI assembles the perfect layout every time from primitive widgets, we can introduce a first-class “Holiday Card” widget and let the model generate data for it.

Why Add a Custom Widget?

In the current implementation, the AI generates festive UIs using general-purpose widgets, which works but leads to inconsistent card structure, repeated styling instructions, and excessive layout freedom.

By introducing a custom widget into the catalog, layout and styling decisions are encoded directly in Flutter. This allows the AI to focus on content and personalization while producing more predictable, production-ready results.

Step 1: Adding json_schema_builder

To define a custom widget, GenUI needs to know what data it accepts. You can tell it this using a JSON Schema.

Add json_schema_builder as a dependency, using the same repository reference as GenUI:

dependencies:
  json_schema_builder:
    git:
      url: https://github.com/flutter/genui.git
      path: packages/json_schema_builder

This ensures schema compatibility with the GenUI runtime.

Step 2: Defining the Holiday Card Schema

A Christmas card in our app needs a few core pieces of data:

  • The recipient’s name

  • The relationship (friend, spouse, family, and so on)

  • The message body

  • A closing signature

Using json_schema_builder, we can define this explicitly:

final holidayCardSchema = S.object(
  properties: {
    'recipientName': S.string(
      description: 'Name of the person receiving the card',
    ),
    'relationship': S.string(
      description: 'Relationship to the recipient (friend, spouse, family)',
    ),
    'message': S.string(
      description: 'Main heartfelt holiday message',
    ),
    'signature': S.string(
      description: 'Closing signature for the card',
    ),
  },
  required: [
    'recipientName',
    'relationship',
    'message',
    'signature',
  ],
);

This schema becomes the contract between your Flutter app and the AI.

Step 3: Creating the CatalogItem

Each custom widget is registered as a CatalogItem. This ties together:

  • A name (used by the AI)

  • The schema

  • A widget builder that renders Flutter UI

Here’s what a HolidayCard catalog item might look like:

final holidayCardItem = CatalogItem(
  name: 'HolidayCard',
  dataSchema: holidayCardSchema,
  widgetBuilder: (context) {
    final name = context.dataContext.subscribeToString(
      context.data['recipientName'] as Map<String, Object?>?,
    );
    final message = context.dataContext.subscribeToString(
      context.data['message'] as Map<String, Object?>?,
    );
    final signature = context.dataContext.subscribeToString(
      context.data['signature'] as Map<String, Object?>?,
    );

    return ValueListenableBuilder<String?>(
      valueListenable: name,
      builder: (context, recipientName, _) {
        return ValueListenableBuilder<String?>(
          valueListenable: message,
          builder: (context, body, _) {
            return ValueListenableBuilder<String?>(
              valueListenable: signature,
              builder: (context, signOff, _) {
                return Container(
                  margin: const EdgeInsets.all(24),
                  padding: const EdgeInsets.all(24),
                  decoration: BoxDecoration(
                    color: Colors.white,
                    borderRadius: BorderRadius.circular(20),
                    border: Border.all(color: Colors.redAccent),
                  ),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.center,
                    children: [
                      const Text(
                        '🎄 Merry Christmas 🎄',
                        style: TextStyle(
                          fontSize: 24,
                          fontWeight: FontWeight.bold,
                        ),
                      ),
                      const SizedBox(height: 16),
                      Text(
                        'Dear ${recipientName ?? ''},',
                        style: const TextStyle(fontSize: 18),
                      ),
                      const SizedBox(height: 12),
                      Text(
                        body ?? '',
                        textAlign: TextAlign.center,
                      ),
                      const SizedBox(height: 24),
                      Text(
                        signOff ?? '',
                        style: const TextStyle(fontWeight: FontWeight.w600),
                      ),
                    ],
                  ),
                );
              },
            );
          },
        );
      },
    );
  },
);

Notice how no state is stored in the widget itself. Everything comes from the GenUI data model.

Step 4: Registering the Widget in Your App

Now we’ll plug the custom widget into your existing setup.

In your initState, instead of using only CoreCatalogItems, extend the catalog:

_a2uiMessageProcessor = A2uiMessageProcessor(
  catalogs: [
    CoreCatalogItems.asCatalog().copyWith([
      holidayCardItem,
    ]),
  ],
);

This makes HolidayCard available to the AI.

Step 5: Teaching the AI to Use the Widget

Finally, we’ll update the system instruction so the AI knows when and how to use the new widget.

In your existing FirebaseAiContentGenerator, the instruction can be refined like this:

      _contentGenerator = FirebaseAiContentGenerator(
      catalog: CoreCatalogItems.asCatalog(),
      systemInstruction: '''
          You are an expert Festive UI Designer and Holiday Copywriter.

          YOUR GOAL: Generate a high-end, visually appealing Christmas card using the `surfaceUpdate` tool, suitable for printing or digital sharing. The card should feel personalized, warm, and festive.

          DESIGN GUIDELINES:
          - Layout: Use a vertical Column inside a Container with rounded corners, generous padding, and a border. Fill the Container with a color that **mixes Red with $selectedColorName ** to create a rich, holiday-themed background.
          - Typography: Use distinct font weights (Bold for headers, normal for body). Center all text.
          - Visuals: Include seasonal icons (🎄, ✨, ❄️) as decorative elements. Place a Christmas tree emoji strategically without overcrowding the layout.
          - Personalization: Display the recipient's name prominently in the middle of the card in a visually striking way.

          COPYWRITING GUIDELINES:
          - Create a deeply personal, heartfelt holiday message (3-4 sentences) that matches the relationship type (fun for friends, romantic for spouse, warm for family).
          - Include a proper closing/signature.
          - NEVER use placeholders. Always generate the **final text ready to display**.

          OUTPUT INSTRUCTIONS:
          - Use the `surfaceUpdate` tool to construct the UI.
          - Ensure all elements (Container, text, emojis) are visually aligned and harmonious.
          - The card must feel festive, elegant, and balanced. When generating a Christmas card, always use the HolidayCard widget.
          ''',
    );

Now the AI isn’t guessing – it’s explicitly guided toward your custom widget.

How This Fits into Your Existing Screen

This integration requires no structural changes to your existing CardGeneratorScreen: GenUiConversation continues to manage the interaction lifecycle, GenUiSurface still handles rendering, and your input form remains fully responsible for shaping the prompt. The only change is what the AI is allowed to generate, which significantly improves control and consistency.

By adding custom widgets to the GenUI catalog, your application moves from AI assembling loosely defined UI fragments to AI populating structured, production-ready components, resulting in a cleaner interface, stronger visual identity, reduced prompt engineering, and far more predictable outputs. This is the point where GenUI stops feeling like a demo and starts functioning as a real product framework.

Screenshots:

App Screenshot 1 - Entry

App Screenshot 2 - Error State

App Screenshot 3 - Color Choosing

App Screenshot 4 - Loading State

App Screenshot 1 - Successfuly showing the christmas card

Final Thoughts

This project demonstrates how you can take advantage of GenUI in its most practical form: not merely as a tech demo, but as a functional Flutter paradigm that bridges the gap between static code and user intent.

By shifting the responsibility of layout orchestration from the developer to an intelligent agent, we unlock a level of personalization that was previously not possible in mobile development.

Once you master the Conversation Loop (how the AI thinks), Surfaces (how the AI draws), and Catalog Boundaries (what the AI is allowed to use), GenUI becomes a transformative addition to your Flutter toolkit. It allows you to build interfaces that aren't just "responsive" to screen sizes, but "responsive" to human needs.

As an early adopter, you are on the cutting edge of AI-Generated User Interfaces. Your explorations and feedback will help shape the future of how we build apps in the era of generative intelligence. You can find the complete project on Github here.

References

  1. Flutter Team. GenUI: Build AI-powered user interfaces in Flutter. GitHub repository.
    Available at: https://github.com/flutter/genui/

  2. Flutter Documentation. Getting started with GenUI.
    Available at: https://docs.flutter.dev/ai/genui/get-started

  3. Dart & Flutter Ecosystem. genui package. pub.dev.
    Available at: https://pub.dev/packages/genui

  4. Dart & Flutter Ecosystem. genui_firebase_ai package. pub.dev.
    Available at: https://pub.dev/packages/genui_firebase_ai