1. Introducción
En este codelab, compilarás una app de lista de tareas con Flutter, Firebase AI Logic y el nuevo paquete genui. Comenzarás con una app de chat basada en texto, la actualizarás con GenUI para darle al agente la capacidad de crear su propia IU y, por último, compilarás tu propio componente de IU interactivo y personalizado que tú y el agente puedan manipular directamente.

Actividades
- Compilar una interfaz de chat básica con Flutter y Firebase AI Logic
- Integrar el paquete
genuipara generar superficies basadas en IA - Agregar una barra de progreso para indicar cuándo la app está esperando una respuesta del agente
- Crear una superficie con nombre y mostrarla en un lugar específico de la IU
- Compilar un componente de catálogo de GenUI personalizado que te permita controlar cómo se presentan las tareas
Requisitos
- Un navegador web, como Chrome
- El SDK de Flutter instalado de forma local
- El Firebase CLI instalado y configurado
Este codelab está destinado a desarrolladores de Flutter de nivel intermedio.
2. Antes de comenzar
Configura el proyecto de Flutter
Abre la terminal y ejecuta flutter create para crear un proyecto nuevo:
flutter create intro_to_genui
cd intro_to_genui
Agrega las dependencias necesarias a tu proyecto de Flutter:
flutter pub add firebase_core firebase_ai genui json_schema_builder
La sección dependencies final debería verse así (los números de versión pueden variar ligeramente):
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.8
firebase_core: ^4.5.0
firebase_ai: ^3.9.0
genui: ^0.8.0
json_schema_builder: ^0.1.3
Ejecuta flutter pub get para descargar todos los paquetes.
Habilita las APIs y Firebase
Para usar el paquete firebase_ai, primero debes habilitar Firebase AI Logic en tu proyecto.
- Ve a Firebase AI Logic en Firebase console.
- Haz clic en Get started para iniciar el flujo de trabajo guiado.
- Sigue las instrucciones en pantalla para configurar tu proyecto.
Para obtener más información, consulta las instrucciones para agregar Firebase a una app de Flutter.
Una vez que las APIs estén activas, inicializa Firebase en tu app de Flutter con FlutterFire CLI:
flutterfire configure
Selecciona tu proyecto de Firebase y sigue las instrucciones para configurarlo para las plataformas objetivo (por ejemplo, Android, iOS, la Web). Este codelab se puede completar solo con el SDK de Flutter y Chrome instalados en tu máquina, pero la app también funcionará en otras plataformas.
3. Crea una estructura para una interfaz de chat básica
Antes de presentar la IU generativa, tu app necesita una base: una aplicación de chat básica basada en texto potenciada por Firebase AI Logic. Para comenzar rápidamente, copiarás y pegarás toda la configuración de la interfaz de chat.

Crea el widget de burbuja de mensaje
Para mostrar mensajes de texto del usuario y el agente, tu app necesita un widget. Crea un archivo nuevo llamado lib/message_bubble.dart y agrega la siguiente clase:
import 'package:flutter/material.dart';
class MessageBubble extends StatelessWidget {
final String text;
final bool isUser;
const MessageBubble({super.key, required this.text, required this.isUser});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final bubbleColor = isUser
? colorScheme.primary
: colorScheme.surfaceContainerHighest;
final textColor = isUser
? colorScheme.onPrimary
: colorScheme.onSurfaceVariant;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6.0, horizontal: 8.0),
child: Column(
crossAxisAlignment: isUser
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: isUser
? MainAxisAlignment.end
: MainAxisAlignment.start,
children: [
Flexible(
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 12.0,
),
decoration: BoxDecoration(
color: bubbleColor,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(20),
topRight: const Radius.circular(20),
bottomLeft: Radius.circular(isUser ? 20 : 0),
bottomRight: Radius.circular(isUser ? 0 : 20),
),
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(20),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
gradient: isUser
? LinearGradient(
colors: [
colorScheme.primary,
colorScheme.primary.withAlpha(200),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
)
: null,
),
child: Text(
text,
style: theme.textTheme.bodyLarge?.copyWith(
color: textColor,
height: 1.3,
),
),
),
),
],
),
const SizedBox(height: 2),
],
),
);
}
}
MessageBubble es un StatelessWidget que muestra un solo mensaje de chat. Se usará más adelante en este codelab para mostrar mensajes tuyos y del agente, pero, en su mayoría, es solo un widget Text sofisticado.
Implementa la IU de chat en main.dart
Reemplaza todo el contenido de lib/main.dart por esta implementación completa de chatbot de texto:
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_ai/firebase_ai.dart';
import 'package:intro_to_genui/message_bubble.dart';
import 'firebase_options.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Just Today',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
),
home: const MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key});
@override
State<MyHomePage> createState() => _MyHomePageState();
}
sealed class ConversationItem {}
class TextItem extends ConversationItem {
final String text;
final bool isUser;
TextItem({required this.text, this.isUser = false});
}
class _MyHomePageState extends State<MyHomePage> {
final List<ConversationItem> _items = [];
final _textController = TextEditingController();
final _scrollController = ScrollController();
late final ChatSession _chatSession;
@override
void initState() {
super.initState();
final model = FirebaseAI.googleAI().generativeModel(
model: 'gemini-3-flash-preview',
);
_chatSession = model.startChat();
_chatSession.sendMessage(Content.text(systemInstruction));
}
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
}
@override
void dispose() {
_textController.dispose();
_scrollController.dispose();
super.dispose();
}
Future<void> _addMessage() async {
final text = _textController.text;
if (text.trim().isEmpty) {
return;
}
_textController.clear();
setState(() {
_items.add(TextItem(text: text, isUser: true));
});
_scrollToBottom();
final response = await _chatSession.sendMessage(Content.text(text));
if (response.text?.isNotEmpty ?? false) {
setState(() {
_items.add(TextItem(text: response.text!, isUser: false));
});
_scrollToBottom();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: const Text('Just Today'),
),
body: Column(
children: [
Expanded(
child: ListView(
controller: _scrollController,
padding: const EdgeInsets.all(16),
children: [
for (final item in _items)
switch (item) {
TextItem() => MessageBubble(
text: item.text,
isUser: item.isUser,
),
},
],
),
),
SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Row(
children: [
Expanded(
child: TextField(
controller: _textController,
onSubmitted: (_) => _addMessage(),
decoration: const InputDecoration(
hintText: 'Enter a message',
),
),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: _addMessage,
child: const Text('Send'),
),
],
),
),
),
],
),
);
}
}
const systemInstruction = '''
## PERSONA
You are an expert task planner.
## GOAL
Work with me to produce a list of tasks that I should do today, and then track
the completion status of each one.
## RULES
Talk with me only about tasks that I should do today.
Do not engage in conversation about any other topic.
Do not offer suggestions unless I ask for them.
Do not offer encouragement unless I ask for it.
Do not offer advice unless I ask for it.
Do not offer opinions unless I ask for them.
## PROCESS
### Planning
* Ask me for information about tasks that I should do today.
* Synthesize a list of tasks from that information.
* Ask clarifying questions if you need to.
* When you have a list of tasks that you think I should do today, present it
to me for review.
* Respond to my suggestions for changes, if I have any, until I accept the
list.
### Tracking
* Once the list is accepted, ask me to let you know when individual tasks are
complete.
* If I tell you a task is complete, mark it as complete.
* Once all tasks are complete, send a message acknowledging that, and then
end the conversation.
''';
El archivo main.dart que acabas de copiar y pegar configura un ChatSession básico con Firebase AI Logic y la instrucción en systemInstruction. Administra los turnos de conversación manteniendo una lista de elementos TextItem y mostrándolos junto con las consultas del usuario con el widget MessageBubble que creaste antes.
Aquí tienes algunas cosas que debes consultar antes de continuar:
- El método
initStatees donde se configura la conexión a Firebase AI Logic. - La app ofrece un
TextFieldy un botón para enviar mensajes al agente. - El método
_addMessagees donde se envía el mensaje del usuario al agente. - La lista
_itemses donde se almacena el historial de conversaciones. - Los mensajes se muestran en un
ListViewcon el widgetMessageBubble.
Prueba la app
Con esto en su lugar, ahora puedes ejecutar la app y probarla.
flutter run -d chrome
Intenta chatear con el agente sobre algunas tareas que te gustaría realizar hoy. Si bien una IU puramente basada en texto puede hacer el trabajo, GenUI puede hacer que la experiencia sea más fácil y rápida.
4. Integra el paquete GenUI
Ahora es el momento de actualizar de texto sin formato a IU generativa. Cambiarás el bucle de mensajería básico de Firebase por objetos Conversation, Catalog y SurfaceController de GenUI. Esto permite que el modelo de IA cree instancias de widgets de Flutter reales dentro del flujo de chat.

El paquete genui proporciona cinco clases que usarás en este codelab:
SurfaceControllerasigna la IU generada por el modelo a la pantalla.A2uiTransportAdapterune las solicitudes internas de GenUI con cualquier modelo de lenguaje externo.Conversationune el controlador y el adaptador de transporte con una sola API unificada para tu app de Flutter.Catalogdescribe los widgets y las propiedades disponibles para el modelo de lenguaje.Surfacees un widget que muestra la IU generada por el modelo.
Prepárate para mostrar una Surface generada
El código existente incluye una clase TextItem que representa un solo mensaje de texto dentro de la conversación. Agrega otra clase para representar una Surface creada por el agente:
class SurfaceItem extends ConversationItem {
final String surfaceId;
SurfaceItem({required this.surfaceId});
}
Inicializa los componentes básicos de GenUI
En la parte superior de lib/main.dart, importa la biblioteca genui:
import 'package:genui/genui.dart' hide TextPart;
import 'package:genui/genui.dart' as genui;
Tanto el paquete genui como el paquete firebase_ai incluyen una clase TextPart. Si importas genui de esta manera, le asignas un espacio de nombres a su versión de TextPart como genui.TextPart, lo que evita una colisión de nombres.
Declara los controladores funcionales principales en _MyHomePageState después de _chatSession:
class _MyHomePageState extends State<MyHomePage> {
// ... existing members
late final ChatSession _chatSession;
// Add GenUI controllers
late final SurfaceController _controller;
late final A2uiTransportAdapter _transport;
late final Conversation _conversation;
late final Catalog catalog;
A continuación, actualiza initState para preparar los controladores de la biblioteca GenUI.
Quita esta línea de initState:
_chatSession.sendMessage(Content.text(systemInstruction));
Luego, agrega el siguiente código:
@override
void initState() {
// ... existing code ...
// Initialize the GenUI Catalog.
// The genui package provides a default set of primitive widgets (like text
// and basic buttons) out of the box using this class.
catalog = BasicCatalogItems.asCatalog();
// Create a SurfaceController to manage the state of generated surfaces.
_controller = SurfaceController(catalogs: [catalog]);
// Create a transport adapter that will process messages to and from the
// agent, looking for A2UI messages.
_transport = A2uiTransportAdapter(onSend: _sendAndReceive);
// Link the transport and SurfaceController together in a Conversation,
// which provides your app a unified API for interacting with the agent.
_conversation = Conversation(
controller: _controller,
transport: _transport,
);
}
Este código crea una fachada Conversation que administra el controlador y el adaptador. Esa conversación le ofrece a tu app un flujo de eventos que puede usar para mantenerse al día con lo que está creando el agente, así como un método para enviarle mensajes.
A continuación, crea un objeto de escucha para los eventos de conversación. Estos incluyen eventos relacionados con la superficie, así como los de mensajes de texto y errores:
@override
void initState() {
// ... existing code ...
// Listen to GenUI stream events to update the UI
_conversation.events.listen((event) {
setState(() {
switch (event) {
case ConversationSurfaceAdded added:
_items.add(SurfaceItem(surfaceId: added.surfaceId));
_scrollToBottom();
case ConversationSurfaceRemoved removed:
_items.removeWhere(
(item) =>
item is SurfaceItem && item.surfaceId == removed.surfaceId,
);
case ConversationContentReceived content:
_items.add(TextItem(text: content.text, isUser: false));
_scrollToBottom();
case ConversationError error:
debugPrint('GenUI Error: ${error.error}');
default:
}
});
});
}
Por último, crea la instrucción del sistema y envíala al agente:
@override
void initState() {
// ... existing code ...
// Create the system prompt for the agent, which will include this app's
// system instruction as well as the schema for the catalog.
final promptBuilder = PromptBuilder.chat(
catalog: catalog,
systemPromptFragments: [systemInstruction],
);
// Send the prompt into the Conversation, which will subsequently route it
// to Firebase using the transport mechanism.
_conversation.sendRequest(
ChatMessage.system(promptBuilder.systemPromptJoined()),
);
}
Muestra superficies
A continuación, actualiza el método build de ListView para mostrar los SurfaceItem en la lista _items:
Expanded(
child: ListView(
controller: _scrollController,
padding: const EdgeInsets.all(16),
children: [
for (final item in _items)
switch (item) {
TextItem() => MessageBubble(
text: item.text,
isUser: item.isUser,
),
// New!
SurfaceItem() => Surface(
surfaceContext: _controller.contextFor(
item.surfaceId,
),
),
},
],
),
),
El constructor del widget Surface toma un surfaceContext que le indica qué superficie es responsable de mostrar. El SurfaceController creado antes, _controller, proporciona la definición y el estado de cada superficie, y se asegura de que se vuelva a compilar cuando haya una actualización.
Conecta GenUI a Firebase AI Logic
El paquete genui usa un enfoque de "Trae tu propio modelo", lo que significa que controlas qué LLM potencia tu experiencia. En este caso, usas Firebase AI Logic, pero el paquete está diseñado para funcionar con una variedad de agentes y proveedores.
Esa libertad genera un poco de responsabilidad adicional: debes tomar los mensajes generados por el paquete genui y enviarlos al agente que elegiste, y debes tomar las respuestas del agente y enviarlas de vuelta a genui.
Para ello, definirás el método _sendAndReceive al que se hace referencia en el código del paso anterior. Agrega este código a MyHomePageState:
Future<void> _sendAndReceive(ChatMessage msg) async {
final buffer = StringBuffer();
// Reconstruct the message part fragments
for (final part in msg.parts) {
if (part.isUiInteractionPart) {
buffer.write(part.asUiInteractionPart!.interaction);
} else if (part is genui.TextPart) {
buffer.write(part.text);
}
}
if (buffer.isEmpty) {
return;
}
final text = buffer.toString();
// Send the string to Firebase AI Logic.
final response = await _chatSession.sendMessage(Content.text(text));
if (response.text?.isNotEmpty ?? false) {
// Feed the response back into GenUI's transportation layer
_transport.addChunk(response.text!);
}
}
El paquete genui llamará a este método cada vez que necesite enviar un mensaje al agente. La llamada a addChunk al final del método vuelve a enviar la respuesta del agente al paquete genui, lo que le permite procesar la respuesta y generar la IU.
Por último, reemplaza por completo el método _addMessage existente por esta versión nueva, de modo que enrute los mensajes a Conversation en lugar de a Firebase directamente:
Future<void> _addMessage() async {
final text = _textController.text;
if (text.trim().isEmpty) {
return;
}
_textController.clear();
setState(() {
_items.add(TextItem(text: text, isUser: true));
});
_scrollToBottom();
// Send the user's input through GenUI instead of directly to Firebase.
await _conversation.sendRequest(ChatMessage.user(text));
}
Eso es todo. Intenta volver a ejecutar la app. Además de los mensajes de texto, verás que el agente genera superficies de IU, como botones, widgets de texto y mucho más.
Incluso puedes intentar pedirle al agente que muestre la IU de una manera en particular. Por ejemplo, prueba con un mensaje como "Muéstrame mis tareas en una columna, con un botón para marcar cada una como completada".
5. Agrega el estado de espera
La generación de LLM es asíncrona. Mientras espera una respuesta, la interfaz de chat debe inhabilitar los botones de entrada y mostrar un indicador de progreso para que el usuario sepa que GenUI está creando contenido. Afortunadamente, el paquete genui proporciona un Listenable que puedes usar para hacer un seguimiento del estado de la conversación. Ese valor ConversationState incluye una propiedad isWaiting para determinar si el modelo está generando contenido.
Encapsula los controles de entrada con un ValueListenableBuilder
Crea un ValueListenableBuilder que encapsule el Row (que contiene tu TextField y ElevatedButton) en la parte inferior de lib/main.dart para escuchar el _conversation.state. Si inspeccionas state.isWaiting, puedes inhabilitar la entrada mientras el modelo genera contenido.
ValueListenableBuilder<ConversationState>(
valueListenable: _conversation.state,
builder: (context, state, child) {
return Row(
children: [
Expanded(
child: TextField(
controller: _textController,
// Also disable the Enter key submission when waiting!
onSubmitted: state.isWaiting ? null : (_) => _addMessage(),
decoration: const InputDecoration(
hintText: 'Enter a message',
),
),
),
const SizedBox(width: 8),
ElevatedButton(
// Disable the send button when the model is generating
onPressed: state.isWaiting ? null : _addMessage,
child: const Text('Send'),
),
],
);
},
),
Cómo agregar una barra de progreso
Encapsula el widget Column principal dentro de un Stack y agrega el LinearProgressIndicator como segundo elemento secundario de esa pila, anclado a la parte inferior. Cuando termines, el body de tu Scaffold debería verse así:
body: Stack( // New!
children: [
Column(
children: [
Expanded(
child: ListView(
controller: _scrollController,
padding: const EdgeInsets.all(16),
children: [
for (final item in _items)
switch (item) {
TextItem() => MessageBubble(
text: item.text,
isUser: item.isUser,
),
SurfaceItem() => Surface(
surfaceContext: _controller.contextFor(
item.surfaceId,
),
),
},
],
),
),
SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: ValueListenableBuilder<ConversationState>(
valueListenable: _conversation.state,
builder: (context, state, child) {
return Row(
children: [
Expanded(
child: TextField(
controller: _textController,
onSubmitted:
state.isWaiting ? null : (_) => _addMessage(),
decoration: const InputDecoration(
hintText: 'Enter a message',
),
),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: state.isWaiting ? null : _addMessage,
child: const Text('Send'),
),
],
);
},
),
),
),
],
),
// Listen to the state again, this time to render a progress indicator
ValueListenableBuilder<ConversationState>(
valueListenable: _conversation.state,
builder: (context, state, child) {
if (state.isWaiting) {
return const LinearProgressIndicator();
}
return const SizedBox.shrink();
},
),
],
),
6. Conserva una superficie de GenUI
Hasta ahora, la lista de tareas se renderizó en el flujo de chat con desplazamiento, y cada mensaje o superficie nuevos se agregaron a la lista a medida que llegaban. En el siguiente paso, verás cómo nombrar una superficie y mostrarla en una ubicación específica dentro de la IU.
Primero, en la parte superior de main.dart, antes de void main(), declara una constante para usar como ID de superficie:
const taskDisplaySurfaceId = 'task_display';
Luego, actualiza la instrucción switch en el objeto de escucha Conversation para asegurarte de que no se agregue ninguna superficie con ese ID a _items:
case ConversationSurfaceAdded added:
if (added.surfaceId != taskDisplaySurfaceId) {
_items.add(SurfaceItem(surfaceId: added.surfaceId));
_scrollToBottom();
}
A continuación, abre la estructura de diseño del árbol de widgets para crear un espacio para la superficie fijada inmediatamente sobre tu registro de chat. Agrega estos dos widgets como los primeros elementos secundarios de la Column principal:
AnimatedSize(
duration: const Duration(milliseconds: 300),
child: Container(
padding: const EdgeInsets.all(16),
alignment: Alignment.topLeft,
child: Surface(
surfaceContext: _controller.contextFor(
taskDisplaySurfaceId,
),
),
),
),
const Divider(),
Hasta ahora, tu agente tuvo libertad para crear y usar superficies según lo considerara adecuado. Para darle instrucciones más específicas, debes volver a consultar la instrucción del sistema. Agrega la siguiente sección ## USER INTERFACE al final de la instrucción almacenada en la constante systemInstruction:
const systemInstruction = '''
// ... existing prompt content ...
## USER INTERFACE
* To display the list of tasks create one and only one instance of the
TaskDisplay catalog item. Use "$taskDisplaySurfaceId" as its surface ID.
* Update $taskDisplaySurfaceId as necessary when the list changes.
* $taskDisplaySurfaceId must include a button for each task that I can use
to mark it complete. When I use that button to mark a task complete, it
should send you a message indicating what I've done.
* Avoid repeating the same information in a single message.
* When responding with text, rather than A2UI messages, be brief.
''';
Es importante darle a tu agente instrucciones claras sobre cuándo y cómo usar las superficies de la IU. Si le indicas al agente que use un elemento de catálogo y un ID de superficie específicos (y que reutilice una sola instancia), puedes asegurarte de que cree la interfaz que deseas ver.
Aún queda trabajo por hacer, pero puedes intentar volver a ejecutar tu app para ver que el agente cree la superficie de visualización de tareas en la parte superior de la IU.
7. Compila tu widget de catálogo personalizado
En este punto, el elemento de catálogo TaskDisplay no existe. En los siguientes pasos, lo solucionarás creando un esquema de datos, una clase para analizar ese esquema, un widget y el elemento de catálogo que une todo.
Primero, crea un archivo llamado task_display.dart y agrega las siguientes importaciones:
import 'package:flutter/material.dart';
import 'package:genui/genui.dart';
import 'package:json_schema_builder/json_schema_builder.dart';
Crea el esquema de datos
A continuación, define el esquema de datos que proporcionará el agente cuando quiera crear una visualización de tareas. El proceso usa algunos constructores sofisticados del paquete json_schema_builder, pero, básicamente, solo defines un esquema JSON que se usa en los mensajes hacia y desde el agente.
Comienza con un S.object básico que haga referencia al nombre del componente:
final taskDisplaySchema = S.object(
properties: {
'component': S.string(enumValues: ['TaskDisplay']),
},
);
A continuación, agrega title, tasks, name, isCompleted y completeAction a las propiedades del esquema.
final taskDisplaySchema = S.object(
properties: {
'component': S.string(enumValues: ['TaskDisplay']),
'title': S.string(description: 'The title of the task list'),
'tasks': S.list(
description: 'A list of tasks to be completed today',
items: S.object(
properties: {
'name': S.string(description: 'The name of the task to be completed'),
'isCompleted': S.boolean(
description: 'Whether the task is completed',
),
'completeAction': A2uiSchemas.action(
description:
'The action performed when the user has completed the task.',
),
},
),
),
},
);
Echa un vistazo a la propiedad completeAction. Se crea con A2uiSchemas.action, el constructor de una propiedad de esquema que representa una acción de A2UI. Cuando agregas una acción al esquema, la app le dice al agente: "Cuando me des una tarea, también proporciona el nombre y los metadatos de una acción que pueda usar para indicarte que se completó la tarea". Más adelante, la app invocará esa acción cuando el usuario presione una casilla de verificación.
A continuación, agrega campos required al esquema. Estos le indican al agente que propague ciertas propiedades cada vez. En este caso, se requiere cada propiedad.
final taskDisplaySchema = S.object(
properties: {
'component': S.string(enumValues: ['TaskDisplay']),
'title': S.string(description: 'The title of the task list'),
'tasks': S.list(
description: 'A list of tasks to be completed today',
items: S.object(
properties: {
'name': S.string(description: 'The name of the task to be completed'),
'isCompleted': S.boolean(
description: 'Whether the task is completed',
),
'completeAction': A2uiSchemas.action(
description:
'The action performed when the user has completed the task.',
),
},
// New!
required: ['name', 'isCompleted', 'completeAction'],
),
),
},
// New!
required: ['title', 'tasks'],
);
Crea clases de análisis de datos
Cuando cree instancias de este componente, el agente enviará datos que coincidan con el esquema. Agrega dos clases para analizar ese JSON entrante en objetos Dart con tipo seguro. Observa cómo _TaskDisplayData controla la estructura raíz, mientras delega el análisis del array interno a _TaskData.
class _TaskData {
final String name;
final bool isCompleted;
final String actionName;
final JsonMap actionContext;
_TaskData({
required this.name,
required this.isCompleted,
required this.actionName,
required this.actionContext,
});
factory _TaskData.fromJson(Map<String, Object?> json) {
try {
final action = json['completeAction']! as JsonMap;
final event = action['event']! as JsonMap;
return _TaskData(
name: json['name'] as String,
isCompleted: json['isCompleted'] as bool,
actionName: event['name'] as String,
actionContext: event['context'] as JsonMap,
);
} catch (e) {
throw Exception('Invalid JSON for _TaskData: $e');
}
}
}
class _TaskDisplayData {
final String title;
final List<_TaskData> tasks;
_TaskDisplayData({required this.title, required this.tasks});
factory _TaskDisplayData.fromJson(Map<String, Object?> json) {
try {
return _TaskDisplayData(
title: (json['title'] as String?) ?? 'Tasks',
tasks: (json['tasks'] as List<Object?>)
.map((e) => _TaskData.fromJson(e as Map<String, Object?>))
.toList(),
);
} catch (e) {
throw Exception('Invalid JSON for _TaskDisplayData: $e');
}
}
}
Si ya compilaste con Flutter, es probable que estas clases sean similares a las que creaste. Aceptan un JsonMap y muestran un objeto con tipo seguro que contiene datos analizados desde JSON.
Echa un vistazo a los campos actionName y actionContext en _TaskData. Se extraen de la propiedad completeAction del JSON y contienen el nombre de la acción y su contexto de datos (una referencia a la ubicación de la acción en el modelo de datos de GenUI). Estos se usarán más adelante para crear un UserActionEvent.
El modelo de datos es un almacén centralizado y observable para todo el estado de la IU dinámica, que mantiene la biblioteca genui. Cuando el agente crea un componente de IU desde el catálogo, también crea un objeto de datos que coincide con el esquema del componente. Este objeto de datos se almacena en el modelo de datos del cliente, de modo que se pueda usar para compilar widgets y hacer referencia a él en mensajes posteriores al agente (como el completeAction que estás a punto de conectar a un widget).
Agrega el widget
Ahora, crea un widget para mostrar la lista. Debe aceptar una instancia de la clase _TaskDisplayData y una devolución de llamada para invocar cuando se complete una tarea.
class _TaskDisplay extends StatelessWidget {
final _TaskDisplayData data;
final void Function(_TaskData) onCompleteTask;
const _TaskDisplay({required this.data, required this.onCompleteTask});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
data.title,
style: Theme.of(context).textTheme.titleLarge,
),
),
...data.tasks.map(
(task) => CheckboxListTile(
title: Text(
task.name,
style: TextStyle(
decoration: task.isCompleted
? TextDecoration.lineThrough
: TextDecoration.none,
),
),
value: task.isCompleted,
onChanged: task.isCompleted
? null
: (val) {
if (val == true) {
onCompleteTask(task);
}
},
),
),
],
);
}
}
Crea el CatalogItem
Con el esquema, el analizador y el widget creados, ahora puedes crear un CatalogItem para unirlos.
En la parte inferior de task_display.dart, crea taskDisplay como una variable de nivel superior, usa _TaskDisplayData para analizar el JSON entrante y compila una instancia del widget _TaskDisplay.
final taskDisplay = CatalogItem(
name: 'TaskDisplay',
dataSchema: taskDisplaySchema,
widgetBuilder: (itemContext) {
final json = itemContext.data as Map<String, Object?>;
final data = _TaskDisplayData.fromJson(json);
return _TaskDisplay(
data: data,
onCompleteTask: (task) async {
// We will implement this next!
},
);
},
);
Implementa onCompleteTask
Para que el widget funcione, debe comunicarse con el agente cuando se complete una tarea. Reemplaza el marcador de posición onCompleteTask vacío por el siguiente código para crear y despachar un evento con el completeAction de los datos de la tarea.
onCompleteTask: (task) async {
// A data context is a reference to a location in the data model. This line
// turns that reference into a concrete data object that the agent can use.
// It's kind of like taking a pointer and replacing it with the value it
// points to.
final JsonMap resolvedContext = await resolveContext(
itemContext.dataContext,
task.actionContext,
);
// Dispatch an event back to the agent, letting it know a task was completed.
// This will be sent to the agent in an A2UI message that includes the name
// of the action, the surface ID, and the resolved data context.
itemContext.dispatchEvent(
UserActionEvent(
name: task.actionName,
sourceComponentId: itemContext.id,
context: resolvedContext,
),
);
}
Registra el elemento de catálogo
Por último, abre main.dart, importa el archivo nuevo y regístralo junto con los otros elementos de catálogo.
Agrega esta importación a la parte superior de lib/main.dart:
import 'task_display.dart';
Reemplaza catalog = BasicCatalogItems.asCatalog(); en tu función initState() por lo siguiente:
// The Catalog is immutable, so use copyWith to create a new version
// that includes our custom catalog item along with the basics.
catalog = BasicCatalogItems.asCatalog().copyWith(newItems: [taskDisplay]);
¡Listo! Reinicia la app en caliente para ver los cambios.
8. Experimenta con diferentes formas de interactuar con el agente

Ahora que agregaste el widget nuevo al catálogo y creaste un espacio para él en la IU de la app, es momento de divertirte trabajando con el agente. Uno de los principales beneficios de GenUI es que ofrece dos formas de interactuar con tus datos: a través de la IU de la aplicación, como botones y casillas de verificación, y a través de un agente que comprende el lenguaje natural y puede razonar sobre los datos. Experimenta con ambos.
- Usa el campo de texto para describir tres o cuatro tareas y observa cómo aparecen en la lista.
- Usa una casilla de verificación para activar o desactivar una tarea como completada o incompleta.
- Crea una lista de 5 a 6 tareas y, luego, dile al agente que quite las que requieren que conduzcas a algún lugar.
- Dile al agente que cree una lista repetitiva de tareas como elementos individuales ("Necesito comprar una tarjeta de felicitación para el Día de la Madre para mamá, papá y abuela. Crea tareas separadas para ellos").
- Dile al agente que marque todas las tareas como terminadas o sin terminar, o que marque las primeras dos o tres.
9. Felicitaciones
¡Felicitaciones! Compilaste una app de seguimiento de tareas potenciada por IA con IU generativa y Flutter.
Qué aprendiste
- Interactuar con los modelos base de Google usando el SDK de Firebase para Flutter
- Renderizar superficies interactivas generadas por Gemini con GenUI
- Fijar superficies en diseños con IDs de renderización estática predeterminados
- Diseñar esquemas personalizados y catálogos de widgets para bucles de interacción sólidos