👗 Crea un probador virtual y un estilista de IA con Flutter, ADK Go y Gemini

1. Introducción

Qué compilará

En este codelab, te pondrás en el lugar de un desarrollador que crea Fashion App, una app de compras de Flutter para una marca minorista ficticia. Tu misión es agregar dos funciones potenciadas por IA que transformen la experiencia de compra en línea.

  1. Probador virtual: El usuario sube una foto de sí mismo, selecciona una prenda y ve una imagen generada por IA de sí mismo con esa prenda.
  2. Asistente de moda con IA: Según la ubicación, la ocasión y las preferencias de estilo del usuario, un agente de IA selecciona recomendaciones de atuendos completos, y el usuario puede definirlos mejor a través de una conversación.

La idea es simple: cuando las personas se prueban ropa en un probador, es mucho más probable que la compren. ¿Pero en línea? Estás adivinando. Este proyecto cierra esa brecha con la IA.

Arquitectura de un vistazo

Flutter App  ──── HTTP/REST ────▶  ADK Go Backend
                                       
                            ┌──────────┼──────────┐
                       Fitting Room  Stylist    Catalog
                         Agent       Agent      Agent
                                       
                            Gemini API + Cloud Storage

Tecnologías principales

Componente

Tecnología

Objetivo

Framework del agente

ADK (Kit de desarrollo de agentes) para Go

Organización de varios agentes, sesiones y artefactos

Agent Reasoning (Pro)

Versión preliminar de Gemini 3.1 Pro

Potencia los agentes de probador y de estilista

Agent Reasoning (Flash)

Versión preliminar de Gemini 3 Flash

Potencia los agentes raíz y de catálogo (enrutamiento y búsqueda ligeros)

Generación de imágenes

Gemini 2.5 Flash Image

Genera imágenes de prueba virtual y de atuendos

Frontend

Flutter (Dart)

App multiplataforma (Web, iOS y Android)

Almacenamiento

Google Cloud Storage

Almacena imágenes de productos y artefactos generados

Hosting

Cloud Run

Implementación de contenedores sin servidores

2. 📦 Requisitos previos y configuración de Cloud Shell

1. Abre el editor de Cloud Shell

👉 Abre el Editor de Cloud Shell en tu navegador.

Si la terminal no aparece en la parte inferior de la pantalla, haz lo siguiente:

  • Haz clic en VerTerminal.

2. Configura el SDK de Flutter

Cloud Shell se entrega con Flutter preinstalado en /google/flutter. Como ese directorio pertenece a otro usuario del sistema, recibirás un error de fatal: detected dubious ownership la primera vez que ejecutes flutter. Agrega el directorio a la lista de directorios seguros de Git una vez:

git config --global --add safe.directory /google/flutter

Verifica que Flutter esté en tu PATH y que funcione:

flutter --version

La primera ejecución descarga el SDK de Dart y compila la herramienta de Flutter. Esto puede tardar un minuto. Deberías ver algo como Flutter 3.x • channel stable.

3. Clona el repositorio

cd ~
git clone https://github.com/gca-americas/fashion-app-demo
cd fashion_app_demo

4. Explora la estructura del proyecto

fashion_app_demo/
├── adk_backend/                 # Go backend with ADK agents
   ├── main.go                  # Entry point — wires all agents + REST server
   ├── catalog/                 # Catalog Agent — product lookup
      ├── agent.go
      ├── catalog.yaml         # Product database (YAML)
      └── instructions.md      # Agent persona prompt
   ├── fittingroom/             # Fitting Room Agent — virtual try-on
      ├── agent.go
      ├── instructions.md      # Agent persona prompt
      └── tool_instructions.md # Image generation prompt
   ├── stylist/                 # Stylist Agent — outfit curation
      ├── agent.go
      └── instructions.md      # Agent persona + output format
   ├── rootagent/               # Root Agent — routes to the right agent
      └── agent.go
   └── tools/                   # Shared tools
       ├── imagetool.go         # getProductImage — loads product images
       ├── outfit_gen_tool.go   # generate_outfit_image — creates outfit images
       └── cors_helper.go      # CORS middleware + request logging

├── flutter_frontend/            # Flutter cross-platform app
   ├── lib/
      ├── main.dart            # App entry point + Provider setup
      ├── app_config.dart      # Backend URL configuration
      ├── core_app/            # Pre-built shopping app (browse, cart, etc.)
      └── workshop_tasks/      # AI feature code
          ├── step_1_try_it_on/  # Virtual Try-On flow
             ├── providers/     # TryItOnProvider (state management)
             ├── services/      # AdkFittingRoomService (HTTP calls)
             └── ui/            # Screens (product detail → try on → fitting room)
          └── step_2_style_me/   # Style Me flow
              ├── models/        # Outfit, StyleRequest data classes
              ├── providers/     # StylingProvider (state management)
              ├── services/      # AdkStylingService (HTTP calls)
              └── ui/            # Screens (form sheet → outfit carousel)
   └── assets/images/           # Product catalog images

3. ☁️ Configuración del proyecto de Google Cloud

1. Crear un proyecto nuevo

gcloud projects create fashion-app-demo --name="Fashion App Demo"
gcloud config set project fashion-app-demo

Haz una lista de tus cuentas de facturación:

gcloud billing accounts list

Mira la

OPEN

columna. Debe decir True. Si dice False (común con una prueba gratuita vencida), la cuenta está cerrada y no pagará nada. Antes de continuar, ve al bloque de solución de problemas que se encuentra a continuación.

Copia el ACCOUNT_ID de una cuenta de OPEN: True (se ve como 0X0X0X-0X0X0X-0X0X0X) y vincúlalo a tu proyecto:

gcloud billing projects link fashion-app-demo \
 --billing-account=YOUR_BILLING_ACCOUNT_ID

Verifica el vínculo:

gcloud billing projects describe fashion-app-demo

Deberías ver billingEnabled: true. Si ves billingEnabled: false incluso después de la vinculación, significa que la cuenta está cerrada (OPEN: False). Consulta el bloque de solución de problemas a continuación.

3. Habilita las API obligatorias

gcloud services enable \
 aiplatform.googleapis.com \
 storage.googleapis.com \
 run.googleapis.com \
 cloudbuild.googleapis.com \
 artifactregistry.googleapis.com

API

Objetivo

aiplatform.googleapis.com

Vertex AI: fitting_tool llama a la generación de imágenes de Gemini a través de Vertex AI

storage.googleapis.com

Cloud Storage: Almacena imágenes del catálogo de productos y los resultados de la prueba generados.

run.googleapis.com

Cloud Run: Aloja el backend como un contenedor sin servidores

cloudbuild.googleapis.com

Cloud Build: Compila imágenes de Docker a partir del código fuente.

artifactregistry.googleapis.com

Artifact Registry: Almacena imágenes de Docker compiladas

4. Crea un bucket de GCS

export PROJECT_ID=$(gcloud config get-value project)
gcloud storage buckets create gs://fashion-app-$PROJECT_ID \
 --location=us-central1 \
 --uniform-bucket-level-access

5. Sube imágenes del catálogo de productos

La herramienta getProductImage del backend lee desde gs://$GCS_BUCKET/catalog-assets/images/. Sube las imágenes del catálogo a esa ruta de acceso exacta:

cd ~/fashion_app_demo
gcloud storage cp flutter_frontend/assets/images/*.png \
 gs://fashion-app-$PROJECT_ID/catalog-assets/images/

Verifica la carga (deberías ver una lista de archivos .png):

gcloud storage ls gs://fashion-app-$PROJECT_ID/catalog-assets/images/

6. Configura el archivo .env

cd ~/fashion_app_demo/adk_backend
cat > .env << EOF
GOOGLE_CLOUD_PROJECT=$PROJECT_ID
GCS_BUCKET=fashion-app-$PROJECT_ID
EOF

7. Autentica con credenciales predeterminadas de la aplicación

Debes ejecutar este comando antes de iniciar el backend de forma local. El backend de Go usa la ADC para autenticar cada llamada a Vertex AI (Gemini) y Cloud Storage. Sin ADC, el backend se iniciará, pero todas las solicitudes de prueba fallarán con un 401 CREDENTIALS_MISSING.

Una credencial abarca ambos servicios. Ejecuta estos dos comandos en el siguiente orden:

# 1. Log in (opens a browser; in Cloud Shell, paste the verification code back)
gcloud auth application-default login

# 2. Attach your project as the quota / billing project for ADC
gcloud auth application-default set-quota-project $(gcloud config get-value project)

Verifica que el ADC esté en buen estado:

gcloud auth application-default print-access-token | head -c 20 && echo "..."

Deberías ver alrededor de 20 caracteres de un token seguidos de .... Si se produce un error, significa que no se realizó el acceso. Vuelve a ejecutar el paso 1.

4. 🏗️ Descripción general de la arquitectura

Ahora que el entorno está listo, veamos cómo funciona el sistema antes de analizar el código.

El sistema de cuatro agentes

El backend se compila como un sistema multiagente con el ADK (Kit de desarrollo de agentes) para Go. Cuatro agentes trabajan juntos, cada uno con una responsabilidad específica:

                   ┌──────────────┐
                    Root Agent      Routes requests to the right agent
                    (gemini-3-   
                     flash-      
                     preview)    
                   └──────┬───────┘
                          
           ┌──────────────┼──────────────┐
                                       
  ┌────────────────┐ ┌──────────┐ ┌────────────────┐
   Fitting Room     Catalog    Stylist        
   Agent            Agent      Agent          
   (gemini-3.1-     (gemini-   (gemini-3.1-   
    pro-preview)     3-flash-   pro-preview)  
                     preview)                 
   Tools:                      Tools:         
    fitting_tool   Tools:      fitting_tool 
    getProduct      list      getProduct   
     Image          Products     Image        
    catalog_agent   get       catalog_agent
     (delegation)   Product      (delegation) 
                     Image      generate_    
  └────────────────┘ └──────────┘    outfit_image 
                                  └────────────────┘

Agente

Modelo

Rol

Agente raíz

gemini-3-flash-preview

Policía de tráfico. Lee el mensaje del usuario y lo delega al agente especialista adecuado. Usa un modelo rápido y ligero porque solo necesita tomar decisiones de rutas.

Agente de catálogo

gemini-3-flash-preview

Ser experto en productos Carga el catálogo de productos desde un archivo YAML y responde preguntas sobre los productos. También es liviano, ya que solo busca datos.

Agente de probador virtual

gemini-3.1-pro-preview

Especialista en pruebas virtuales Toma una foto del usuario y una imagen del producto, y genera una imagen compuesta de la persona usando ese artículo. Utiliza un modelo más potente porque necesita razonar sobre las imágenes.

Agente de estilista

gemini-3.1-pro-preview

Asesor de moda. Dadas la ubicación, la ocasión y las preferencias, selecciona 3 combinaciones de atuendos del catálogo. Puede generar imágenes de prueba para cada atuendo. También usa el modelo capaz para el razonamiento creativo.

Punto de entrada: main.go

Todo comienza en main.go, que conecta los agentes y, luego, inicia el servidor HTTP:

// main.go  simplified for clarity


func main() {
   godotenv.Load()  // Load .env file


   // 1. Create the artifact storage (GCS-backed)
   artifacts, _ := gcsartifact.NewService(ctx, bucket)


   // 2. Build agents bottom-up (dependencies first)
   catagent, _    := catalog.NewCatalogAgent(apikey, "catalog/catalog.yaml")
   fitagent, _    := fittingroom.NewFittingRoomAgent(apikey, catagent)
   stylistAgent, _ := stylist.NewStylistAgent(apikey, catagent)
   ragent, _      := rootagent.NewRootAgent(apikey, fitagent, catagent, stylistAgent)


   // 3. Register all agents with a multi-loader
   loader, _ := agent.NewMultiLoader(ragent, fitagent, catagent, stylistAgent)


   // 4. Create the ADK REST server
   restHandler, _ := adkrest.NewServer(adkrest.ServerConfig{
       SessionService:  session.InMemoryService(),
       MemoryService:   memory.InMemoryService(),
       AgentLoader:     loader,
       ArtifactService: artifacts,
   })


   // 5. Mount behind /api/ with CORS support
   r := mux.NewRouter()
   r.Use(tools.LocalhostCORS)
   r.PathPrefix("/api/").Handler(
       http.StripPrefix("/api", tools.LogHandler(restHandler)))


   http.Server{Addr: ":8080", Handler: r}.ListenAndServe()
}

Debes tener en cuenta lo siguiente:

  • Los agentes se compilan de forma ascendente: Primero se crea el agente de catálogo, ya que tanto el agente de probador como el de estilista dependen de él (le delegan las búsquedas de productos).
  • agent.NewMultiLoader registra los cuatro agentes para que la API de REST pueda enrutar a cualquiera de ellos por su nombre.
  • adkrest.NewServer proporciona la API de REST automáticamente, por lo que no es necesario que escribas controladores de extremos. El ADK te proporciona administración de sesiones, almacenamiento de artefactos y ejecución de agentes listos para usar.
  • session.InMemoryService() almacena sesiones en la memoria. Esto significa que las sesiones se pierden si se reinicia el servidor, lo que está bien para una demostración. En la producción, usarías un almacenamiento persistente.
  • gcsartifact.NewService almacena artefactos (imágenes generadas) en Google Cloud Storage, por lo que persisten en las solicitudes y se pueden compartir a través de URIs de GCS.

5. 🤖 Análisis detallado del ADK (Kit de desarrollo de agentes)

¿Qué es el ADK?

El Kit de desarrollo de agentes (ADK) es un framework de código abierto de Google para crear agentes de IA en Go (y Python/Java). Es la capa entre tu aplicación y la API de Gemini.

Podrías llamar a la API de Gemini directamente. Sin embargo, una vez que tu app necesite hacer lo siguiente:

  • Cómo buscar productos en un catálogo
  • Generar imágenes basadas en las fotos del usuario
  • Recordar qué atuendos se sugirieron anteriormente
  • Coordina varios agentes de IA

Necesitas estructura. El ADK proporciona esa estructura.

El bucle del agente

Todos los agentes del ADK siguen un bucle:

1. Receive a message (from user or another agent)
2. Think  the LLM reasons about what to do
3. Act  call a tool, delegate to a sub-agent, or respond
4. Return  send the result back

Este bucle se puede repetir varias veces dentro de una misma solicitud. Por ejemplo, el agente de estilista podría hacer lo siguiente:

  1. Recibir "Ayúdame a elegir mi atuendo para unas vacaciones en la playa"
  2. Llama a la herramienta catalog_agent para obtener la lista de productos
  3. Selecciona 3 combinaciones de atuendos
  4. Llama a fitting_tool para cada atuendo y generar imágenes
  5. Devuelve la respuesta JSON estructurada

Conceptos básicos (con código de este repo)

Agentes LLM

Es el componente básico principal. Creado con llmagent.New():

// From catalog/agent.go
agent, err := llmagent.New(llmagent.Config{
   Name:        "catalog_agent",                    // Unique identifier
   Model:       m,                                  // Which LLM to use
   Description: "An agent that can search and list products from the catalog",
   Instruction: instructions,                       // Persona prompt (embedded from .md file)
   Tools:       []tool.Tool{listTool, imageTool},   // What the agent can do
})

El campo Instruction es el arquetipo del agente, ya que le indica al LLM quién es y cómo debe comportarse. En este repo, las instrucciones se escriben como archivos Markdown y se incorporan en el tiempo de compilación con la directiva //go:embed de Go:

//go:embed instructions.md
var instructions string

Esto mantiene las instrucciones como documentos separados y con versiones, en lugar de cadenas intercaladas.

Herramientas

Las herramientas son funciones de Go a las que puede llamar el LLM. El ADK controla la traducción entre el formato de llamada a herramientas del LLM y tu función de Go escrita:

// From catalog/agent.go
type ListProductsArgs struct{}                    // Input (can be empty)
type ListProductsResult struct {
   Products []Product `json:"products"`          // Output
}


func ListProducts(ctx tool.Context, args ListProductsArgs) (ListProductsResult, error) {
   return ListProductsResult{Products: catalogProducts}, nil
}


// Register it:
listTool, _ := functiontool.New(functiontool.Config{
   Name:        "listProducts",
   Description: "list all products in the catalog",
}, ListProducts)

El ADK genera automáticamente un esquema JSON a partir de tus structs de Go y lo envía al LLM. Cuando el LLM decide llamar a listProducts, el ADK deserializa los argumentos, llama a tu función y envía el resultado.

El parámetro tool.Context otorga a las herramientas acceso a los servicios de tiempo de ejecución del ADK, en especial a los artefactos:

// Save an image as an artifact
ctx.Artifacts().Save(ctx, "my_image", imagePart)


// Load an artifact
resp, _ := ctx.Artifacts().Load(ctx, "my_image")

Delegación de subagente

Un agente puede usar otro agente como herramienta a través de agenttool.New():

// From fittingroom/agent.go
Tools: []tool.Tool{
   loadartifactstool.New(),              // List available artifacts
   imgtool,                              // Get product images
   agenttool.New(catalogAgent, nil),     // Delegate to catalog agent
   fittingTool,                          // Generate try-on image
},

Cuando el agente de probador necesita información del producto, puede llamar al agente de catálogo como si fuera una herramienta normal. El LLM la ve en la lista de herramientas y puede decidir invocarla.

Sesiones

Las sesiones hacen un seguimiento del historial de conversaciones. La API de REST del ADK los administra automáticamente:

POST /api/apps/{appName}/users/{userId}/sessions    Creates a new session
POST /api/run  (with sessionId)                     Runs agent within that session

Una decisión de diseño fundamental en esta app es que el probador crea una sesión nueva por solicitud (cada prueba es independiente), mientras que el estilista reutiliza la misma sesión (por lo que recuerda las sugerencias anteriores y puede mejorarlas según los comentarios).

Estado

El estado es un almacén de pares clave-valor adjunto a una sesión. Los agentes leen y escriben el estado para coordinar:

// Write to state
ctx.State().Set("previously_used_products", "[\"id_bomber\",\"id_hat\"]")


// Read from state
val, err := ctx.State().Get("previously_used_products")

El agente de estilista usa el estado para recordar qué productos ya sugirió, de modo que elige otros la próxima vez.

Artefactos

Los artefactos son objetos binarios con nombre (por lo general, imágenes) que se almacenan por sesión. A diferencia de las respuestas de texto, se almacenan por separado y se recuperan por nombre:

// Save a generated image as an artifact
artName := fmt.Sprintf("generated_fitting_%s_%s", ctx.InvocationID(), uuid.NewString()[:8])
ctx.Artifacts().Save(ctx, artName, imagePart)


// The frontend fetches it via:
// GET /api/apps/{app}/users/{user}/sessions/{session}/artifacts/{artName}

Esto mantiene las respuestas ligeras: el agente solo devuelve el nombre del artefacto, y el frontend recupera los datos de la imagen binaria por separado.

Devoluciones de llamada

Las devoluciones de llamada son hooks que se ejecutan en puntos específicos del bucle del agente. Pueden inspeccionar, modificar o interrumpir la ejecución:

llmagent.Config{
   // Runs before the agent starts  used to save uploaded images
   BeforeAgentCallbacks: []agent.BeforeAgentCallback{SaveIncomingBlobs},


   // Runs before each LLM call  used to inject context
   BeforeModelCallbacks: []llmagent.BeforeModelCallback{
       ExtractAndInjectUserImage,
       InjectPreviousProducts,
   },


   // Runs after each LLM response  used to extract data
   AfterModelCallbacks: []llmagent.AfterModelCallback{SaveSelectedProducts},
}

Si una devolución de llamada muestra una respuesta que no es nula, se omite el comportamiento predeterminado. Por ejemplo, un BeforeModelCallback que devuelve una respuesta almacenada en caché omitiría por completo la llamada real al LLM.

Aplicación del esquema JSON

Tanto el agente de probadores como el de estilista obligan al LLM a responder en JSON estructurado:

GenerateContentConfig: &genai.GenerateContentConfig{
   ResponseMIMEType:   "application/json",
   ResponseJsonSchema: fittingSchemaMap(),  // Defines the expected structure
}

Esto garantiza que el frontend de Flutter siempre reciba datos analizables, no texto de formato libre.

El agente de Catalog: ejemplo más simple

El agente de catálogo (catalog/agent.go) es el agente más simple del sistema y un buen punto de partida para comprender los patrones del ADK.

Tiene dos herramientas:

  1. listProducts: Muestra el catálogo de productos completo de un archivo YAML.
  2. getProductImage: Carga una imagen del producto desde GCS (o una alternativa local) y la guarda como un artefacto.

La herramienta getProductImage muestra un patrón importante: carga de varias fuentes con almacenamiento en caché de artefactos:

// From tools/imagetool.go  simplified
func GetProductImage(ctx tool.Context, args GetProductImageArgs) (GetProductImageResult, error) {
   // First: check if it's already an artifact
   _, err := ctx.Artifacts().Load(ctx, args.ImageName)
   if err == nil {
       return GetProductImageResult{Image: args.ImageName}, nil  // Cache hit!
   }


   // Second: try loading from GCS bucket
   rc, err := client.Bucket(bucket).Object("catalog-assets/images/" + args.ImageName).NewReader(ctx)
   if err != nil {
       // Third: fall back to local filesystem
       localData, _ := os.ReadFile("../flutter_frontend/assets/images/" + args.ImageName)
       ctx.Artifacts().Save(ctx, args.ImageName, &genai.Part{InlineData: ...})
       return GetProductImageResult{Image: args.ImageName}, nil
   }


   // Save to artifact cache and return
   ctx.Artifacts().Save(ctx, args.ImageName, &genai.Part{InlineData: ...})
   return GetProductImageResult{Image: args.ImageName}, nil
}

La herramienta primero prueba los artefactos, luego GCS y, por último, los archivos locales. Una vez cargada, la imagen se almacena en caché como un artefacto, por lo que las llamadas posteriores son instantáneas.

6. 🧪 La canalización de IA: Agentes en acción

Ahora, veamos los dos agentes más sofisticados, los que realmente generan imágenes y seleccionan atuendos.

6.1 El agente de Fitting Room

Archivo:

adk_backend/fittingroom/agent.go

El agente del probador es el motor detrás de la función "Probar virtualmente". Cuando un usuario sube su foto y elige un producto, este agente genera una imagen compuesta de la persona usando ese artículo.

El fitting_tool: paso a paso

La lógica principal se encuentra en la función doFitting. Esto es lo que sucede cuando el agente la llama:

Paso 1: Resuelve la imagen del usuario

func doFitting(ctx tool.Context, args FittingToolArgs) (FittingToolResult, error) {
   if len(args.Accessories) > 2 {
       args.Accessories = args.Accessories[:2]  // Safety limit: max 2 items
   }


   var userPart *genai.Part
   if strings.HasPrefix(args.UserImage, "gs://") {
       // If we have a GCS URI from a previous fitting, use it directly
       userPart = &genai.Part{FileData: &genai.FileData{
           FileURI:  args.UserImage,
           MIMEType: gcsURIMimeType(args.UserImage),
       }}
   } else {
       // Otherwise, load the image from artifact storage
       userImgResp, err := ctx.Artifacts().Load(ctx, args.UserImage)
       userPart = userImgResp.Part
   }

La imagen del usuario puede provenir de dos fuentes:

  • Un nombre de artefacto (como upload_abc123_1): Es la carga inicial, guardada por la devolución de llamada SaveIncomingBlobs.
  • Un URI de gs://: Es un resultado de ajuste generado previamente y almacenado en GCS para su reutilización en diferentes sesiones.

Este diseño de doble ruta es intencional: cuando el agente de estilista genera más adelante pruebas de atuendos, reutiliza la URL de GCS del resultado inicial del probador para que la identidad del usuario se mantenga coherente en todos los atuendos.

Paso 2: Crea la instrucción multimodal

   parts := []*genai.Part{
       genai.NewPartFromText(toolInstructions),           // Identity preservation prompt
       genai.NewPartFromText("Reference Person Photo:"),
       userPart,                                          // The user's photo
   }


   for _, acc := range args.Accessories {
       accResp, _ := ctx.Artifacts().Load(ctx, acc)       // Load product image artifact
       parts = append(parts, genai.NewPartFromText("Product Image to Apply:"))
       parts = append(parts, accResp.Part)                // The product photo
   }

El toolInstructions (incorporado desde tool_instructions.md) es fundamental, ya que le indica a Gemini que conserve la identidad del usuario (rostro, tipo de cuerpo, tono de piel, cabello) y que solo aplique la prenda. Sin esta ingeniería de instrucciones, el modelo podría cambiar la apariencia de la persona.

Paso 3: Llama a Gemini para generar imágenes

   client, _ := genai.NewClient(ctx, &genai.ClientConfig{
       Backend:  genai.BackendVertexAI,                 // Vertex AI endpoint
       Project:  os.Getenv("GOOGLE_CLOUD_PROJECT"),     // From your .env
       Location: "global",                              // Multi-region endpoint
   })


   resp, _ := client.Models.GenerateContent(ctx, "gemini-2.5-flash-image",
       []*genai.Content{genai.NewContentFromParts(parts, "user")},
       &genai.GenerateContentConfig{
           ResponseModalities: []string{"TEXT", "IMAGE"},  // Request both text and image output
           Temperature:        genai.Ptr(float32(0.2)),    // Low temperature for consistency
       })

Los cuatro agentes y la herramienta de generación de imágenes comparten una sola ruta de autenticación: Backend: genai.BackendVertexAI con el ID del proyecto, autenticado a través de las credenciales predeterminadas de la aplicación. Los modelos de orquestación (gemini-3.1-pro-preview, gemini-3-flash-preview) y el modelo de imagen (gemini-2.5-flash-image) se encuentran detrás del mismo extremo de Vertex AI, y las mismas ADC también autorizan el acceso a Cloud Storage: una credencial para cada llamada.

Paso 4: Guarda el resultado

   // Find the image in the response (may contain both text + image parts)
   var genPart *genai.Part
   for _, p := range resp.Candidates[0].Content.Parts {
       if p.InlineData != nil {
           genPart = p
           break
       }
   }


   // Save as an ADK artifact
   artName := fmt.Sprintf("generated_fitting_%s_%s", ctx.InvocationID(), uuid.NewString()[:8])
   ctx.Artifacts().Save(ctx, artName, genPart)


   // Also upload to GCS for cross-session reuse
   objectName := fmt.Sprintf("generated-fittings/%s.jpg", ctx.InvocationID())
   w := storageClient.Bucket(bucket).Object(objectName).NewWriter(ctx)
   w.Write(genPart.InlineData.Data)
   gcsURI := fmt.Sprintf("gs://%s/%s", bucket, objectName)


   return FittingToolResult{ArtifactName: artName, GCSUrl: gcsURI}, nil

El guardado doble (artefacto + GCS) es la clave para la transferencia del agente entre el probador y el estilista. El artefacto proporciona acceso inmediato dentro de la sesión actual, mientras que el URI de GCS permite que el estilista (que se ejecuta en una sesión diferente) haga referencia a la misma imagen más adelante.

La devolución de llamada de SaveIncomingBlobs

Antes de que el agente comience a razonar, se ejecuta este BeforeAgentCallback para guardar las imágenes que subió el usuario:

func SaveIncomingBlobs(ctx agent.CallbackContext) (*genai.Content, error) {
   for pindex, p := range ctx.UserContent().Parts {
       if p.InlineData != nil {
           aname := fmt.Sprintf("upload_%s_%d", ctx.InvocationID(), pindex)
           ctx.Artifacts().Save(ctx, aname, p)
       }
   }
   return nil, nil  // Return nil to proceed with normal agent execution
}

Cuando se devuelve (nil, nil), la devolución de llamada indica "Terminé el procesamiento previo. Ahora, ejecuta el agente con normalidad". Si devolviera contenido no nulo, se cortocircuitaría todo el agente.

6.2 El agente de Stylist

Archivo:

adk_backend/stylist/agent.go

El agente de estilista es el más sofisticado del sistema. Selecciona recomendaciones de atuendos personalizadas y admite el perfeccionamiento iterativo a través de la conversación.

Tres devoluciones de llamada: La memoria de la estilista

El asistente usa tres devoluciones de llamada para mantener el contexto en las conversaciones de varios turnos:

Devolución de llamada 1:

InjectPreviousProducts (BeforeModel)

El problema: Si el usuario dice "muéstrame diferentes opciones", es posible que el LLM vuelva a sugerir los mismos productos porque no realiza un seguimiento inherente de lo que ya recomendó.

Solución: Después de cada respuesta, los IDs de los productos se guardan en el estado de la sesión. Antes de la siguiente llamada al LLM, esta devolución de llamada los lee y, luego, inserta una sugerencia:

func InjectPreviousProducts(ctx agent.CallbackContext, req *model.LLMRequest) (*model.LLMResponse, error) {
   prev, err := ctx.State().Get(stateKeyPreviousProducts)  // Read from session state
   if err != nil {
       return nil, nil  // No previous state  first run
   }


   // Append hint to the user's message
   for i := len(req.Contents) - 1; i >= 0; i-- {
       if req.Contents[i].Role == "user" {
           req.Contents[i].Parts = append(req.Contents[i].Parts,
               genai.NewPartFromText(fmt.Sprintf(
                   "IMPORTANT: You previously suggested these products: %s. "+
                   "You MUST pick DIFFERENT complementary products this time.", prev)))
           break
       }
   }
   return nil, nil  // Continue to LLM call
}

Devolución de llamada 2:

ExtractAndInjectUserImage (BeforeModel)

El problema: Cuando el usuario proporciona comentarios ("hazlo más informal"), el mensaje de seguimiento no incluye la foto del usuario nuevamente. Pero la herramienta de ajuste la necesita.

La solución: En la primera solicitud, esta devolución de llamada extrae la referencia de la imagen del usuario y la guarda en el estado. En las solicitudes posteriores, se vuelve a insertar:

func ExtractAndInjectUserImage(ctx agent.CallbackContext, req *model.LLMRequest) (*model.LLMResponse, error) {
   var foundImgStr string


   // Search for user image in the latest message
   for i := len(req.Contents) - 1; i >= 0; i-- {
       if req.Contents[i].Role == "user" {
           for _, part := range req.Contents[i].Parts {
               if strings.Contains(part.Text, "User try-on base image") {
                   foundImgStr = part.Text  // Found the GCS URI reference
               }
           }
           break
       }
   }


   if foundImgStr != "" {
       ctx.State().Set(stateKeyUserImageStr, foundImgStr)  // Save for later
   } else {
       // Not in current message  retrieve from state and inject
       val, _ := ctx.State().Get(stateKeyUserImageStr)
       if savedImgStr, ok := val.(string); ok {
           // Inject into the latest user message
           req.Contents[last].Parts = append(req.Contents[last].Parts,
               genai.NewPartFromText("REMINDER: Use this image: " + savedImgStr))
       }
   }
   return nil, nil
}

Devolución de llamada 3:

SaveSelectedProducts (AfterModel)

Después de que el LLM responde con sugerencias de atuendos, esta devolución de llamada analiza el JSON para extraer los IDs de los productos y los guarda para que la devolución de llamada InjectPreviousProducts los use la próxima vez:

func SaveSelectedProducts(ctx agent.CallbackContext, resp *model.LLMResponse, respErr error) (*model.LLMResponse, error) {
   for _, part := range resp.Content.Parts {
       ids := extractProductIDs(part.Text)  // Parse JSON  extract product IDs
       if len(ids) > 0 {
           data, _ := json.Marshal(ids)
           ctx.State().Set(stateKeyPreviousProducts, string(data))  // Save to state
       }
   }
   return nil, nil  // Don't modify the response
}

En conjunto, estas tres devoluciones de llamada crean un ciclo de retroalimentación:

Request 1:  User sends styling request + user image
            ExtractAndInjectUserImage SAVES image to state
            LLM generates 3 outfits
            SaveSelectedProducts SAVES product IDs to state


Request 2:  User says "make it more casual"
            ExtractAndInjectUserImage INJECTS saved image into prompt
            InjectPreviousProducts INJECTS "don't reuse these IDs"
            LLM generates 3 NEW outfits
            SaveSelectedProducts UPDATES product IDs in state

6.3 El agente raíz

Archivo:

adk_backend/rootagent/agent.go

El agente más simple, con solo 31 líneas:

func NewRootAgent(project string, fittingAgent, catalogAgent, stylistAgent agent.Agent) (agent.Agent, error) {
   m, _ := gemini.NewModel(ctx, "gemini-3-flash-preview", &genai.ClientConfig{
       Backend:  genai.BackendVertexAI,
       Project:  project,
       Location: "global",
   })


   return llmagent.New(llmagent.Config{
       Name:        "root_agent",
       Model:       m,
       Description: "A root agent that delegates to other agents",
       Instruction: "You are a helpful shopping assistant. If the user asks about fitting " +
           "items or generating images, delegate to the fitting room agent. If the user " +
           "asks about products, delegate to the catalog agent. If the user asks for " +
           "styling advice, delegate to the stylist agent.",
       SubAgents: []agent.Agent{fittingAgent, catalogAgent, stylistAgent},
   })
}

Utiliza gemini-3-flash-preview (el modelo más rápido) porque las decisiones de enrutamiento son simples: el LLM solo necesita leer la intención del usuario y elegir el agente secundario correcto. No se necesitan herramientas, ya que SubAgents controla la delegación automáticamente.

7. 📱 Arquitectura de frontend de Flutter

El frontend de Flutter es una app de compras minoristas completamente funcional. Las funciones basadas en IA se encuentran en flutter_frontend/lib/workshop_tasks/, separadas de la experiencia de compra prediseñada en core_app/.

El patrón MVVM

La app sigue la arquitectura Model-View-ViewModel con el paquete Provider:

┌──────────────────┐    ┌────────────────────┐    ┌──────────────────┐
  View (Widget)   │◀───│ ViewModel (Provider)│◀───│  Service (HTTP)  
                                                                 
  Renders UI           Holds state             Makes API calls
  User gestures  │───▶│  Business logic    │───▶│  Parses response
  Listens for          notifyListeners()       Returns data   
   state changes                                                 
└──────────────────┘    └────────────────────┘    └──────────────────┘

Cada capa tiene un rol claro:

  • Modelo: Clases de datos como Product, Outfit, StyleRequest y enumeraciones como TryOnState
  • ViewModel (ChangeNotifier): Contiene el estado actual y transmite los cambios a la IU a través de notifyListeners()
  • View (widget): Se suscribe al ViewModel con context.watch() y se vuelve a compilar cuando cambia el estado.
  • Servicio: Realiza llamadas HTTP al backend del ADK y devuelve datos escritos.

La capa de servicio

Los servicios se definen como interfaces abstractas, con implementaciones específicas del ADK:

// Abstract interface  defines WHAT the service does
abstract class TryItOnService {
 Future<(Uint8List?, String?)> generateTryOnImage(
   Uint8List userImageBytes,
   Uint8List productImageBytes,
 );
}


// Concrete implementation  defines HOW (via ADK REST API)
class AdkFittingRoomService implements TryItOnService { ... }

Esta separación significa que podrías intercambiar el backend del ADK por Firebase AI, un servicio simulado o cualquier otra implementación sin cambiar el resto de la app.

El patrón de API de 3 pasos

Tanto AdkFittingRoomService como AdkStylingService siguen el mismo patrón para comunicarse con el backend del ADK:

Paso 1: Crea una sesión

Future<String> _createSession() async {
 final url = Uri.parse(
   '$_baseUrl/apps/${Uri.encodeComponent(_appName)}/users/$_userId/sessions');
 final response = await _client.post(url,
   headers: {'Content-Type': 'application/json'},
   body: jsonEncode({}));
 final body = jsonDecode(response.body) as Map<String, dynamic>;
 return body['id'] as String;  // Returns the session ID
}

Paso 2: Ejecuta el agente

Future<(String?, String?)> _runAgent({required String sessionId, ...}) async {
 final requestBody = jsonEncode({
   'appName': _appName,
   'userId': _userId,
   'sessionId': sessionId,
   'newMessage': {
     'role': 'user',
     'parts': [
       {'text': 'Generate a virtual try-on...'},
       {'inlineData': {'mimeType': 'image/jpeg', 'data': base64Encode(userImageBytes)}},
       {'inlineData': {'mimeType': 'image/png', 'data': base64Encode(productImageBytes)}},
     ],
   },
 });
 final response = await _client.post(Uri.parse('$_baseUrl/run'), body: requestBody);
 // Parse response events for artifact name and GCS URL...
}

Paso 3: Recupera el artefacto

Future<Uint8List?> _loadArtifact({required String sessionId, required String artifactName}) async {
 final url = Uri.parse(
   '$_baseUrl/apps/$_appName/users/$_userId/sessions/$sessionId/artifacts/$artifactName');
 final response = await _client.get(url);
 final part = jsonDecode(response.body) as Map<String, dynamic>;
 final data = part['inlineData']['data'] as String;
 return base64Decode(data);  // Returns raw image bytes
}

Una diferencia de diseño fundamental es que el servicio de probador crea una sesión nueva para cada solicitud (se llama a _createSession() cada vez), mientras que el servicio de estilismo reutiliza la misma sesión (_sessionId ??= await _createSession()) para habilitar la conversación de varios turnos.

Administración de estado: TryItOnProvider

Archivo:

workshop_tasks/step_1_try_it_on/providers/try_it_on_provider.dart

El TryItOnProvider administra todo el flujo de prueba. Usa un enum TryOnState como máquina de estados:

enum TryOnState { initial, imagePicked, generating, success, error }


class TryItOnProvider with ChangeNotifier {
 TryOnState _state = TryOnState.initial;
 Uint8List? _userImageBytes;
 Uint8List? _generatedImage;
 String? _errorMessage;

Las transiciones de estado privadas garantizan la coherencia: nunca actualizas el estado sin borrar también los datos obsoletos y notificar a la IU:

 void _setGenerating() {
   _state = TryOnState.generating;
   _errorMessage = null;            // Clear any previous error
   _wasLastGenerationCached = false;
   notifyListeners();               // Tell the UI to rebuild
 }


 void _setSuccess(Uint8List image, {bool isCached = false}) {
   _generatedImage = image;
   _errorMessage = null;
   _wasLastGenerationCached = isCached;
   _state = TryOnState.success;
   notifyListeners();
 }

El método de generación principal une todo:

 Future<String?> generateTryOnImage(String productImagePath) async {
   final userImageBytes = _userImageBytes;
   if (userImageBytes == null) {
     _setError('No image selected.');
     return _errorMessage;
   }


   // Check local cache first
   final cachedImage = ImageUtils.getCachedImage(_sessionCache, userImageBytes, productUint8List);
   if (cachedImage != null) {
     _setSuccess(cachedImage, isCached: true);
     return null;
   }


   _setGenerating();  // Triggers loading state in UI


   try {
     final (generatedBytes, gcsUrl) = await _aiService.generateTryOnImage(
       userImageBytes, productUint8List);
     if (generatedBytes != null) {
       _fittingGcsUrl = gcsUrl;     // Save for the stylist agent later
       _setSuccess(generatedBytes);
     }
   } catch (e) {
     _setError(e.toString());
   }
   return _state == TryOnState.success ? null : _errorMessage;
 }

La IU: Pantallas como un router de estado

Archivo:

workshop_tasks/step_1_try_it_on/ui/2_try_it_on_screen.dart

La pantalla de prueba usa la coincidencia de patrones de Dart 3 con AnimatedSwitcher para enrutar entre subpantallas según el estado del proveedor:

class TryItOnScreen extends StatelessWidget {
 @override
 Widget build(BuildContext context) {
   final tryOnProvider = context.watch<TryItOnProvider>();


   return ScaffoldWithBackgroundNoise(
     appBar: const TryOnAppBar(),
     body: AnimatedSwitcher(
       duration: AppDurations.medium,
       child: switch (tryOnProvider.state) {
         TryOnState.initial || TryOnState.error => const ChooseImageScreen(),
         TryOnState.imagePicked || TryOnState.generating => LoadingScreen(
           userImage: tryOnProvider.userImageBytes),
         TryOnState.success => const FittingRoomScreen(),
       },
     ),
   );
 }
}

context.watch() se suscribe al proveedor. Cada vez que se llama a notifyListeners(), este widget se vuelve a compilar y AnimatedSwitcher realiza una transición fluida entre las pantallas. No hay Navigator.push: El contenido de la pantalla cambia en el mismo lugar según el enum del estado.

Transferencia de agente: Probador → Estilista

El patrón de UX más interesante es cómo la app pasa el contexto del agente del probador al agente del estilista.

En 5_fitting_room.dart, después de que se genera la imagen de prueba, el botón "Sugerir atuendo" abre un formulario. Cuando el usuario envía la solicitud, sucede lo siguiente:

// From 1_style_me_form_sheet.dart
Navigator.pop(context, StyleRequest(
 location: _locationController.text.trim(),
 occasion: _occasionController.text.trim(),
 notes: _notesController.text.trim(),
 gcsUserImageUrl: provider.fittingGcsUrl,          // GCS URI from fitting result
 userImageData: provider.fittingGcsUrl == null
     ? provider.userImageBytes : null,              // Fallback to raw bytes
 selectedProductId: provider.selectedProduct?.id,   // Product they already tried on
 selectedProductTitle: provider.selectedProduct?.title,
));

El StyleRequest incluye todo lo que necesita el estilista:

  • Ubicación y ocasión: Contexto de texto para el diseño
  • URL de la imagen del usuario de GCS: Para que el estilista pueda reutilizar la misma representación del usuario
  • Producto seleccionado: Para que el estilista lo incluya en todos los conjuntos

Esta es la transferencia de agentes, que transfiere sin problemas el contexto multimodal de un agente de IA a otro, y el usuario solo ve un formulario simple.

El flujo de diseño: StylingProvider

Archivo:

workshop_tasks/step_2_style_me/providers/styling_provider.dart

El StylingProvider es más simple que TryItOnProvider porque delega la mayor parte de la complejidad al backend:

class StylingProvider with ChangeNotifier {
 StylingState _state = StylingState.initial;
 List<Outfit> _outfits = [];


 Future<bool> getStyleSuggestions(StyleRequest request) async {
   if (_state == StylingState.loading) return false;  // Prevent spam
   _currentRequest = request;
   _setLoading();
   try {
     final suggestions = await _stylingService.getStyleSuggestions(request);
     _setSuccess(suggestions);
     return true;
   } catch (e) {
     _setError('Something went wrong.');
   }
   return false;
 }


 // Multi-turn refinement  uses the same session
 Future<bool> refineWithFeedback(String feedback) async {
   if (_state == StylingState.loading) return false;
   _setLoading();
   try {
     final suggestions = await _stylingService.refineWithFeedback(feedback);
     _setSuccess(suggestions);
     return true;
   } catch (e) {
     _setError('Something went wrong.');
   }
   return false;
 }
}

El método refineWithFeedback envía un mensaje de texto sin formato a la misma sesión. Las devoluciones de llamada InjectPreviousProducts y ExtractAndInjectUserImage del backend controlan automáticamente toda la administración del contexto.

8. 🚀 Ejecuta la app de forma local

Para una experiencia fluida de Cloud Shell, el backend de Go entrega la app web de Flutter compilada desde el mismo puerto (8080). Un proceso, una URL de vista previa, sin problemas de origen cruzado ni edición de archivos de configuración.

Antes de comenzar: Comprueba el ADC

El backend necesita credenciales predeterminadas de la aplicación para llamar a Vertex AI. Si completaste el paso 7 de la configuración del proyecto en esta sesión de Cloud Shell y esta Cuenta de Google, todo está listo. Si regresas después de un descanso, cambiaste de cuenta o no tienes certeza, dedica 5 segundos a verificar lo siguiente:

gcloud auth application-default print-access-token | head -c 20 && echo "..."

Si se imprimen alrededor de 20 caracteres de un token, todo está listo. Si se produce un error, vuelve a ejecutar el paso 7 de la configuración del proyecto:

gcloud auth application-default login
gcloud auth application-default set-quota-project $(gcloud config get-value project)

Usarás dos terminales de Cloud Shell:

  • Terminal A: Ejecuta el backend de forma continua (./run.sh). Déjala abierta.
  • Terminal B: Ejecuta la compilación web de Flutter una vez (flutter build web) y se cierra cuando termina.

El orden no importa, puedes comenzar con cualquiera de los dos. Sin embargo, para obtener la experiencia inicial más limpia, primero compila Flutter para que el backend tenga una IU que mostrar desde el momento en que se inicia.

1. Terminal B: Compila el paquete web de Flutter (una sola vez)

Abre una nueva pestaña de Cloud Shell (el signo + en la parte superior del panel de la terminal) y, luego, haz lo siguiente:

cd ~/fashion_app_demo/flutter_frontend
flutter pub get
flutter build web

Esto genera flutter_frontend/build/web/, un directorio de archivos estáticos (HTML, JS, recursos), y sale cuando finaliza. El backend publicará estos archivos en cuanto vea que existe el directorio.

2. Terminal A: Inicia el backend (de larga duración)

En la terminal original de Cloud Shell, haz lo siguiente:

cd ~/fashion_app_demo/adk_backend
./run.sh

Deberías ver algo como lo siguiente:

Serving Flutter web build from ../flutter_frontend/build/web

Deja esta terminal en ejecución: El backend permanece activo mientras run.sh esté activo. Para detenerla, presiona Ctrl+C.

El servidor expone todo en el puerto 8080:

  • /: App web de Flutter (la IU de compras)
  • /api/: Extremos de REST del ADK (a los que llama la app de Flutter)
  • IU de desarrollo del ADK: También en / cuando no hay una compilación de Flutter; útil para la depuración directa del agente

3. Abrir la vista previa en la Web

  1. En Cloud Shell, haz clic en el ícono de Vista previa en la Web (en la esquina superior derecha) → Vista previa en el puerto 8080.
  2. La app de compras de Flutter se carga en una pestaña nueva
  3. Explorar el catálogo de productos y seleccionar un artículo
  4. Presiona el ícono de persona (👤) para iniciar el flujo de prueba.
  5. Sube una foto y mira cómo la IA genera una imagen de prueba virtual
  6. Presiona "Sugerir atuendo" para obtener recomendaciones de atuendos.
  7. Escribe comentarios de seguimiento, como "haz que sea más informal" (refinamiento en la misma sesión).

9. ☁️ Implementa en Cloud Run

Agrupa la compilación de Flutter en el backend

El contenedor de Cloud Run incluye la API y la IU en una sola imagen. Copia la compilación web de Flutter en adk_backend/flutter_web/, que es la primera ruta que verifica el servidor de Go cuando elige qué IU publicar:

cd ~/fashion_app_demo/flutter_frontend
flutter build web
rm -rf ../adk_backend/flutter_web
cp -r build/web ../adk_backend/flutter_web

(Si has estado iterando de forma local, es posible que ya tengas build/web del paso Ejecutar de forma local. Volver a ejecutar flutter build web sigue siendo una buena opción.

Implementa el backend (publica la API y la IU)

cd ~/fashion_app_demo/adk_backend

gcloud run deploy fashion-app-backend \
 --source . \
 --region us-central1 \
 --allow-unauthenticated \
 --set-env-vars "GOOGLE_CLOUD_PROJECT=$PROJECT_ID,GCS_BUCKET=fashion-app-$PROJECT_ID" \
 --memory 1Gi \
 --cpu 2 \
 --timeout 300s \
 --min-instances 0 \
 --max-instances 3

Cuando finalice la implementación, obtendrás una URL de servicio como https://fashion-app-backend-xyz-uc.a.run.app. Ábrela en un navegador. La app de compras de Flutter se carga desde / y sus llamadas a la API se dirigen a /api/ en el mismo host. No se necesitan ediciones en la configuración del frontend ni se pasó una clave de API.

Verifica la implementación

Abre la URL de Cloud Run en tu navegador y ejecuta el flujo completo:

  1. Explorar → Selecciona un producto
  2. Probador → Sube tu foto → Mira la imagen generada por IA
  3. Sugerencias de estilo → Completa la ubicación o la ocasión → Ve atuendos seleccionados
  4. Comentarios → Escribe "Hazlo más informal" → Mira los atuendos actualizados
  5. Agregar a la bolsa → Completa el flujo de compra

10. 🎉 Conclusión

Qué compilaste

Exploraste una experiencia de venta minorista completa potenciada por IA con lo siguiente:

  • ✅ Un backend multiagente con 4 agentes especializados que trabajan juntos
  • ✅ Un probador virtual que genera imágenes de prueba personalizadas
  • ✅ Un estilista de IA que selecciona atuendos y los perfecciona a través de conversaciones
  • ✅ Una app de Flutter multiplataforma que se conecta al backend del agente
  • Implementación de Cloud Run para un hosting escalable y sin servidores

Conceptos clave

Concepto

Dónde lo viste

Organización de varios agentes con el ADK

Enrutamiento del agente raíz a los agentes de probador, catálogo y estilista

Generación de imágenes multimodales con Gemini

fitting_tool combinar fotos de usuarios con imágenes de productos

Estado de la sesión para la IA conversacional

Sesiones de reutilización de estilistas para obtener comentarios iterativos

Almacenamiento de artefactos para datos binarios

Separación del almacenamiento de imágenes de las respuestas de texto

Devoluciones de llamada para la lógica de middleware

SaveIncomingBlobs, InjectPreviousProducts, SaveSelectedProducts

MVVM + Provider en Flutter

TryItOnProvider y StylingProvider con ChangeNotifier

Transferencia de agente

StyleRequest pasar contexto multimodal entre agentes

Próximos pasos

  • 🎨 Personaliza las instrucciones del agente: Edita instructions.md para cambiar la personalidad del estilista.
  • 🛍️ Agrega más productos: Actualiza catalog.yaml con elementos nuevos.
  • 📱 Crea pensando en dispositivos móviles: Ejecuta flutter build ios o flutter build apk
  • 🔄 Agrega sesiones persistentes: Reemplaza InMemoryService por una implementación respaldada por una base de datos
  • 🔒 Agrega autenticación: Protege el extremo de Cloud Run con IAM

Recursos