Flutter & Firebase Firestore: Real-Time Chat with Typing Indicator
Building real-time applications has become a staple in modern software development, and chat applications are perhaps the most common example. Leveraging the power of Flutter for UI and Firebase Firestore for its real-time NoSQL database capabilities, we can create highly responsive and scalable chat experiences. This article will guide you through developing a real-time chat application with the added sophistication of a typing indicator, enhancing user experience significantly.
1. Prerequisites and Setup
Before diving into the code, ensure you have the following:
- Flutter SDK installed and configured.
- A Firebase project set up.
- FlutterFire CLI installed to connect your Flutter app to Firebase.
Firebase Project Setup:
- Create a new Firebase project in the Firebase console.
- Add a Flutter app to your Firebase project by following the instructions in the Firebase console or using the FlutterFire CLI:
flutterfire configure - Enable Firestore Database in your Firebase project. Choose "Start in production mode" and set a server location.
Firestore Security Rules:
For a basic chat application, you'll need to define security rules that allow authenticated users to read and write messages. For development, you might use more permissive rules, but for production, tailor them carefully.
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Allow authenticated users to read and write to 'messages' collection
match /messages/{messageId} {
allow read, write: if request.auth != null;
}
// Allow authenticated users to read and write to 'typingStatus' collection
match /typingStatus/{userId} {
allow read, write: if request.auth != null;
}
}
}
Flutter Project Initialization:
Add the necessary dependencies to your `pubspec.yaml` file:
dependencies:
flutter:
sdk: flutter
firebase_core: ^2.24.2
cloud_firestore: ^4.13.6
firebase_auth: ^4.15.2 # If you plan to use authentication
Run `flutter pub get` after updating `pubspec.yaml`. Then, initialize Firebase in your `main.dart`:
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'firebase_options.dart'; // Generated by flutterfire configure
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Real-Time Chat',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: ChatScreen(), // Your main chat screen
);
}
}
2. Firestore Data Model for Chat
We'll need two main collections for our chat application:
messages Collection:
Each document in this collection represents a single chat message. A typical message document might look like this:
- `text`: The content of the message (String).
- `senderId`: The ID of the user who sent the message (String).
- `timestamp`: The time the message was sent (Timestamp).
- `type`: Optional, e.g., 'text', 'image', 'video' (String).
// Example document in 'messages' collection
{
"text": "Hello, how are you?",
"senderId": "user123",
"timestamp": "2023-10-27T10:00:00Z",
"type": "text"
}
typingStatus Collection:
This collection will store the typing status of each active user. Each document ID will be the user's ID. This structure allows efficient updates and queries for who is currently typing.
- `isTyping`: A boolean indicating if the user is currently typing (Boolean).
- `lastTyped`: A timestamp to track the last typing activity, useful for timeouts (Timestamp).
// Example document in 'typingStatus' collection (document ID is the user ID)
{
"isTyping": true,
"lastTyped": "2023-10-27T10:05:30Z"
}
3. Core Chat Functionality: Sending and Displaying Messages
Sending Messages:
To send a message, we'll use a `TextFormField` and a button. When the button is pressed, the message text is added to the `messages` collection.
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
// Assume you have a way to get the current user's ID, e.g., from Firebase Auth
String currentUserId = 'user123'; // Replace with actual user ID
class ChatScreen extends StatefulWidget {
@override
_ChatScreenState createState() => _ChatScreenState();
}
class _ChatScreenState extends State {
final TextEditingController _messageController = TextEditingController();
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
void _sendMessage() async {
if (_messageController.text.trim().isEmpty) return;
await _firestore.collection('messages').add({
'text': _messageController.text.trim(),
'senderId': currentUserId, // Replace with actual user ID
'timestamp': FieldValue.serverTimestamp(),
'type': 'text',
});
_messageController.clear();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Real-Time Chat')),
body: Column(
children: [
// Message list will go here
Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
Expanded(
child: TextField(
controller: _messageController,
decoration: InputDecoration(hintText: 'Enter your message...'),
),
),
IconButton(
icon: Icon(Icons.send),
onPressed: _sendMessage,
),
],
),
),
],
),
);
}
}
Displaying Messages:
To display messages in real-time, we use `StreamBuilder` which listens for changes in the `messages` collection. The messages are ordered by timestamp.
// ... inside _ChatScreenState build method ...
Expanded(
child: StreamBuilder(
stream: _firestore
.collection('messages')
.orderBy('timestamp', descending: true)
.snapshots(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return Center(child: CircularProgressIndicator());
}
final messages = snapshot.data!.docs;
return ListView.builder(
reverse: true, // To show new messages at the bottom
itemCount: messages.length,
itemBuilder: (context, index) {
final message = messages[index].data() as Map;
final messageText = message['text'];
final messageSender = message['senderId'];
final isMe = messageSender == currentUserId;
return Align(
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
margin: EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0),
padding: EdgeInsets.symmetric(vertical: 10.0, horizontal: 15.0),
decoration: BoxDecoration(
color: isMe ? Colors.blueAccent : Colors.grey[300],
borderRadius: BorderRadius.circular(20.0),
),
child: Text(
messageText,
style: TextStyle(
color: isMe ? Colors.white : Colors.black,
),
),
),
);
},
);
},
),
),
// ... rest of the build method ...
4. Implementing the Typing Indicator
The typing indicator involves two main parts: updating the current user's typing status and listening to other users' typing statuses.
Updating Typing Status:
When the user starts or stops typing in the `TextFormField`, we'll update their `isTyping` status in the `typingStatus` collection. To prevent excessive writes to Firestore, a debouncing mechanism is highly recommended.
import 'dart:async';
// ... inside _ChatScreenState class ...
Timer? _debounce;
bool _isTyping = false;
void _updateTypingStatus(bool isTyping) {
if (_isTyping == isTyping) return; // Only update if status changed
_isTyping = isTyping;
_firestore.collection('typingStatus').doc(currentUserId).set({
'isTyping': isTyping,
'lastTyped': FieldValue.serverTimestamp(),
}, SetOptions(merge: true)); // Use merge to avoid overwriting other fields
}
void _onTypingChanged(String text) {
if (_debounce?.isActive ?? false) _debounce!.cancel();
if (text.isNotEmpty && !_isTyping) {
_updateTypingStatus(true);
} else if (text.isEmpty && _isTyping) {
_updateTypingStatus(false);
}
// Debounce for setting isTyping to false after a pause
_debounce = Timer(const Duration(milliseconds: 1000), () {
if (_messageController.text.isEmpty && _isTyping) {
_updateTypingStatus(false);
}
});
}
@override
void dispose() {
_debounce?.cancel();
// Ensure typing status is set to false when leaving the chat
_updateTypingStatus(false);
_messageController.dispose();
super.dispose();
}
// ... inside the TextField in the build method ...
TextField(
controller: _messageController,
onChanged: _onTypingChanged, // Add this listener
decoration: InputDecoration(hintText: 'Enter your message...'),
),
Listening for Typing Status:
We'll use another `StreamBuilder` to listen for changes in the `typingStatus` collection. We filter out the current user's status and display who else is typing.
// ... inside _ChatScreenState build method, above the TextField ...
StreamBuilder(
stream: _firestore
.collection('typingStatus')
.where('isTyping', isEqualTo: true)
.snapshots(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return SizedBox.shrink(); // No typing indicator if no data
}
final typingUsers = snapshot.data!.docs
.where((doc) => doc.id != currentUserId) // Exclude current user
.map((doc) => doc.id) // Get user IDs
.toList();
if (typingUsers.isEmpty) {
return SizedBox.shrink(); // No one is typing
}
// You might want to map user IDs to actual usernames for better display
final typingUserNames = typingUsers.join(', '); // Simple join for now
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
child: Text(
'$typingUserNames is typing...',
style: TextStyle(fontStyle: FontStyle.italic, color: Colors.grey[700]),
),
);
},
),
// ... The rest of the Column widget for messages and input ...
Integrate this `StreamBuilder` into your `Column` widget, perhaps right above the message input area.
5. Conclusion
By combining Flutter's declarative UI with Firebase Firestore's real-time capabilities, we've successfully built a real-time chat application complete with a dynamic typing indicator. This approach provides a responsive and engaging user experience, akin to popular messaging apps. You can further expand on this foundation by adding user authentication, displaying usernames instead of IDs, implementing image/file sharing, and more complex chat room functionalities.