Building a Real-Time Chat Feature
Building a robust, real-time chat system is crucial for many mobile apps, from on-demand service platforms to social communities. In this post, I’ll outline the architecture and code considerations involved in creating a Flutter-based chat feature with message synchronization, backend triggers, and advanced user-interface design.
High-Level Approach
- Backend Integration: I rely on REST APIs or serverless functions (Firebase Functions, AWS Lambdas, etc.) for sending/receiving messages, updating read statuses, and broadcasting system events (like a user leaving, or the system acknowledging a payment).
- Local State & UI: On the client side, Flutter apps use
StreamBuilder,ListView, or custom polling to keep a local record of conversation history. When new messages arrive, they’re appended to the local list, and the view updates in real time. - Permissions & Validation: Ensure each user only accesses messages relevant to their order or context. Some apps also rely on role-based or ID-based verification to handle certain logic (e.g., “technicians” vs. “customers”).
Basic Architecture
The chat data model typically includes:
- Message: Contains
id,senderId,text,imageUrl(if any),timestamp,messageType(e.g., normal vs. system). - Conversation (optional): Tracks participants, unread counts, etc.
- Server: Receives new messages through an HTTP endpoint or direct Firestore writes, then broadcasts them to all relevant participants.
For real-time notification, you can use push notifications or background fetch logic. However, many situations also employ short polling if the user is already in the chat screen—this can sometimes simplify concurrency or offline cases.
Flutter UI Example
Below is a condensed snippet illustrating how I might manage message state, handle text+image inputs, and display messages in conversation bubbles.
import 'package:flutter/material.dart';
import 'dart:typed_data';
class ChatMessage {
final String id;
final String senderId;
final String text;
final String? imageUrl;
final int timestamp;
final bool isSystemMessage;
ChatMessage({
required this.id,
required this.senderId,
required this.text,
this.imageUrl,
required this.timestamp,
this.isSystemMessage = false,
});
}
class ChatPage extends StatefulWidget {
final String conversationId;
const ChatPage({Key? key, required this.conversationId}) : super(key: key);
@override
State<ChatPage> createState() => _ChatPageState();
}
class _ChatPageState extends State<ChatPage> {
final _messageController = TextEditingController();
List<ChatMessage> _messages = [];
bool _isUploading = false;
Uint8List? _pendingImageBytes;
@override
void initState() {
super.initState();
_fetchMessages(); // initial fetch
// Optionally set up a periodic polling or streaming subscription
}
void _fetchMessages() async {
// Call backend or DB to load existing messages
final fetchedMessages = await _mockFetchMessagesFromServer();
setState(() {
_messages = fetchedMessages;
});
}
Future<List<ChatMessage>> _mockFetchMessagesFromServer() async {
// Replace with your actual logic, e.g.:
await Future.delayed(const Duration(milliseconds: 500));
return [
ChatMessage(
id: 'm1',
senderId: 'customer123',
text: 'Hi, I have a question about my order.',
timestamp: DateTime.now().millisecondsSinceEpoch - 60000,
),
ChatMessage(
id: 'm2',
senderId: 'system',
text: 'System event: The technician is en route.',
timestamp: DateTime.now().millisecondsSinceEpoch - 30000,
isSystemMessage: true,
),
];
}
Future<void> _sendMessage({String? text, String? imageUrl}) async {
// Make sure we have content
if ((text == null || text.isEmpty) && (imageUrl == null || imageUrl.isEmpty)) {
return;
}
// Construct a local message
final newMsg = ChatMessage(
id: DateTime.now().millisecondsSinceEpoch.toString(),
senderId: 'technician456',
text: text ?? '',
imageUrl: imageUrl,
timestamp: DateTime.now().millisecondsSinceEpoch,
);
// Update local UI
setState(() {
_messages.add(newMsg);
});
// Optionally call backend to persist
// e.g., await MyAPI.sendChatMessage(widget.conversationId, newMsg);
}
Widget _buildMessageBubble(ChatMessage msg) {
if (msg.isSystemMessage) {
// system bubble
return Container(
margin: const EdgeInsets.symmetric(vertical: 8),
alignment: Alignment.center,
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(16),
),
child: Text(
msg.text,
textAlign: TextAlign.center,
style: const TextStyle(fontStyle: FontStyle.italic),
),
),
);
}
final bool isMine = (msg.senderId == 'technician456');
return Container(
margin: EdgeInsets.fromLTRB(isMine ? 60 : 10, 5, isMine ? 10 : 60, 5),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: isMine ? Colors.blue.shade100 : Colors.green.shade100,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment:
isMine ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: [
if (msg.text.isNotEmpty)
Text(msg.text),
if (msg.imageUrl != null && msg.imageUrl!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Image.network(msg.imageUrl!),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Conversation #${widget.conversationId}'),
),
body: Column(
children: [
Expanded(
child: ListView.builder(
itemCount: _messages.length,
itemBuilder: (ctx, i) => _buildMessageBubble(_messages[i]),
),
),
_buildTextComposer(),
],
),
);
}
Widget _buildTextComposer() {
return SafeArea(
child: Row(
children: [
IconButton(
icon: const Icon(Icons.add_photo_alternate),
onPressed: _onSelectImage,
),
Expanded(
child: TextField(
controller: _messageController,
decoration: const InputDecoration(
hintText: 'Type your message...',
),
),
),
IconButton(
icon: const Icon(Icons.send),
onPressed: () {
final text = _messageController.text.trim();
_messageController.clear();
_sendMessage(text: text);
},
),
],
),
);
}
// Pseudocode for image picking
Future<void> _onSelectImage() async {
// final selectedFile = await pickImage(); // implement picking
// _isUploading = true; setState(() {});
// final uploadedUrl = await MyAPI.uploadFile(selectedFile);
// _isUploading = false; setState(() {});
// _sendMessage(imageUrl: uploadedUrl);
}
}
Key Observations
- The code uses a local
_messageslist for immediate UI updates. - Actual network calls (e.g., to a REST endpoint or Firestore) happen in an async function. This ensures the UI remains responsive.
- System messages (like “technician is on the way”) are displayed differently than normal user messages.
Handling Advanced Scenarios
- Unread Indicators: If multiple screens or partial user sessions are involved, track
lastSeenMessageTimeon the server. In my apps, I typically store each user’s last-read timestamp in a database and then highlight any newer messages. - Delivery Receipts: If you need full reliability (like “delivered” or “read” confirmations), design a small queue to push read acknowledgements back to the server.
- Push Notifications: Typically used if a user is outside the app. On new messages, a backend function triggers a push, and the mobile OS (Android/iOS) displays it. Tapping the notification deep-links into the chat screen.
Concluding Thoughts
Building a chat feature requires careful orchestration between the frontend, backend, and push notification system. By focusing on clarity of message structures, implementing robust synchronization (pull or push), and differentiating user/system flows in the UI, you’ll create a messaging experience that feels immediate and stable. Whether you’re working on a simple 1-on-1 chat or a large multi-participant system, these core principles remain the same.
If done right, a real-time chat not only boosts user engagement but also enables advanced features—such as in-chat payments, scheduling, and shared media—making your mobile app far more valuable in the eyes of both users.
Enjoy Reading This Article?
Here are some more articles you might like to read next: