image

27 Jan 2026

9K

35K

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:

  1. Create a new Firebase project in the Firebase console.
  2. Add a Flutter app to your Firebase project by following the instructions in the Firebase console or using the FlutterFire CLI:
    
            flutterfire configure
            
  3. 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.

Related Articles

May 14, 2026

Building a Multi-Event Countdown Timer Widget with Reminders, Notifications, Repeat, and Custom Labels in Flutter

Building a Multi-Event Countdown Timer Widget with Reminders, Notifications, Repeat, and Custom Labels in Flutter Countdown timers are essential in many applic

May 11, 2026

Unleashing Dynamic UIs: Flutter's Animation Prowess

Unleashing Dynamic UIs: Flutter's Animation Prowess for Slide & Scale Effects Flutter's declarative UI framework, combined with its powerful animation capabilit

May 11, 2026

Building a Product Detail Page Widget in Flutter with Related Items, Review Carousel, Promo Badges, and Quick Buy

Building a Product Detail Page Widget in Flutter with Related Items, Review Carousel, Promo Badges, and Quick Buy A well-designed Product Detail Page (PDP) is