tutoriales.com

Flutter: Creando un Cliente de Chat en Tiempo Real con WebSockets y Provider

Este tutorial te guiará en la creación de una aplicación de chat en tiempo real con Flutter. Exploraremos cómo implementar la comunicación bidireccional utilizando WebSockets y gestionaremos el estado de la aplicación de manera eficiente con el paquete Provider, permitiéndote construir interfaces de usuario reactivas y fluidas.

Intermedio20 min de lectura9 views
Reportar error

🚀 Introducción al Chat en Tiempo Real con Flutter y WebSockets

En la era digital actual, las aplicaciones de mensajería y chat en tiempo real son omnipresentes. Desde las redes sociales hasta las herramientas de colaboración empresarial, la capacidad de comunicarse instantáneamente es fundamental. Flutter, con su rendimiento excepcional y su flexibilidad para construir interfaces de usuario atractivas, es una plataforma ideal para desarrollar este tipo de aplicaciones.

En este tutorial, nos embarcaremos en el emocionante viaje de construir un cliente de chat en tiempo real desde cero. Utilizaremos WebSockets para establecer una conexión persistente y bidireccional entre nuestro cliente Flutter y un servidor (que simularemos o te daremos una base para conectar). Para una gestión de estado robusta y escalable, integraremos el popular paquete Provider.

¿Por qué WebSockets para el Chat en Tiempo Real? 🤔

HTTP, el protocolo estándar para la web, es stateless y request-response. Esto significa que un cliente envía una solicitud y el servidor responde, cerrando la conexión. Para el chat, donde los mensajes deben fluir en ambas direcciones en cualquier momento, HTTP sería ineficiente (requeriría polling constante).

💡 **Consejo:** El *polling* implica que el cliente pregunta repetidamente al servidor si hay nuevos datos, lo que consume recursos y añade latencia.

WebSockets, por otro lado, establecen una conexión persistente y bidireccional sobre una única conexión TCP. Una vez establecida, tanto el cliente como el servidor pueden enviar datos en cualquier momento sin necesidad de reestablecer la conexión. Esto lo hace ideal para aplicaciones que requieren baja latencia y comunicación full-duplex como el chat, notificaciones push, juegos en línea, etc.

PROTOCOLO HTTP (Unidireccional) CLIENTE SERVIDOR Solicitud (Request) Respuesta (Response) CONEXIÓN CERRADA Nueva Solicitud PROTOCOLO WEBSOCKET (Bidireccional) CLIENTE SERVIDOR Handshake HTTP Upgrade FLUJO DE DATOS CONTINUO Conexión Abierta / Baja Latencia

Gestión de Estado con Provider ✨

Provider es una de las soluciones de gestión de estado más populares y recomendadas en Flutter. Es simple, escalable y muy eficiente. Nos permite:

  • Exponer datos a través del árbol de widgets.
  • Reconstruir selectivamente solo los widgets que dependen de esos datos cuando cambian.
  • Separar la lógica de negocio de la interfaz de usuario.

Utilizaremos Provider para gestionar la lista de mensajes, el estado de la conexión WebSocket y la información del usuario.


🛠️ Configuración del Entorno y Dependencias

Antes de sumergirnos en el código, necesitamos configurar nuestro proyecto Flutter y añadir las dependencias necesarias.

1. Crear un Nuevo Proyecto Flutter

Si aún no tienes uno, crea un nuevo proyecto Flutter desde tu terminal:

flutter create flutter_websocket_chat
cd flutter_websocket_chat

2. Añadir Dependencias 📦

Necesitaremos dos paquetes principales:

  • web_socket_channel: Para la comunicación con WebSockets.
  • provider: Para la gestión de estado. \Abre tu archivo pubspec.yaml y añade las siguientes líneas bajo dependencies:
dependencies:
  flutter:
    sdk: flutter
  web_socket_channel: ^2.4.0
  provider: ^6.0.5

Luego, ejecuta flutter pub get para descargar los paquetes:

flutter pub get
📌 **Nota:** Asegúrate de que las versiones de los paquetes sean compatibles o usa las últimas estables.

🔌 Implementando la Conexión WebSocket

Crearemos una clase WebSocketService que encapsulará toda la lógica relacionada con la conexión y comunicación WebSocket.

1. Definir el Modelo de Mensaje 💬

Primero, necesitamos un modelo simple para nuestros mensajes. Crea un archivo lib/models/message.dart:

// lib/models/message.dart
import 'package:flutter/foundation.dart';

class Message {
  final String sender;
  final String content;
  final DateTime timestamp;

  Message({
    required this.sender,
    required this.content,
    required this.timestamp,
  });

  factory Message.fromJson(Map<String, dynamic> json) {
    return Message(
      sender: json['sender'] as String,
      content: json['content'] as String,
      timestamp: DateTime.parse(json['timestamp'] as String),
    );
  }

  Map<String, dynamic> toJson() => {
        'sender': sender,
        'content': content,
        'timestamp': timestamp.toIso8601String(),
      };

  @override
  String toString() {
    return 'Message{sender: $sender, content: $content, timestamp: $timestamp}';
  }
}

2. Crear WebSocketService 🌐

Ahora, implementemos el servicio WebSocket. Crea un archivo lib/services/websocket_service.dart:

// lib/services/websocket_service.dart
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:flutter_websocket_chat/models/message.dart';

enum WebSocketStatus {
  connecting,
  connected,
  disconnected,
  error,
}

class WebSocketService extends ChangeNotifier {
  WebSocketChannel? _channel;
  final String _url;
  WebSocketStatus _status = WebSocketStatus.disconnected;
  final List<Message> _messages = [];
  String? _username;

  WebSocketService(this._url);

  WebSocketStatus get status => _status;
  List<Message> get messages => _messages;
  String? get username => _username;

  void setUsername(String name) {
    _username = name;
    notifyListeners();
  }

  Future<void> connect() async {
    if (_status == WebSocketStatus.connected || _username == null) return;

    _status = WebSocketStatus.connecting;
    notifyListeners();
    print('Attempting to connect to $_url');

    try {
      _channel = WebSocketChannel.connect(Uri.parse(_url));
      await _channel!.ready; // Espera a que la conexión esté lista
      _status = WebSocketStatus.connected;
      print('WebSocket connected.');
      notifyListeners();

      _channel!.stream.listen(
        (data) {
          print('Received: $data');
          try {
            final Map<String, dynamic> json = jsonDecode(data as String);
            final message = Message.fromJson(json);
            _messages.add(message);
            notifyListeners();
          } catch (e) {
            print('Error decoding message: $e');
          }
        },
        onDone: () {
          print('WebSocket disconnected.');
          _status = WebSocketStatus.disconnected;
          notifyListeners();
          // Opcional: intentar reconectar
          // Future.delayed(Duration(seconds: 5), () => connect());
        },
        onError: (error) {
          print('WebSocket error: $error');
          _status = WebSocketStatus.error;
          notifyListeners();
          disconnect(); // Cierra la conexión en caso de error
        },
        cancelOnError: true,
      );
    } catch (e) {
      print('Failed to connect to WebSocket: $e');
      _status = WebSocketStatus.error;
      notifyListeners();
    }
  }

  void sendMessage(String content) {
    if (_status == WebSocketStatus.connected && _username != null) {
      final message = Message(
        sender: _username!,
        content: content,
        timestamp: DateTime.now(),
      );
      final jsonMessage = jsonEncode(message.toJson());
      _channel!.sink.add(jsonMessage);
      print('Sent: $jsonMessage');
    } else {
      print('Cannot send message. Not connected or username not set.');
    }
  }

  void disconnect() {
    if (_channel != null && _status != WebSocketStatus.disconnected) {
      _channel!.sink.close();
      _status = WebSocketStatus.disconnected;
      notifyListeners();
      print('WebSocket closed.');
    }
  }

  @override
  void dispose() {
    disconnect();
    super.dispose();
  }
}

Vamos a desglosar este servicio:

  • _url: La dirección de nuestro servidor WebSocket.
  • _channel: La instancia de WebSocketChannel que gestiona la conexión.
  • _status: Un enum para rastrear el estado actual de la conexión.
  • _messages: Una lista para almacenar los mensajes recibidos.
  • _username: El nombre del usuario actual, necesario para enviar mensajes.
  • connect(): Inicializa la conexión WebSocket, escucha los mensajes entrantes y maneja los eventos de cierre o error.
    • _channel!.stream.listen(): Este es el corazón de la recepción de mensajes. Cuando llega un mensaje, se decodifica de JSON a nuestro objeto Message y se añade a la lista.
  • sendMessage(): Envía un mensaje al servidor después de serializarlo a JSON.
  • disconnect(): Cierra la conexión WebSocket de forma segura.
  • dispose(): Importante para liberar recursos cuando el servicio ya no es necesario.
⚠️ **Advertencia:** Para que este tutorial sea completamente funcional, necesitarás un servidor WebSocket. Puedes usar un servidor de prueba (`ws://echo.websocket.events`) para probar la conexión, pero para un chat real necesitarás uno que retransmita mensajes a todos los clientes conectados. Más adelante te daré una sugerencia de implementación de servidor básico.

🧩 Integración con Provider y la Interfaz de Usuario

Ahora que tenemos nuestro servicio WebSocket, vamos a integrarlo con Provider para construir la interfaz de usuario del chat.

1. Configurar Provider en main.dart 🌳

Envuelve tu aplicación con ChangeNotifierProvider para que WebSocketService esté disponible en todo el árbol de widgets. Abre lib/main.dart y modifícalo:

// lib/main.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter_websocket_chat/services/websocket_service.dart';
import 'package:flutter_websocket_chat/screens/chat_screen.dart';
import 'package:flutter_websocket_chat/screens/username_screen.dart';

void main() {
  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (_) => WebSocketService('ws://echo.websocket.events'), // ¡Cambia esta URL a tu servidor!
      child: MaterialApp(
        title: 'Flutter WebSocket Chat',
        theme: ThemeData(
          primarySwatch: Colors.blue,
          visualDensity: VisualDensity.adaptivePlatformDensity,
        ),
        home: const UsernameScreen(),
      ),
    );
  }
}
🔥 **Importante:** Cambia la URL de `WebSocketService` por la de tu propio servidor WebSocket una vez que lo tengas configurado. Para pruebas iniciales, `ws://echo.websocket.events` es útil porque simplemente reenvía cualquier mensaje que recibe.

2. Pantalla de Entrada de Nombre de Usuario 👋

Necesitamos una pantalla para que el usuario ingrese su nombre antes de unirse al chat. Crea lib/screens/username_screen.dart:

// lib/screens/username_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter_websocket_chat/services/websocket_service.dart';
import 'package:flutter_websocket_chat/screens/chat_screen.dart';

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

  @override
  State<UsernameScreen> createState() => _UsernameScreenState();
}

class _UsernameScreenState extends State<UsernameScreen> {
  final TextEditingController _usernameController = TextEditingController();

  @override
  void dispose() {
    _usernameController.dispose();
    super.dispose();
  }

  void _joinChat() {
    final username = _usernameController.text.trim();
    if (username.isNotEmpty) {
      Provider.of<WebSocketService>(context, listen: false).setUsername(username);
      Navigator.pushReplacement(
        context,
        MaterialPageRoute(builder: (context) => const ChatScreen()),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Bienvenido al Chat'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            TextField(
              controller: _usernameController,
              decoration: const InputDecoration(
                labelText: 'Ingresa tu nombre de usuario',
                border: OutlineInputBorder(),
              ),
              onSubmitted: (_) => _joinChat(),
            ),
            const SizedBox(height: 20),
            ElevatedButton(
              onPressed: _joinChat,
              child: const Text('Unirse al Chat'),
            ),
          ],
        ),
      ),
    );
  }
}

3. Pantalla Principal del Chat 💬

Esta será la pantalla donde se mostrarán los mensajes y el usuario podrá enviar los suyos. Crea lib/screens/chat_screen.dart:

// lib/screens/chat_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter_websocket_chat/services/websocket_service.dart';
import 'package:intl/intl.dart'; // Para formatear la hora, añade intl a pubspec.yaml si no lo tienes
import 'package:flutter_websocket_chat/models/message.dart';

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

  @override
  State<ChatScreen> createState() => _ChatScreenState();
}

class _ChatScreenState extends State<ChatScreen> {
  final TextEditingController _messageController = TextEditingController();
  final ScrollController _scrollController = ScrollController();

  @override
  void initState() {
    super.initState();
    // Conectar al WebSocket cuando la pantalla se inicializa
    WidgetsBinding.instance.addPostFrameCallback((_) {
      Provider.of<WebSocketService>(context, listen: false).connect();
    });
  }

  @override
  void dispose() {
    _messageController.dispose();
    _scrollController.dispose();
    // Desconectar al salir de la pantalla
    Provider.of<WebSocketService>(context, listen: false).disconnect();
    super.dispose();
  }

  void _sendMessage() {
    final content = _messageController.text.trim();
    if (content.isNotEmpty) {
      Provider.of<WebSocketService>(context, listen: false).sendMessage(content);
      _messageController.clear();
      _scrollToBottom();
    }
  }

  void _scrollToBottom() {
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (_scrollController.hasClients) {
        _scrollController.animateTo(
          _scrollController.position.maxScrollExtent,
          duration: const Duration(milliseconds: 300),
          curve: Curves.easeOut,
        );
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Consumer<WebSocketService>(
          builder: (context, service, child) {
            String titleText = 'Chat';
            Color statusColor = Colors.grey;
            switch (service.status) {
              case WebSocketStatus.connected:
                titleText = 'Chat (${service.username ?? 'Anónimo'}) - Conectado';
                statusColor = Colors.green;
                break;
              case WebSocketStatus.connecting:
                titleText = 'Chat (${service.username ?? 'Anónimo'}) - Conectando...';
                statusColor = Colors.orange;
                break;
              case WebSocketStatus.disconnected:
                titleText = 'Chat (${service.username ?? 'Anónimo'}) - Desconectado';
                statusColor = Colors.red;
                break;
              case WebSocketStatus.error:
                titleText = 'Chat (${service.username ?? 'Anónimo'}) - Error';
                statusColor = Colors.deepOrange;
                break;
            }
            return Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                Text(titleText),
                Icon(Icons.circle, color: statusColor, size: 12),
              ],
            );
          },
        ),
      ),
      body: Column(
        children: [
          Expanded(
            child: Consumer<WebSocketService>(
              builder: (context, service, child) {
                _scrollToBottom(); // Asegura que se desplace al final al recibir nuevos mensajes
                return ListView.builder(
                  controller: _scrollController,
                  padding: const EdgeInsets.all(8.0),
                  itemCount: service.messages.length,
                  itemBuilder: (context, index) {
                    final message = service.messages[index];
                    final isMe = message.sender == service.username;
                    return Align(
                      alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
                      child: Container(
                        margin: const EdgeInsets.symmetric(vertical: 4.0),
                        padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
                        decoration: BoxDecoration(
                          color: isMe ? Colors.blueAccent : Colors.grey[300],
                          borderRadius: BorderRadius.circular(12.0),
                        ),
                        child: Column(
                          crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start,
                          children: [
                            Text(
                              isMe ? 'Tú' : message.sender,
                              style: TextStyle(
                                fontWeight: FontWeight.bold,
                                color: isMe ? Colors.white : Colors.black,
                              ),
                            ),
                            const SizedBox(height: 4.0),
                            Text(
                              message.content,
                              style: TextStyle(
                                color: isMe ? Colors.white : Colors.black87,
                              ),
                            ),
                            const SizedBox(height: 4.0),
                            Text(
                              DateFormat('HH:mm').format(message.timestamp),
                              style: TextStyle(
                                fontSize: 10.0,
                                color: isMe ? Colors.white70 : Colors.black54,
                              ),
                            ),
                          ],
                        ),
                      ),
                    );
                  },
                );
              },
            ),
          ),
          Padding(
            padding: const EdgeInsets.all(8.0),
            child: Row(
              children: [
                Expanded(
                  child: TextField(
                    controller: _messageController,
                    decoration: InputDecoration(
                      hintText: 'Escribe un mensaje...', 
                      border: OutlineInputBorder(
                        borderRadius: BorderRadius.circular(20.0),
                      ),
                      contentPadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
                    ),
                    onSubmitted: (_) => _sendMessage(),
                  ),
                ),
                const SizedBox(width: 8.0),
                Consumer<WebSocketService>(
                  builder: (context, service, child) {
                    return FloatingActionButton(
                      mini: true,
                      onPressed: service.status == WebSocketStatus.connected ? _sendMessage : null,
                      child: const Icon(Icons.send),
                      backgroundColor: service.status == WebSocketStatus.connected ? Colors.blue : Colors.grey,
                    );
                  },
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

Si aún no lo tienes, añade intl a tu pubspec.yaml para formatear las fechas:

dependencies:
  flutter:
    sdk: flutter
  web_socket_channel: ^2.4.0
  provider: ^6.0.5
  intl: ^0.18.1 # Añade esta línea

Luego flutter pub get.

En ChatScreen:

  • initState: Llamamos a connect() en el WebSocketService cuando la pantalla se inicializa. Usamos WidgetsBinding.instance.addPostFrameCallback para asegurar que el BuildContext esté completamente disponible.
  • dispose: Aseguramos que la conexión WebSocket se cierre cuando la pantalla se descarte.
  • _sendMessage: Llama al método sendMessage del WebSocketService y limpia el campo de texto.
  • _scrollToBottom: Una función para mantener la lista de mensajes desplazada automáticamente hacia el último mensaje.
  • Consumer<WebSocketService>: Este widget de Provider nos permite escuchar los cambios en WebSocketService y reconstruir solo las partes de la UI que dependen de él.
    • El título de la AppBar muestra el estado de la conexión.
    • El ListView.builder se encarga de mostrar todos los mensajes, diferenciando si el mensaje es del usuario actual o de otro.
    • El botón de envío de mensajes se habilita/deshabilita según el estado de la conexión.

💡 Sugerencia de Servidor WebSocket Simple (Node.js)

Para probar tu aplicación de chat de forma real, necesitarás un servidor WebSocket que retransmita los mensajes. Aquí hay un ejemplo básico con Node.js y la librería ws:

  1. Crea un nuevo directorio para tu servidor (fuera de tu proyecto Flutter).
  2. Inicializa un proyecto Node.js:
npm init -y
  1. Instala la librería ws:
npm install ws
  1. Crea un archivo server.js:
// server.js
const WebSocket = require('ws');

const wss = new WebSocket.Server({ port: 8080 });

wss.on('connection', ws => {
console.log('Cliente conectado');

ws.on('message', message => {
console.log(`Recibido: ${message}`);

// Retransmitir el mensaje a todos los clientes conectados
wss.clients.forEach(client => {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(message);
} else if (client === ws && client.readyState === WebSocket.OPEN) {
// También podemos enviar el mensaje de vuelta al remitente para confirmación
client.send(message);
}
});
});

ws.on('close', () => {
console.log('Cliente desconectado');
});

ws.on('error', error => {
console.error('Error del WebSocket:', error);
});

// Opcional: Enviar un mensaje de bienvenida al nuevo cliente
ws.send(JSON.stringify({
sender: 'Servidor',
content: '¡Bienvenido al chat!',
timestamp: new Date().toISOString()
}));
});

console.log('Servidor WebSocket iniciado en ws://localhost:8080');
  1. Inicia el servidor:
node server.js

Ahora, cambia la URL en main.dart a ws://localhost:8080 (o la IP de tu máquina si pruebas en un dispositivo físico).


✅ Probando la Aplicación

  1. Asegúrate de que tu servidor WebSocket esté en ejecución.
  2. Ejecuta tu aplicación Flutter:
flutter run
  1. Ingresa un nombre de usuario en la pantalla de bienvenida.
  2. Accede a la pantalla de chat. Deberías ver el estado de conexión como "Conectado" (punto verde).
  3. Envía mensajes. Si usas el servidor Node.js, deberías poder abrir la aplicación en dos dispositivos o emuladores diferentes, cada uno con un nombre de usuario distinto, y ver cómo los mensajes se intercambian en tiempo real.
💡 **Consejo:** Para probar en un emulador o dispositivo real y tu servidor está en tu máquina local, usa la IP de tu máquina en lugar de `localhost`. Por ejemplo, `ws://192.168.1.100:8080`.

Consideraciones para Producción ⚠️

Para una aplicación de producción, necesitarías añadir:

  • Autenticación y Autorización: Para asegurar que solo usuarios válidos puedan chatear.
  • Persistencia de Mensajes: Guardar el historial de chat en una base de datos.
  • Manejo de Errores Robusto: Reconexión automática con backoff exponencial.
  • Seguridad: Usar wss:// (WebSockets seguros) con certificados TLS.
  • Escalabilidad: Un servidor WebSocket más robusto capaz de manejar miles de conexiones simultáneas (ej. Socket.IO, ws con Node.js en producción, Go, Elixir con Phoenix).
  • UI/UX mejorada: Indicadores de escritura, notificaciones, etc.

🎯 Conclusión

¡Felicidades! 🎉 Has construido un cliente de chat en tiempo real completamente funcional en Flutter utilizando WebSockets para la comunicación bidireccional y Provider para una gestión de estado eficiente. Has aprendido a:

  • Configurar un proyecto Flutter con las dependencias necesarias (web_socket_channel, provider, intl).
  • Diseñar un modelo de datos para los mensajes.
  • Implementar un servicio WebSocket (WebSocketService) que maneja la conexión, el envío y la recepción de mensajes.
  • Integrar WebSocketService con Provider para hacer que los datos y el estado de la conexión sean accesibles en toda la aplicación.
  • Crear una interfaz de usuario de chat reactiva que muestra mensajes en tiempo real y permite al usuario enviar nuevos mensajes.
  • Configurar un servidor WebSocket básico con Node.js para pruebas.

Este tutorial te proporciona una base sólida para desarrollar aplicaciones de comunicación en tiempo real más complejas. La combinación de Flutter, WebSockets y Provider es poderosa y te abrirá un mundo de posibilidades para crear experiencias de usuario interactivas y dinámicas.

Tutorial Completado ✅

Tutoriales relacionados

Comentarios (0)

Aún no hay comentarios. ¡Sé el primero!