Real-time Chat with WebSocket
Building a real-time chat application in Flutter using WebSocket allows for instant message delivery and bidirectional communication. This tutorial will guide you through implementing a scalable chat system with features like typing indicators, read receipts, and message persistence.
Features
- Real-time messaging
- Typing indicators
- Read receipts
- Message persistence
- User presence
- Message reactions
- File sharing
- Message search
- Message threading
- Offline support
Implementation Steps
-
Setup Dependencies
# pubspec.yaml dependencies: web_socket_channel: ^2.4.0 socket_io_client: ^2.0.3+1 provider: ^6.1.1 shared_preferences: ^2.2.2 intl: ^0.19.0 uuid: ^4.2.1 path_provider: ^2.1.2 sqflite: ^2.3.2
-
Create Chat Models
class Message { final String id; final String senderId; final String content; final DateTime timestamp; final MessageType type; final String? replyTo; final Map<String, dynamic>? metadata; final List<String> readBy; final List<Reaction> reactions; Message({ required this.id, required this.senderId, required this.content, required this.timestamp, required this.type, this.replyTo, this.metadata, this.readBy = const [], this.reactions = const [], }); factory Message.fromJson(Map<String, dynamic> json) { return Message( id: json['id'], senderId: json['senderId'], content: json['content'], timestamp: DateTime.parse(json['timestamp']), type: MessageType.values[json['type']], replyTo: json['replyTo'], metadata: json['metadata'], readBy: List<String>.from(json['readBy']), reactions: (json['reactions'] as List) .map((r) => Reaction.fromJson(r)) .toList(), ); } Map<String, dynamic> toJson() { return { 'id': id, 'senderId': senderId, 'content': content, 'timestamp': timestamp.toIso8601String(), 'type': type.index, 'replyTo': replyTo, 'metadata': metadata, 'readBy': readBy, 'reactions': reactions.map((r) => r.toJson()).toList(), }; } } class Reaction { final String userId; final String emoji; final DateTime timestamp; Reaction({ required this.userId, required this.emoji, required this.timestamp, }); factory Reaction.fromJson(Map<String, dynamic> json) { return Reaction( userId: json['userId'], emoji: json['emoji'], timestamp: DateTime.parse(json['timestamp']), ); } Map<String, dynamic> toJson() { return { 'userId': userId, 'emoji': emoji, 'timestamp': timestamp.toIso8601String(), }; } } enum MessageType { text, image, file, audio, video, location, }
-
Create WebSocket Service
class WebSocketService { Socket? _socket; final String _url; final String _token; final _messageController = StreamController<Message>.broadcast(); final _typingController = StreamController<String>.broadcast(); final _presenceController = StreamController<Map<String, bool>>.broadcast(); WebSocketService(this._url, this._token); Stream<Message> get messageStream => _messageController.stream; Stream<String> get typingStream => _typingController.stream; Stream<Map<String, bool>> get presenceStream => _presenceController.stream; Future<void> connect() async { _socket = io(_url, <String, dynamic>{ 'transports': ['websocket'], 'autoConnect': false, 'auth': {'token': _token}, }); _socket!.onConnect((_) { print('Connected to WebSocket'); }); _socket!.onDisconnect((_) { print('Disconnected from WebSocket'); }); _socket!.on('message', (data) { final message = Message.fromJson(data); _messageController.add(message); }); _socket!.on('typing', (data) { _typingController.add(data['userId']); }); _socket!.on('presence', (data) { final presence = Map<String, bool>.from(data); _presenceController.add(presence); }); _socket!.connect(); } void sendMessage(Message message) { _socket?.emit('message', message.toJson()); } void sendTyping() { _socket?.emit('typing'); } void sendReaction(String messageId, Reaction reaction) { _socket?.emit('reaction', { 'messageId': messageId, 'reaction': reaction.toJson(), }); } void markAsRead(String messageId) { _socket?.emit('read', {'messageId': messageId}); } void disconnect() { _socket?.disconnect(); _socket?.dispose(); _messageController.close(); _typingController.close(); _presenceController.close(); } }
-
Create Chat Provider
class ChatProvider extends ChangeNotifier { final WebSocketService _webSocketService; final Database _database; final String _userId; List<Message> _messages = []; Map<String, bool> _typingUsers = {}; Map<String, bool> _onlineUsers = {}; bool _isConnected = false; String? _error; ChatProvider({ required WebSocketService webSocketService, required Database database, required String userId, }) : _webSocketService = webSocketService, _database = database, _userId = userId { _initialize(); } List<Message> get messages => _messages; Map<String, bool> get typingUsers => _typingUsers; Map<String, bool> get onlineUsers => _onlineUsers; bool get isConnected => _isConnected; String? get error => _error; Future<void> _initialize() async { await _loadMessages(); await _webSocketService.connect(); _webSocketService.messageStream.listen((message) { _addMessage(message); }); _webSocketService.typingStream.listen((userId) { _typingUsers[userId] = true; notifyListeners(); Future.delayed(Duration(seconds: 3), () { _typingUsers.remove(userId); notifyListeners(); }); }); _webSocketService.presenceStream.listen((presence) { _onlineUsers = presence; notifyListeners(); }); } Future<void> _loadMessages() async { final messages = await _database.query( 'messages', orderBy: 'timestamp DESC', limit: 50, ); _messages = messages .map((row) => Message.fromJson(row)) .toList() .reversed .toList(); notifyListeners(); } void _addMessage(Message message) { _messages.add(message); _saveMessage(message); notifyListeners(); } Future<void> _saveMessage(Message message) async { await _database.insert( 'messages', message.toJson(), conflictAlgorithm: ConflictAlgorithm.replace, ); } Future<void> sendMessage(String content, {MessageType type = MessageType.text}) async { final message = Message( id: Uuid().v4(), senderId: _userId, content: content, timestamp: DateTime.now(), type: type, ); _webSocketService.sendMessage(message); _addMessage(message); } void sendTyping() { _webSocketService.sendTyping(); } void sendReaction(String messageId, String emoji) { final reaction = Reaction( userId: _userId, emoji: emoji, timestamp: DateTime.now(), ); _webSocketService.sendReaction(messageId, reaction); _updateMessageReaction(messageId, reaction); } void _updateMessageReaction(String messageId, Reaction reaction) { final index = _messages.indexWhere((m) => m.id == messageId); if (index != -1) { final message = _messages[index]; final reactions = List<Reaction>.from(message.reactions) ..add(reaction); _messages[index] = Message( id: message.id, senderId: message.senderId, content: message.content, timestamp: message.timestamp, type: message.type, replyTo: message.replyTo, metadata: message.metadata, readBy: message.readBy, reactions: reactions, ); notifyListeners(); } } void markAsRead(String messageId) { _webSocketService.markAsRead(messageId); _updateMessageReadStatus(messageId); } void _updateMessageReadStatus(String messageId) { final index = _messages.indexWhere((m) => m.id == messageId); if (index != -1) { final message = _messages[index]; final readBy = List<String>.from(message.readBy)..add(_userId); _messages[index] = Message( id: message.id, senderId: message.senderId, content: message.content, timestamp: message.timestamp, type: message.type, replyTo: message.replyTo, metadata: message.metadata, readBy: readBy, reactions: message.reactions, ); notifyListeners(); } } @override void dispose() { _webSocketService.disconnect(); super.dispose(); } }
-
Create Chat Widgets
class MessageBubble extends StatelessWidget { final Message message; final bool isMe; final Function(String) onReaction; final Function(String) onReply; const MessageBubble({ required this.message, required this.isMe, required this.onReaction, required this.onReply, }); @override Widget build(BuildContext context) { return Align( alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, child: Container( margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: EdgeInsets.all(12), decoration: BoxDecoration( color: isMe ? Colors.blue : Colors.grey[300], borderRadius: BorderRadius.circular(16), ), child: Column( crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ if (message.replyTo != null) _buildReplyPreview(context, message.replyTo!), Text( message.content, style: TextStyle( color: isMe ? Colors.white : Colors.black, ), ), SizedBox(height: 4), Row( mainAxisSize: MainAxisSize.min, children: [ Text( DateFormat.jm().format(message.timestamp), style: TextStyle( fontSize: 12, color: isMe ? Colors.white70 : Colors.black54, ), ), if (isMe) ...[ SizedBox(width: 4), Icon( Icons.done_all, size: 16, color: message.readBy.length > 1 ? Colors.blue : Colors.white70, ), ], ], ), if (message.reactions.isNotEmpty) _buildReactions(context), ], ), ), ); } Widget _buildReplyPreview(BuildContext context, String replyToId) { // Implement reply preview return Container(); } Widget _buildReactions(BuildContext context) { return Wrap( spacing: 4, children: message.reactions.map((reaction) { return GestureDetector( onTap: () => onReaction(reaction.emoji), child: Container( padding: EdgeInsets.symmetric(horizontal: 4, vertical: 2), decoration: BoxDecoration( color: Colors.white.withOpacity(0.2), borderRadius: BorderRadius.circular(12), ), child: Text(reaction.emoji), ), ); }).toList(), ); } } class ChatInput extends StatefulWidget { final Function(String) onSend; final Function() onTyping; final Function(String) onAttachment; const ChatInput({ required this.onSend, required this.onTyping, required this.onAttachment, }); @override State<ChatInput> createState() => _ChatInputState(); } class _ChatInputState extends State<ChatInput> { final _controller = TextEditingController(); Timer? _typingTimer; @override void dispose() { _controller.dispose(); _typingTimer?.cancel(); super.dispose(); } void _handleTyping() { _typingTimer?.cancel(); widget.onTyping(); _typingTimer = Timer(Duration(seconds: 1), () {}); } @override Widget build(BuildContext context) { return Container( padding: EdgeInsets.all(8), decoration: BoxDecoration( color: Colors.white, boxShadow: [ BoxShadow( color: Colors.black12, blurRadius: 4, offset: Offset(0, -2), ), ], ), child: Row( children: [ IconButton( icon: Icon(Icons.attach_file), onPressed: () => widget.onAttachment('file'), ), Expanded( child: TextField( controller: _controller, decoration: InputDecoration( hintText: 'Type a message...', border: OutlineInputBorder( borderRadius: BorderRadius.circular(24), ), contentPadding: EdgeInsets.symmetric( horizontal: 16, vertical: 8, ), ), onChanged: (_) => _handleTyping(), onSubmitted: (text) { if (text.isNotEmpty) { widget.onSend(text); _controller.clear(); } }, ), ), IconButton( icon: Icon(Icons.send), onPressed: () { if (_controller.text.isNotEmpty) { widget.onSend(_controller.text); _controller.clear(); } }, ), ], ), ); } }
-
Create Main Screen
class ChatScreen extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Chat Room'), Consumer<ChatProvider>( builder: (context, provider, child) { final onlineCount = provider.onlineUsers.values .where((online) => online) .length; return Text( '$onlineCount online', style: TextStyle(fontSize: 12), ); }, ), ], ), actions: [ IconButton( icon: Icon(Icons.more_vert), onPressed: () { // Show chat options }, ), ], ), body: Column( children: [ Expanded( child: Consumer<ChatProvider>( builder: (context, provider, child) { if (provider.error != null) { return Center( child: Text(provider.error!), ); } return ListView.builder( reverse: true, itemCount: provider.messages.length, itemBuilder: (context, index) { final message = provider.messages[index]; return MessageBubble( message: message, isMe: message.senderId == provider._userId, onReaction: (emoji) { provider.sendReaction(message.id, emoji); }, onReply: (messageId) { // Handle reply }, ); }, ); }, ), ), Consumer<ChatProvider>( builder: (context, provider, child) { if (provider.typingUsers.isNotEmpty) { return Padding( padding: EdgeInsets.all(8), child: Text( '${provider.typingUsers.keys.join(", ")} typing...', style: TextStyle( fontStyle: FontStyle.italic, color: Colors.grey, ), ), ); } return SizedBox.shrink(); }, ), ChatInput( onSend: (text) { context.read<ChatProvider>().sendMessage(text); }, onTyping: () { context.read<ChatProvider>().sendTyping(); }, onAttachment: (type) { // Handle attachment }, ), ], ), ); } }
Best Practices
-
WebSocket Management
- Handle reconnection
- Manage connection state
- Implement heartbeat
- Handle errors
-
Message Handling
- Queue messages
- Handle offline
- Implement retry
- Validate data
-
Performance
- Optimize rendering
- Handle large lists
- Cache messages
- Manage memory
-
Security
- Encrypt messages
- Validate users
- Handle tokens
- Prevent attacks
Conclusion
This tutorial has shown you how to implement a real-time chat application in Flutter with features like:
- Real-time messaging
- Typing indicators
- Read receipts
- Message reactions
- File sharing
You can extend this implementation by adding:
- Voice messages
- Video calls
- Message encryption
- Group chats
- Message search
Remember to:
- Handle connectivity
- Test thoroughly
- Consider security
- Follow platform guidelines
- Keep dependencies updated
This implementation provides a solid foundation for creating a real-time chat application in Flutter.