image

17 Feb 2026

9K

35K

Building a Chat Screen Widget with Typing Indicator and Seen Status in Flutter

Creating a robust and intuitive chat interface is fundamental for many modern applications. Beyond merely displaying messages, features like typing indicators and seen statuses significantly enhance the user experience by providing real-time feedback and transparency. This article will guide you through building a Flutter chat screen widget, incorporating these essential features.

1. Core Chat Screen Structure

First, let's establish the basic structure of our chat screen. This involves a list of messages and an input field for sending new messages.

1.1. Message Model

We'll need a simple data model for our messages.


class Message {
  final String id;
  final String senderId;
  final String text;
  final DateTime timestamp;
  bool isSeen;
  final bool isMe; // To differentiate between current user's messages and others'

  Message({
    required this.id,
    required this.senderId,
    required this.text,
    required this.timestamp,
    this.isSeen = false,
    required this.isMe,
  });
}

1.2. Chat Screen Widget

Our `ChatScreen` will be a `StatefulWidget` to manage the list of messages, typing status, and other dynamic elements.


import 'package:flutter/material.dart';

class ChatScreen extends StatefulWidget {
  const ChatScreen({Key? key}) : super(key: key);

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

class _ChatScreenState extends State<ChatScreen> {
  final List<Message> _messages = [];
  final TextEditingController _textController = TextEditingController();
  bool _isTyping = false; // State for typing indicator

  @override
  void initState() {
    super.initState();
    // Simulate some initial messages
    _messages.addAll([
      Message(id: '1', senderId: 'userB', text: 'Hey there!', timestamp: DateTime.now().subtract(const Duration(minutes: 5)), isMe: false),
      Message(id: '2', senderId: 'userA', text: 'Hi! How are you?', timestamp: DateTime.now().subtract(const Duration(minutes: 4)), isMe: true, isSeen: true),
      Message(id: '3', senderId: 'userB', text: 'I\'m good, thanks! And you?', timestamp: DateTime.now().subtract(const Duration(minutes: 3)), isMe: false),
      Message(id: '4', senderId: 'userA', text: 'Doing great!', timestamp: DateTime.now().subtract(const Duration(minutes: 2)), isMe: true, isSeen: false),
    ]);
  }

  void _handleSubmitted(String text) {
    _textController.clear();
    setState(() {
      _messages.insert(0, Message(
        id: DateTime.now().millisecondsSinceEpoch.toString(),
        senderId: 'userA',
        text: text,
        timestamp: DateTime.now(),
        isMe: true,
      ));
      _isTyping = false; // Stop typing after sending message
    });
    // In a real app, send message to backend and update seen status upon delivery/receipt
  }

  void _updateTypingStatus(bool typing) {
    if (_isTyping != typing) {
      setState(() {
        _isTyping = typing;
      });
      // In a real app, send typing status to backend
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Chat Screen')),
      body: Column(
        children: <Widget>[
          Flexible(
            child: ListView.builder(
              padding: const EdgeInsets.all(8.0),
              reverse: true, // New messages at the bottom
              itemCount: _messages.length,
              itemBuilder: (_, int index) => ChatBubble(message: _messages[index]),
            ),
          ),
          if (_isTyping) const TypingIndicator(), // Typing indicator
          const Divider(height: 1.0),
          _buildTextComposer(),
        ],
      ),
    );
  }

  Widget _buildTextComposer() {
    return IconTheme(
      data: IconThemeData(color: Theme.of(context).colorScheme.secondary),
      child: Container(
        margin: const EdgeInsets.symmetric(horizontal: 8.0),
        child: Row(
          children: <Widget>[
            Flexible(
              child: TextField(
                controller: _textController,
                onChanged: (text) {
                  _updateTypingStatus(text.isNotEmpty);
                },
                onSubmitted: _handleSubmitted,
                decoration: const InputDecoration.collapsed(hintText: 'Send a message'),
              ),
            ),
            Container(
              margin: const EdgeInsets.symmetric(horizontal: 4.0),
              child: IconButton(
                icon: const Icon(Icons.send),
                onPressed: () => _handleSubmitted(_textController.text),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

2. Implementing Typing Indicator

A typing indicator visually communicates when the other party is composing a message, creating a more interactive experience. This typically involves a small animation or text that appears briefly.

2.1. Logic for Typing Status

When the user starts typing in the `TextField`, we set `_isTyping` to `true`. When the text field becomes empty or a message is sent, `_isTyping` is set to `false`. In a real application, this `_isTyping` status would be sent to a backend and broadcast to other chat participants.


// Inside _ChatScreenState
void _updateTypingStatus(bool typing) {
  if (_isTyping != typing) {
    setState(() {
      _isTyping = typing;
    });
    // <!-- Send typing status to backend -->
    // Example: ChatService.sendTypingStatus(widget.chatId, typing);
  }
}

// In _buildTextComposer TextField
onChanged: (text) {
  _updateTypingStatus(text.isNotEmpty);
},

2.2. Typing Indicator Widget

This is a simple widget that can be enhanced with animations for a more dynamic feel.


class TypingIndicator extends StatefulWidget {
  const TypingIndicator({Key? key}) : super(key: key);

  @override
  _TypingIndicatorState createState() => _TypingIndicatorState();
}

class _TypingIndicatorState extends State<TypingIndicator> with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _animation;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 1200),
    )..repeat(reverse: true);
    _animation = Tween(begin: 0.0, end: 1.0).animate(CurvedAnimation(
      parent: _controller,
      curve: Curves.easeInOut,
    ));
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  Widget _buildDot(int index) {
    return AnimatedBuilder(
      animation: _animation,
      builder: (context, child) {
        final double opacity = (index == (_animation.value * 3).floor()) ? 1.0 : 0.4;
        return Opacity(
          opacity: opacity,
          child: Container(
            margin: const EdgeInsets.symmetric(horizontal: 2.0),
            width: 8.0,
            height: 8.0,
            decoration: const BoxDecoration(
              color: Colors.grey,
              shape: BoxShape.circle,
            ),
          ),
        );
      },
    );
  }

  @override
  Widget build(BuildContext context) {
    return Align(
      alignment: Alignment.centerLeft,
      child: Container(
        margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0),
        padding: const EdgeInsets.all(8.0),
        decoration: BoxDecoration(
          color: Colors.grey[200],
          borderRadius: BorderRadius.circular(12.0),
        ),
        child: Row(
          mainAxisSize: MainAxisSize.min,
          children: List.generate(3, (index) => _buildDot(index)),
        ),
      ),
    );
  }
}

The `TypingIndicator` is conditionally rendered in the `ChatScreen`'s `Column`:


// Inside _ChatScreenState build method
if (_isTyping) const TypingIndicator(),

3. Implementing Seen Status

Seen status provides crucial feedback to the sender about whether their message has been read. This usually appears as a small icon or text next to the message.

3.1. Seen Status Logic

For a message to be marked as seen, the receiving user's client must notify the backend when they have viewed the message. This often happens when the message enters the viewport. In our example, we'll just display the `isSeen` property from our `Message` model.

In a real-world application, when a message is displayed on screen, an event would be triggered (e.g., using `VisibilityDetector` or `ScrollablePositionedList`) to mark it as seen in the backend.

3.2. Chat Bubble Widget with Seen Status

We'll create a `ChatBubble` widget responsible for displaying a single message, including its seen status.


class ChatBubble extends StatelessWidget {
  final Message message;

  const ChatBubble({Key? key, required this.message}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final bool isMe = message.isMe;
    return Align(
      alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
      child: Container(
        margin: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0),
        padding: const EdgeInsets.all(12.0),
        decoration: BoxDecoration(
          color: isMe ? Colors.blueAccent : Colors.grey[300],
          borderRadius: BorderRadius.only(
            topLeft: const Radius.circular(12.0),
            topRight: const Radius.circular(12.0),
            bottomLeft: isMe ? const Radius.circular(12.0) : Radius.zero,
            bottomRight: isMe ? Radius.zero : const Radius.circular(12.0),
          ),
        ),
        child: Column(
          crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start,
          children: <Widget>[
            Text(
              message.text,
              style: TextStyle(
                color: isMe ? Colors.white : Colors.black87,
              ),
            ),
            const SizedBox(height: 4.0),
            Row(
              mainAxisSize: MainAxisSize.min,
              children: <Widget>[
                Text(
                  '${message.timestamp.hour}:${message.timestamp.minute.toString().padLeft(2, '0')}',
                  style: TextStyle(
                    color: isMe ? Colors.white70 : Colors.black54,
                    fontSize: 10.0,
                  ),
                ),
                if (isMe) ...[
                  const SizedBox(width: 4.0),
                  Icon(
                    message.isSeen ? Icons.done_all : Icons.done,
                    size: 14.0,
                    color: message.isSeen ? Colors.lightBlueAccent : Colors.white70,
                  ),
                ],
              ],
            ),
          ],
        ),
      ),
    );
  }
}

The `ChatBubble` is used within the `ListView.builder` in our `_ChatScreenState`:


// Inside _ChatScreenState build method
ListView.builder(
  // ...
  itemBuilder: (_, int index) => ChatBubble(message: _messages[index]),
),

4. Backend Integration (Conceptual)

While the UI components are built in Flutter, the real-time functionality of typing indicators and seen statuses heavily relies on a backend. Technologies like WebSockets, Firebase Cloud Firestore, or Pusher are commonly used for this:

  • Typing Indicator: When a user starts typing, the client sends a "typing" event to the backend. The backend broadcasts this event to other participants in the chat. When typing stops, a "stopped typing" event is sent.
  • Seen Status: When a user opens a chat or scrolls to view new messages, their client sends a "messages seen" event for the specific message IDs to the backend. The backend then updates the message status in the database and notifies the sender's client.

Conclusion

By combining a well-structured message model, a flexible `ChatBubble` widget, and a visually engaging `TypingIndicator`, we can create a highly interactive and user-friendly chat experience in Flutter. Remember that a robust backend infrastructure is crucial to support the real-time exchange of typing and seen status events, bringing these UI features to life.

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