Inicio / Artículos / App de chat con IA en Flutter y Gemini: respuestas en streaming y Riverpod

App de chat con IA en Flutter y Gemini: respuestas en streaming y Riverpod

Crea un chatbot en Flutter sobre la API de Gemini: respuestas en streaming, memoria con ChatSession, estado con Riverpod, manejo seguro de la clave de API, errores comunes y consejos prácticos.

1604 palabras

Añadir un chat con la API de Gemini a una app de Flutter es una de las formas más rápidas de hacer que parezca inteligente. También es fácil atascarse por el camino con el streaming, la gestión de la clave de API o el manejo del estado. Lo que sigue es un recorrido completo por un chatbot funcional con respuestas en streaming, desde la creación del proyecto hasta la pantalla de chat, junto con los errores en los que más se tropieza y algunos hábitos que vale la pena adoptar antes de publicar.

Por qué fallan estas integraciones

La mayoría trata un chat con IA como cualquier otra llamada REST: envías la petición, esperas y muestras el resultado. Con los modelos de lenguaje eso se desmorona enseguida, por cuatro motivos.

  • Las respuestas tardan. Sin streaming, cada mensaje deja al usuario mirando una pantalla vacía durante unos 3 a 8 segundos.
  • El estado del chat es más enrevesado que el estado normal de una app. Hay que seguir a la vez el historial de mensajes, los indicadores de carga, los errores y los tokens que llegan en streaming.
  • Las claves de API se escriben en el código. Muchos tutoriales pegan la clave de Gemini directamente en la app, lo cual es arriesgado porque las apps de Flutter se distribuyen como binarios compilados a los que se puede aplicar ingeniería inversa.
  • El contexto desaparece. Si cada mensaje se envía por separado, Gemini olvida los turnos anteriores.

El resultado es una app que parece lenta, insegura o torpe, aunque el modelo que hay detrás sea potente.

El plan

Este es el enfoque que se usa a continuación:

  1. Hablar con Gemini mediante el paquete oficial de Dart google_generative_ai.
  2. Recibir la respuesta en streaming token a token, para que parezca que alguien está escribiendo.
  3. Guardar el historial en la ChatSession de Gemini, para que el contexto se mantenga.
  4. Gestionar el estado con Riverpod, que mantiene la interfaz reactiva y fácil de probar.
  5. Mantener la clave de API fuera del cliente con configuración basada en entorno y un proxy en el backend para producción.

El resultado se comporta como ChatGPT y no como un lento formulario de petición y respuesta.

Paso 1: crear el proyecto

Crea un proyecto de Flutter nuevo o usa uno que ya tengas:

flutter create ai_chat_gemini
cd ai_chat_gemini

Consigue una clave de API de Gemini en Google AI Studio, en aistudio.google.com, y declara las dependencias:

# pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  google_generative_ai: ^0.4.6
  flutter_riverpod: ^2.5.1
  flutter_dotenv: ^5.1.0

Descárgalas:

flutter pub get

Paso 2: configurar la clave

Guarda la clave en un archivo .env en lugar de escribirla en el código, y añade ese archivo a .gitignore de inmediato.

# .env
GEMINI_API_KEY=your_api_key_here

Cárgala en main.dart, empezando por los imports:

import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'chat_screen.dart';

Luego inicializa dotenv antes de ejecutar la app, envuelta en un ProviderScope:

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await dotenv.load(fileName: ".env");
  runApp(const ProviderScope(child: MyApp()));
}class MyApp extends StatelessWidget {
  const MyApp({super.key});  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Gemini AI Chat',
      theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.indigo),
      home: const ChatScreen(),
    );
  }
}

Una advertencia para cualquier cosa pública: no incluyas la clave de Gemini en el cliente en ningún caso. Envía las peticiones a través de un pequeño backend, como Firebase Cloud Functions o un endpoint sencillo en Node o Laravel, para que la clave se quede en el servidor. Lo que se construye aquí sirve para prototipos y herramientas internas.

Paso 3: el código principal

El modelo de mensaje

Cada mensaje guarda su texto, quién lo envió y si todavía está llegando en streaming.

// lib/models/chat_message.dart
class ChatMessage {
  final String text;
  final bool isUser;
  final bool isStreaming;

Un método copyWith permite actualizar solo el texto o el indicador de streaming:

  const ChatMessage({
    required this.text,
    required this.isUser,
    this.isStreaming = false,
  });  ChatMessage copyWith({String? text, bool? isStreaming}) {
    return ChatMessage(
      text: text ?? this.text,
      isUser: isUser,
      isStreaming: isStreaming ?? this.isStreaming,
    );
  }
}

El servicio de Gemini

Esta pieza envuelve la ChatSession de Gemini para que el contexto de la conversación se mantenga entre mensajes.

// lib/services/gemini_service.dart
import 'package:google_generative_ai/google_generative_ai.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';

La clase crea el modelo con gemini-1.5-flash, inicia un chat, expone un stream de fragmentos de texto y ofrece un método de reinicio:

class GeminiService {
  late final GenerativeModel _model;
  late ChatSession _chatSession;  GeminiService() {
    _model = GenerativeModel(
      model: 'gemini-1.5-flash',
      apiKey: dotenv.env['GEMINI_API_KEY']!,
    );
    _chatSession = _model.startChat();
  }  Stream<String> sendMessageStream(String message) async* {
    final response = _chatSession.sendMessageStream(
      Content.text(message),
    );    await for (final chunk in response) {
      if (chunk.text != null) {
        yield chunk.text!;
      }
    }
  }  void resetChat() {
    _chatSession = _model.startChat();
  }
}

El detalle clave es que startChat() devuelve una ChatSession que lleva el historial de mensajes por ti. Gemini recuerda los turnos anteriores sin que tengas que reenviar toda la conversación cada vez.

Estado con Riverpod

// lib/providers/chat_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/chat_message.dart';
import '../services/gemini_service.dart';

El notifier añade el mensaje del usuario y después un mensaje vacío del bot marcado como en streaming. A medida que llegan fragmentos, los añade solo a ese último mensaje, y cuando el stream termina, quita la marca:

final geminiServiceProvider = Provider((ref) => GeminiService());class ChatNotifier extends StateNotifier<List<ChatMessage>> {
  final GeminiService _geminiService;  ChatNotifier(this._geminiService) : super([]);  Future<void> sendMessage(String text) async {
    state = [...state, ChatMessage(text: text, isUser: true)];    final botMessage = ChatMessage(text: '', isUser: false, isStreaming: true);
    state = [...state, botMessage];    String fullResponse = '';    await for (final chunk in _geminiService.sendMessageStream(text)) {
      fullResponse += chunk;
      final updated = [...state];
      updated[updated.length - 1] = updated.last.copyWith(
        text: fullResponse,
        isStreaming: true,
      );
      state = updated;
    }    final finalList = [...state];
    finalList[finalList.length - 1] =
        finalList.last.copyWith(isStreaming: false);
    state = finalList;
  }
}final chatProvider =
    StateNotifierProvider<ChatNotifier, List<ChatMessage>>((ref) {
  return ChatNotifier(ref.read(geminiServiceProvider));
});

Paso 4: la pantalla de chat

// lib/chat_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'providers/chat_provider.dart';

La pantalla observa la lista de mensajes y dibuja una lista desplazable de burbujas, con los mensajes del usuario a la derecha en añil y los de la IA a la izquierda en gris, además de un campo de texto y un botón de enviar abajo:

class ChatScreen extends ConsumerStatefulWidget {
  const ChatScreen({super.key});  @override
  ConsumerState<ChatScreen> createState() => _ChatScreenState();
}class _ChatScreenState extends ConsumerState<ChatScreen> {
  final _controller = TextEditingController();
  final _scrollController = ScrollController();  void _handleSend() {
    final text = _controller.text.trim();
    if (text.isEmpty) return;    ref.read(chatProvider.notifier).sendMessage(text);
    _controller.clear();
  }  @override
  Widget build(BuildContext context) {
    final messages = ref.watch(chatProvider);    return Scaffold(
      appBar: AppBar(title: const Text('Gemini AI Chat')),
      body: Column(
        children: [
          Expanded(
            child: ListView.builder(
              controller: _scrollController,
              padding: const EdgeInsets.all(12),
              itemCount: messages.length,
              itemBuilder: (context, index) {
                final message = messages[index];
                return Align(
                  alignment: message.isUser
                      ? Alignment.centerRight
                      : Alignment.centerLeft,
                  child: Container(
                    margin: const EdgeInsets.symmetric(vertical: 4),
                    padding: const EdgeInsets.all(12),
                    constraints: BoxConstraints(
                      maxWidth: MediaQuery.of(context).size.width * 0.75,
                    ),
                    decoration: BoxDecoration(
                      color: message.isUser
                          ? Colors.indigo
                          : Colors.grey.shade200,
                      borderRadius: BorderRadius.circular(14),
                    ),
                    child: Text(
                      message.text.isEmpty ? '...' : message.text,
                      style: TextStyle(
                        color: message.isUser ? Colors.white : Colors.black87,
                      ),
                    ),
                  ),
                );
              },
            ),
          ),
          SafeArea(
            child: Padding(
              padding: const EdgeInsets.all(8.0),
              child: Row(
                children: [
                  Expanded(
                    child: TextField(
                      controller: _controller,
                      decoration: const InputDecoration(
                        hintText: 'Ask something...',
                        border: OutlineInputBorder(),
                      ),
                      onSubmitted: (_) => _handleSend(),
                    ),
                  ),
                  IconButton(
                    icon: const Icon(Icons.send),
                    onPressed: _handleSend,
                  ),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }
}

Así tienes una interfaz de chat con Gemini en streaming y una separación limpia entre la interfaz y la lógica de negocio.

Errores que se repiten una y otra vez

Escribir la clave en el código. Cualquiera que descompile el APK o el IPA puede leerla. Usa variables de entorno durante el desarrollo y un proxy en el backend en producción.

Prescindir de ChatSession. Las llamadas aisladas a generateContent() no dan a Gemini memoria de los mensajes anteriores. Usa startChat() para que el contexto se mantenga.

Ignorar el streaming. Esperar la respuesta completa antes de pintar nada hace que la app parezca lenta, y mostrar los tokens según llegan mejora notablemente la velocidad percibida.

Reconstruir toda la lista por cada token. Las actualizaciones de estado descuidadas provocan reconstrucciones costosas con cada fragmento. Limita las actualizaciones, como en el notifier de arriba, que solo toca el último mensaje.

Sin manejo de errores. Los límites de peticiones y los fallos de red ocurren. Envuelve sendMessageStream en try/catch y ofrece reintentar en lugar de fallar en silencio.

Hábitos que vale la pena adoptar

  • Aplica debounce al envío para que nadie pueda aporrear el botón mientras llega una respuesta.
  • Limita el historial de la conversación. Las sesiones largas aumentan el consumo de tokens y el coste, así que recorta los mensajes antiguos cuando haga falta.
  • Elige gemini-1.5-flash por velocidad y gemini-1.5-pro para razonamientos más difíciles. Para la mayoría de chats, Flash es más barato y más rápido.
  • Añade un botón de parar que cancele la suscripción al stream cuando alguien quiera cortar una respuesta larga.
  • Guarda el historial en local con Hive o SQLite para que los chats sobrevivan a los reinicios.
  • Usa una arquitectura limpia, con el servicio, los notifiers y la interfaz en capas separadas, para que cambiar más adelante a OpenAI o Claude no obligue a reescribir la interfaz.

Dónde encaja este patrón

La misma estructura sirve para apps SaaS que añaden un asistente integrado para soporte u onboarding, apps móviles con ayudantes de escritura, resumidores o preguntas y respuestas, paneles de administración donde el personal consulta datos en lenguaje natural y cualquier sistema que necesite un chatbot ligero sin montar infraestructura de ML. Como el servicio de Gemini está aislado de la interfaz, la pantalla de chat es fácil de reutilizar para un bot de soporte, un asistente interno de conocimiento o una herramienta de IA de nicho dentro de una app más grande.

Conclusiones

Ya tienes un chatbot de Gemini con streaming en Flutter, con un manejo correcto del estado, contexto de conversación y una interfaz ágil. Recuerda cuatro cosas: usa ChatSession para el contexto, haz streaming en lugar de esperar, mantén la clave de API fuera del cliente en producción y separa la lógica de Gemini de la interfaz con Riverpod o el gestor de estado que prefieras. Es base suficiente para publicar y se amplía fácilmente con persistencia, varios modelos o prompts de sistema propios.