DartFirebaseFlutter

Building a Chat App in Flutter with Firebase

20250408 1557 Chat App Development Simple Compose 01jrav2tacfqbt3tg75y2a6qw2 1024x683

Introduction

Real-time chat functionality is one of the most common and powerful features in mobile apps—from social platforms to customer support systems to team collaboration tools. With Flutter and Firebase, you can build a full-featured chat app with ease, backed by real-time updates, secure authentication, and scalable cloud storage. Firebase’s Firestore database provides instant synchronization across all connected devices, while Flutter’s reactive widgets make building responsive chat interfaces straightforward. In this comprehensive guide, we’ll walk through building a production-ready chat app, covering everything from project setup and authentication to message handling, typing indicators, and chat UI best practices.

Step 1: Set Up Firebase for Your Flutter App

Before writing any Flutter code, you’ll need to set up Firebase and connect it to your app. The Firebase CLI makes this process straightforward.

# Install Firebase CLI if you haven't
npm install -g firebase-tools

# Login to Firebase
firebase login

# Install FlutterFire CLI
dart pub global activate flutterfire_cli

# Configure Firebase for your Flutter project
flutterfire configure

In the Firebase Console, enable the services you’ll need:

Firestore Database: Create a database in test mode initially. You’ll lock down the rules later.

Authentication: Enable Email/Password and optionally Google Sign-In for a better user experience.

For detailed Firebase setup instructions, check out our guide on setting up Firebase Firestore in Flutter.

Step 2: Add Required Dependencies

Update your pubspec.yaml file with the necessary packages:

dependencies:
  flutter:
    sdk: flutter
  firebase_core: ^2.24.0
  firebase_auth: ^4.16.0
  cloud_firestore: ^4.14.0
  intl: ^0.18.1  # For date formatting
  cached_network_image: ^3.3.0  # For profile images

Run flutter pub get, then initialize Firebase in your main.dart:

import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );
  runApp(const ChatApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Chat',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
        useMaterial3: true,
      ),
      home: const AuthWrapper(),
    );
  }
}

Step 3: Set Up Authentication

Create an authentication service to handle user sign-in and registration:

class AuthService {
  final FirebaseAuth _auth = FirebaseAuth.instance;
  final FirebaseFirestore _firestore = FirebaseFirestore.instance;

  // Stream of auth state changes
  Stream get authStateChanges => _auth.authStateChanges();

  // Get current user
  User? get currentUser => _auth.currentUser;

  // Sign up with email and password
  Future signUp({
    required String email,
    required String password,
    required String displayName,
  }) async {
    final credential = await _auth.createUserWithEmailAndPassword(
      email: email,
      password: password,
    );

    // Update display name
    await credential.user?.updateDisplayName(displayName);

    // Create user document in Firestore
    await _firestore.collection('users').doc(credential.user!.uid).set({
      'uid': credential.user!.uid,
      'email': email,
      'displayName': displayName,
      'photoUrl': null,
      'createdAt': FieldValue.serverTimestamp(),
      'lastSeen': FieldValue.serverTimestamp(),
      'isOnline': true,
    });

    return credential;
  }

  // Sign in with email and password
  Future signIn({
    required String email,
    required String password,
  }) async {
    final credential = await _auth.signInWithEmailAndPassword(
      email: email,
      password: password,
    );

    // Update online status
    await _updateUserStatus(credential.user!.uid, true);

    return credential;
  }

  // Sign out
  Future signOut() async {
    if (currentUser != null) {
      await _updateUserStatus(currentUser!.uid, false);
    }
    await _auth.signOut();
  }

  Future _updateUserStatus(String uid, bool isOnline) async {
    await _firestore.collection('users').doc(uid).update({
      'isOnline': isOnline,
      'lastSeen': FieldValue.serverTimestamp(),
    });
  }
}

Step 4: Define Data Models and Firestore Structure

Design your Firestore structure for scalability:

// Firestore structure:
// users/{userId} - User profiles
// chats/{chatId} - Chat metadata
// chats/{chatId}/messages/{messageId} - Individual messages

class ChatUser {
  final String uid;
  final String email;
  final String displayName;
  final String? photoUrl;
  final bool isOnline;
  final DateTime? lastSeen;

  ChatUser({
    required this.uid,
    required this.email,
    required this.displayName,
    this.photoUrl,
    required this.isOnline,
    this.lastSeen,
  });

  factory ChatUser.fromFirestore(DocumentSnapshot doc) {
    final data = doc.data() as Map;
    return ChatUser(
      uid: data['uid'],
      email: data['email'],
      displayName: data['displayName'],
      photoUrl: data['photoUrl'],
      isOnline: data['isOnline'] ?? false,
      lastSeen: (data['lastSeen'] as Timestamp?)?.toDate(),
    );
  }
}

class Message {
  final String id;
  final String senderId;
  final String senderName;
  final String text;
  final DateTime timestamp;
  final bool isRead;
  final MessageType type;

  Message({
    required this.id,
    required this.senderId,
    required this.senderName,
    required this.text,
    required this.timestamp,
    this.isRead = false,
    this.type = MessageType.text,
  });

  factory Message.fromFirestore(DocumentSnapshot doc) {
    final data = doc.data() as Map;
    return Message(
      id: doc.id,
      senderId: data['senderId'],
      senderName: data['senderName'],
      text: data['text'],
      timestamp: (data['timestamp'] as Timestamp).toDate(),
      isRead: data['isRead'] ?? false,
      type: MessageType.values.byName(data['type'] ?? 'text'),
    );
  }

  Map toFirestore() {
    return {
      'senderId': senderId,
      'senderName': senderName,
      'text': text,
      'timestamp': FieldValue.serverTimestamp(),
      'isRead': isRead,
      'type': type.name,
    };
  }
}

enum MessageType { text, image, file }

Step 5: Create the Chat Service

Build a service class to handle all chat operations:

class ChatService {
  final FirebaseFirestore _firestore = FirebaseFirestore.instance;
  final FirebaseAuth _auth = FirebaseAuth.instance;

  // Get or create a chat between two users
  Future getOrCreateChat(String otherUserId) async {
    final currentUserId = _auth.currentUser!.uid;
    
    // Create consistent chat ID (alphabetically sorted user IDs)
    final participants = [currentUserId, otherUserId]..sort();
    final chatId = participants.join('_');

    final chatDoc = await _firestore.collection('chats').doc(chatId).get();

    if (!chatDoc.exists) {
      await _firestore.collection('chats').doc(chatId).set({
        'participants': participants,
        'createdAt': FieldValue.serverTimestamp(),
        'lastMessage': null,
        'lastMessageTime': null,
      });
    }

    return chatId;
  }

  // Stream of messages for a chat
  Stream> getMessages(String chatId) {
    return _firestore
        .collection('chats')
        .doc(chatId)
        .collection('messages')
        .orderBy('timestamp', descending: true)
        .limit(50)
        .snapshots()
        .map((snapshot) => snapshot.docs
            .map((doc) => Message.fromFirestore(doc))
            .toList());
  }

  // Send a message
  Future sendMessage({
    required String chatId,
    required String text,
  }) async {
    final user = _auth.currentUser!;
    
    final message = Message(
      id: '',
      senderId: user.uid,
      senderName: user.displayName ?? 'Anonymous',
      text: text,
      timestamp: DateTime.now(),
    );

    // Add message to subcollection
    await _firestore
        .collection('chats')
        .doc(chatId)
        .collection('messages')
        .add(message.toFirestore());

    // Update chat metadata
    await _firestore.collection('chats').doc(chatId).update({
      'lastMessage': text,
      'lastMessageTime': FieldValue.serverTimestamp(),
      'lastSenderId': user.uid,
    });
  }

  // Mark messages as read
  Future markAsRead(String chatId) async {
    final currentUserId = _auth.currentUser!.uid;
    
    final unreadMessages = await _firestore
        .collection('chats')
        .doc(chatId)
        .collection('messages')
        .where('senderId', isNotEqualTo: currentUserId)
        .where('isRead', isEqualTo: false)
        .get();

    final batch = _firestore.batch();
    for (final doc in unreadMessages.docs) {
      batch.update(doc.reference, {'isRead': true});
    }
    await batch.commit();
  }

  // Stream of user's chats
  Stream> getUserChats() {
    final currentUserId = _auth.currentUser!.uid;
    
    return _firestore
        .collection('chats')
        .where('participants', arrayContains: currentUserId)
        .orderBy('lastMessageTime', descending: true)
        .snapshots()
        .map((snapshot) => snapshot.docs
            .map((doc) => Chat.fromFirestore(doc))
            .toList());
  }
}

Step 6: Build the Chat UI

Create an intuitive chat interface with message bubbles:

class ChatScreen extends StatefulWidget {
  final String chatId;
  final String otherUserName;

  const ChatScreen({
    super.key,
    required this.chatId,
    required this.otherUserName,
  });

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

class _ChatScreenState extends State {
  final ChatService _chatService = ChatService();
  final TextEditingController _messageController = TextEditingController();
  final ScrollController _scrollController = ScrollController();

  @override
  void initState() {
    super.initState();
    _chatService.markAsRead(widget.chatId);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.otherUserName),
      ),
      body: Column(
        children: [
          Expanded(
            child: StreamBuilder>(
              stream: _chatService.getMessages(widget.chatId),
              builder: (context, snapshot) {
                if (snapshot.connectionState == ConnectionState.waiting) {
                  return const Center(child: CircularProgressIndicator());
                }

                if (!snapshot.hasData || snapshot.data!.isEmpty) {
                  return const Center(
                    child: Text('No messages yet. Say hello!'),
                  );
                }

                final messages = snapshot.data!;
                return ListView.builder(
                  controller: _scrollController,
                  reverse: true,
                  padding: const EdgeInsets.all(16),
                  itemCount: messages.length,
                  itemBuilder: (context, index) {
                    final message = messages[index];
                    final isMe = message.senderId ==
                        FirebaseAuth.instance.currentUser!.uid;
                    return MessageBubble(
                      message: message,
                      isMe: isMe,
                    );
                  },
                );
              },
            ),
          ),
          _buildMessageComposer(),
        ],
      ),
    );
  }

  Widget _buildMessageComposer() {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
      decoration: BoxDecoration(
        color: Theme.of(context).cardColor,
        boxShadow: [
          BoxShadow(
            offset: const Offset(0, -2),
            blurRadius: 4,
            color: Colors.black.withOpacity(0.1),
          ),
        ],
      ),
      child: SafeArea(
        child: Row(
          children: [
            Expanded(
              child: TextField(
                controller: _messageController,
                decoration: InputDecoration(
                  hintText: 'Type a message...',
                  border: OutlineInputBorder(
                    borderRadius: BorderRadius.circular(24),
                    borderSide: BorderSide.none,
                  ),
                  filled: true,
                  contentPadding: const EdgeInsets.symmetric(
                    horizontal: 16,
                    vertical: 8,
                  ),
                ),
                textCapitalization: TextCapitalization.sentences,
                maxLines: null,
              ),
            ),
            const SizedBox(width: 8),
            IconButton.filled(
              onPressed: _sendMessage,
              icon: const Icon(Icons.send),
            ),
          ],
        ),
      ),
    );
  }

  void _sendMessage() {
    final text = _messageController.text.trim();
    if (text.isEmpty) return;

    _chatService.sendMessage(
      chatId: widget.chatId,
      text: text,
    );

    _messageController.clear();
  }

  @override
  void dispose() {
    _messageController.dispose();
    _scrollController.dispose();
    super.dispose();
  }
}

class MessageBubble extends StatelessWidget {
  final Message message;
  final bool isMe;

  const MessageBubble({
    super.key,
    required this.message,
    required this.isMe,
  });

  @override
  Widget build(BuildContext context) {
    return Align(
      alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
      child: Container(
        margin: const EdgeInsets.symmetric(vertical: 4),
        padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
        decoration: BoxDecoration(
          color: isMe
              ? Theme.of(context).colorScheme.primary
              : Theme.of(context).colorScheme.surfaceVariant,
          borderRadius: BorderRadius.circular(20),
        ),
        constraints: BoxConstraints(
          maxWidth: MediaQuery.of(context).size.width * 0.75,
        ),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              message.text,
              style: TextStyle(
                color: isMe ? Colors.white : null,
              ),
            ),
            const SizedBox(height: 4),
            Text(
              _formatTime(message.timestamp),
              style: TextStyle(
                fontSize: 10,
                color: isMe ? Colors.white70 : Colors.grey,
              ),
            ),
          ],
        ),
      ),
    );
  }

  String _formatTime(DateTime dateTime) {
    return '${dateTime.hour}:${dateTime.minute.toString().padLeft(2, '0')}';
  }
}

Step 7: Secure Your Database

Set up Firestore security rules to protect user data:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // Users can read/write their own profile
    match /users/{userId} {
      allow read: if request.auth != null;
      allow write: if request.auth.uid == userId;
    }
    
    // Chat access limited to participants
    match /chats/{chatId} {
      allow read, write: if request.auth.uid in resource.data.participants
                         || request.auth.uid in request.resource.data.participants;
      
      match /messages/{messageId} {
        allow read: if request.auth.uid in get(/databases/$(database)/documents/chats/$(chatId)).data.participants;
        allow create: if request.auth.uid in get(/databases/$(database)/documents/chats/$(chatId)).data.participants
                      && request.auth.uid == request.resource.data.senderId;
      }
    }
  }
}

Common Mistakes to Avoid

Not using indexes: Firestore queries with multiple where clauses or orderBy need composite indexes. Check the Firebase console for index suggestions when queries fail.

Ignoring offline support: Firestore has built-in offline persistence, but test your app in offline scenarios to ensure good UX.

Fetching too much data: Use pagination with limit() and startAfter() for chat history instead of loading all messages at once.

Weak security rules: Never deploy with open security rules. Always validate that users can only access their own data.

Final Thoughts

Using Firebase and Flutter together gives you the perfect toolkit for building real-time chat apps. Firestore’s real-time listeners make message synchronization seamless, while Flutter’s widget system lets you create beautiful, responsive chat interfaces. Start with the fundamentals covered here—authentication, message storage, and basic UI—then expand with features like typing indicators, read receipts, push notifications, and media sharing. The combination of Flutter’s cross-platform capabilities and Firebase’s scalable backend means your chat app can grow from prototype to production without changing your architecture. For more Flutter tutorials, explore our guides on Firebase Firestore setup and Flutter state management, and check the official Firebase Flutter documentation for the latest updates.

Leave a Comment