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, finalmente, compilarás tu propio componente de IU interactivo y personalizado que tú y el agente podrán manipular directamente.

Actividades
- Crea una interfaz de chat básica con Flutter y Firebase AI Logic
- Integra el paquete
genuipara generar plataformas basadas en IA - Agregar una barra de progreso para indicar cuándo la app está esperando una respuesta del agente
- Crea una superficie con nombre y muéstrala en un lugar dedicado de la IU.
- Crea 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
- Firebase CLI instalada y configurada
Este codelab es para desarrolladores de Flutter de nivel intermedio.
2. Antes de comenzar
Configura el proyecto de Flutter
- Si aún no lo hiciste, instala el SDK de Flutter de forma local.
- Abre la terminal y ejecuta
flutter createpara crear un proyecto nuevo:flutter create intro_to_genui cd intro_to_genui - Agrega las dependencias necesarias a tu proyecto de Flutter:
Tu secciónflutter pub add firebase_core firebase_ai genui json_schema_builderdependenciesfinal debería verse de la siguiente manera (los números de versión pueden variar ligeramente):dependencies: flutter: sdk: flutter cupertino_icons: ^1.0.8 firebase_core: ^4.9.0 firebase_ai: ^3.12.1 genui: ^0.9.0 json_schema_builder: ^0.1.3 - Ejecuta
flutter pub getpara descargar todos los paquetes.
Configura un proyecto de Firebase
- Si aún no lo hiciste, instala Firebase CLI.
- Accede a Firebase con tu Cuenta de Google:
firebase login - Instala la CLI de FlutterFire:
dart pub global activate flutterfire_cli - Desde el directorio de tu proyecto de Flutter, ejecuta el siguiente comando para configurar tu proyecto de Flutter para que use Firebase:
Este comando hará lo siguiente:flutterfire configure- Preguntarte si quieres usar un proyecto de Firebase existente o crear uno nuevo Selecciona Crear un proyecto de Firebase nuevo.
- Te pregunta para qué plataforma (iOS, Android o Web) deseas segmentar tu app de Flutter. Por ahora, selecciona Web.
El comando flutterfire configure crea automáticamente un proyecto de Firebase y una nueva app web de Firebase en ese proyecto. Luego, el comando crea un archivo de configuración de Firebase (firebase_options.dart) y lo agrega automáticamente al directorio lib/ de tu proyecto de Flutter.
Ten en cuenta que la app de este codelab funciona solo con el SDK de Flutter y Chrome instalados en tu máquina (es decir, se compila como una app web). Sin embargo, como esta app se creó con Flutter, también funcionará en otras plataformas. Por lo tanto, al final del codelab, vuelve a ejecutar flutterfire configure para agregar compatibilidad con iOS, Android o alguna otra plataforma, y, luego, vuelve a compilar la app en esa plataforma.
Para obtener más información, consulta las instrucciones para agregar Firebase a una app de Flutter.
Configura Firebase AI Logic
- Accede a Firebase console. Usa la misma Cuenta de Google que usaste para acceder a Firebase CLI.
- Selecciona el proyecto de Firebase que acabas de crear con la CLI de FlutterFire.
- En el menú de navegación de la izquierda, selecciona Servicios de IA > Lógica de IA.
- Haz clic en Comenzar para iniciar el flujo de trabajo guiado.
- Selecciona la opción para comenzar con la API de Gemini Developer y sigue las indicaciones en pantalla para configurar Firebase AI Logic.
Ya tienes los complementos de FlutterFire necesarios para usar Firebase AI Logic de la sección "Configura un proyecto de Flutter". En el siguiente paso, podrás comenzar a programar tu app.
3. Crea 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 los mensajes de texto del usuario y del 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 del 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.5-flash',
);
_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 preguntas del usuario a través del widget MessageBubble que creaste anteriormente.
A continuación, se indican algunos aspectos que debes verificar 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
Ahora puedes ejecutar la app y probarla.
flutter run -d chrome
Intenta chatear con el agente sobre algunas tareas que te gustaría completar hoy. Si bien una IU puramente basada en texto puede hacer el trabajo, la IU generativa puede hacer que la experiencia sea más fácil y rápida.
4. Integra el paquete de GenUI
Ahora es el momento de actualizar el texto sin formato a la IU generativa. Reemplazará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 a lo largo de este codelab:
SurfaceControllerasigna la IU generada por el modelo a la pantalla.A2uiTransportAdapterune las solicitudes internas de GenUI con cualquier modelo de lenguaje externo.Conversationenvuelve 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 un Surface generado
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 un Surface creado 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. Cuando importas genui de esta manera, 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 de 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 tanto de 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()),
);
}
Plataformas de visualización
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 anteriormente, _controller, proporciona la definición y el estado de cada superficie, y se asegura de que se vuelva a compilar cuando hay 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 implica un poco de responsabilidad adicional: debes tomar los mensajes que genera el paquete genui y enviarlos al agente que elijas, 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 devuelve 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 para 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 la IU, como botones, widgets de texto y mucho más.
Incluso puedes pedirle al agente que muestre la IU de una manera específica. 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. Agregar 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 de ConversationState incluye una propiedad isWaiting para determinar si el modelo está generando contenido.
Encierra los controles de entrada con un ValueListenableBuilder
Crea un ValueListenableBuilder que envuelva 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. Cómo conservar una Surface 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 usarla como ID de superficie:
const taskDisplaySurfaceId = 'task_display';
Luego, actualiza la sentencia 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 el registro de chat. Agrega estos dos widgets como los primeros elementos secundarios del 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 plataformas como le pareció adecuado. Para darle instrucciones más específicas, debes volver a 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 que le des 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 ejecutar la app de nuevo para ver cómo el agente crea la superficie de visualización de la tarea en la parte superior de la IU.
7. Crea tu widget de catálogo personalizado
En este punto, el elemento de catálogo TaskDisplay no existe. En los próximos pasos, corregirás esto 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 pantalla 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 que se envían al agente y se reciben de él.
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: "Oye, cuando me des una tarea, también proporciona el nombre y los metadatos de una acción que puedo usar para decirte que la tarea se completó". 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. Estas instrucciones le indican al agente que complete ciertas propiedades cada vez. En este caso, todas las propiedades son obligatorias.
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 se creen 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 escritura segura. Observa cómo _TaskDisplayData controla la estructura raíz y 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 devuelven un objeto con escritura segura que contiene datos analizados a partir de JSON.
Observa 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 dinámico de la IU, que mantiene la biblioteca genui. Cuando el agente crea un componente de la IU a partir del 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á 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 todos.
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 enviar 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 un elemento de catálogo
Por último, abre main.dart, importa el archivo nuevo y regístralo junto con los demás elementos del 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 nuevo widget 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. ¡Intenta experimentar 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 marcar una tarea como completada o incompleta.
- Crea una lista de 5 a 6 tareas y, luego, pídele al agente que quite las que requieran que conduzcas a algún lugar.
- Dile al agente que cree una lista repetitiva de tareas como elementos individuales ("Necesito comprar una tarjeta de vacaciones para mamá, papá y abuela. Crea tareas separadas para eso").
- Dile al agente que marque todas las tareas como terminadas o no terminadas, o que marque las dos o tres primeras.
9. Felicitaciones
¡Felicitaciones! Creaste una app de seguimiento de tareas potenciada por IA con IU generativa y Flutter.
Qué aprendiste
- Interactúa con los modelos básicos de Google usando el SDK de Firebase para Flutter
- Renderizar superficies interactivas generadas por Gemini con GenUI
- Cómo fijar superficies en diseños con IDs de renderización estáticos predeterminados
- Diseño de esquemas personalizados y catálogos de widgets para bucles de interacción sólidos