image

05 Mar 2026

9K

35K

Building a Chat Screen Widget with Message Grouping in Flutter

A chat screen is a fundamental component in many modern applications, enabling real-time communication between users. While seemingly straightforward, designing a user-friendly and visually appealing chat interface involves several nuances. One critical aspect is message grouping, which enhances readability by visually associating consecutive messages from the same sender, thereby reducing visual clutter and improving the overall chat experience.

This article will guide you through the process of building a Flutter chat screen widget that incorporates intelligent message grouping. We will cover the essential data model, the logic for grouping messages, and how to render them effectively.

1. Defining the Message Data Model

First, let's establish a clear data structure for our chat messages. This model will hold all the necessary information for each message.


import 'package:flutter/foundation.dart';

class Message {
  final String id;
  final String senderId;
  final String senderName;
  final String text;
  final DateTime timestamp;
  final bool isMe; // True if the message was sent by the current user

  Message({
    required this.id,
    required this.senderId,
    required this.senderName,
    required this.text,
    required this.timestamp,
    required this.isMe,
  });

  @override
  bool operator ==(Object other) {
    if (identical(this, other)) return true;
    return other is Message &&
        other.id == id &&
        other.senderId == senderId &&
        other.senderName == senderName &&
        other.text == text &&
        other.timestamp == timestamp &&
        other.isMe == isMe;
  }

  @override
  int get hashCode =>
      id.hashCode ^
      senderId.hashCode ^
      senderName.hashCode ^
      text.hashCode ^
      timestamp.hashCode ^
      isMe.hashCode;
}

2. Designing the Message Bubble Widget

The MessageBubble is a crucial part of our chat UI. It's responsible for displaying individual messages, adapting its appearance based on the sender and its position within a group.


import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'message_model.dart'; // Assuming message_model.dart is in the same directory

class MessageBubble extends StatelessWidget {
  final Message message;
  final bool isFirstInGroup; // True if this is the first message from a sender in a consecutive block
  final bool isLastInGroup;  // True if this is the last message from a sender in a consecutive block

  const MessageBubble({
    Key? key,
    required this.message,
    this.isFirstInGroup = true,
    this.isLastInGroup = true,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final isMe = message.isMe;
    final alignment = isMe ? Alignment.centerRight : Alignment.centerLeft;
    final color = isMe ? theme.primaryColor : theme.colorScheme.secondaryContainer;
    final textColor = isMe ? theme.colorScheme.onPrimary : theme.colorScheme.onSecondaryContainer;
    final borderRadius = BorderRadius.only(
      topLeft: Radius.circular(isMe || isFirstInGroup ? 16 : 4),
      topRight: Radius.circular(isMe && !isFirstInGroup ? 4 : 16),
      bottomLeft: Radius.circular(isMe ? 16 : 4),
      bottomRight: Radius.circular(isMe ? 4 : 16),
    );

    return Align(
      alignment: alignment,
      child: Container(
        margin: EdgeInsets.only(
          top: isFirstInGroup ? 8.0 : 4.0,
          bottom: isLastInGroup ? 8.0 : 4.0,
          left: isMe ? 60.0 : 8.0,
          right: isMe ? 8.0 : 60.0,
        ),
        padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
        decoration: BoxDecoration(
          color: color,
          borderRadius: borderRadius.copyWith(
            bottomLeft: Radius.circular(isMe ? (isLastInGroup ? 16 : 4) : 16),
            bottomRight: Radius.circular(isMe ? 16 : (isLastInGroup ? 16 : 4)),
          ),
        ),
        child: Column(
          crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start,
          children: [
            if (isFirstInGroup && !isMe)
              Padding(
                padding: const EdgeInsets.only(bottom: 4.0),
                child: Text(
                  message.senderName,
                  style: theme.textTheme.labelSmall?.copyWith(
                    color: theme.colorScheme.primary,
                    fontWeight: FontWeight.bold,
                  ),
                ),
              ),
            Text(
              message.text,
              style: TextStyle(color: textColor, fontSize: 16.0),
            ),
            if (isLastInGroup)
              Padding(
                padding: const EdgeInsets.only(top: 4.0),
                child: Text(
                  DateFormat('HH:mm').format(message.timestamp),
                  style: theme.textTheme.labelSmall?.copyWith(
                    color: textColor.withOpacity(0.7),
                  ),
                ),
              ),
          ],
        ),
      ),
    );
  }
}

In the MessageBubble, we conditionally show the sender's name and timestamp. The borderRadius is also adjusted to create a "grouped" look, where corners facing other messages in the same group are less rounded. This creates a visually continuous bubble effect.

3. Implementing the Chat Screen with Grouping Logic

Now, let's create the main ChatScreen widget. This widget will manage the list of messages and apply the grouping logic before rendering each MessageBubble.


import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart'; // Add uuid: ^4.0.0 to your pubspec.yaml
import 'message_model.dart';
import 'message_bubble.dart';

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

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

class _ChatScreenState extends State<ChatScreen> {
  final List<Message> _messages = [];
  final TextEditingController _textController = TextEditingController();
  final ScrollController _scrollController = ScrollController();
  final _uuid = const Uuid();

  @override
  void initState() {
    super.initState();
    // Add some dummy messages for demonstration
    _messages.addAll([
      Message(
        id: _uuid.v4(),
        senderId: 'user1',
        senderName: 'Alice',
        text: 'Hi there!',
        timestamp: DateTime.now().subtract(const Duration(minutes: 10, seconds: 30)),
        isMe: false,
      ),
      Message(
        id: _uuid.v4(),
        senderId: 'user2',
        senderName: 'You',
        text: 'Hello Alice! How are you?',
        timestamp: DateTime.now().subtract(const Duration(minutes: 9, seconds: 0)),
        isMe: true,
      ),
      Message(
        id: _uuid.v4(),
        senderId: 'user1',
        senderName: 'Alice',
        text: 'I\'m doing great, thanks for asking!',
        timestamp: DateTime.now().subtract(const Duration(minutes: 7, seconds: 45)),
        isMe: false,
      ),
      Message(
        id: _uuid.v4(),
        senderId: 'user1',
        senderName: 'Alice',
        text: 'Just finished my project.',
        timestamp: DateTime.now().subtract(const Duration(minutes: 7, seconds: 30)),
        isMe: false,
      ),
      Message(
        id: _uuid.v4(),
        senderId: 'user2',
        senderName: 'You',
        text: 'That\'s awesome! What was it about?',
        timestamp: DateTime.now().subtract(const Duration(minutes: 5, seconds: 15)),
        isMe: true,
      ),
      Message(
        id: _uuid.v4(),
        senderId: 'user2',
        senderName: 'You',
        text: 'I\'m curious.',
        timestamp: DateTime.now().subtract(const Duration(minutes: 5, seconds: 5)),
        isMe: true,
      ),
      Message(
        id: _uuid.v4(),
        senderId: 'user1',
        senderName: 'Alice',
        text: 'It was a Flutter app for tracking expenses.',
        timestamp: DateTime.now().subtract(const Duration(minutes: 2, seconds: 0)),
        isMe: false,
      ),
      Message(
        id: _uuid.v4(),
        senderId: 'user1',
        senderName: 'Alice',
        text: 'Pretty cool, huh?',
        timestamp: DateTime.now().subtract(const Duration(minutes: 1, seconds: 45)),
        isMe: false,
      ),
      Message(
        id: _uuid.v4(),
        senderId: 'user1',
        senderName: 'Alice',
        text: 'Still debugging a few things though.',
        timestamp: DateTime.now().subtract(const Duration(minutes: 1, seconds: 15)),
        isMe: false,
      ),
      Message(
        id: _uuid.v4(),
        senderId: 'user2',
        senderName: 'You',
        text: 'Sounds like a great idea! Good luck with the debugging.',
        timestamp: DateTime.now().subtract(const Duration(seconds: 30)),
        isMe: true,
      ),
    ]);
  }

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

  void _sendMessage() {
    if (_textController.text.trim().isEmpty) return;

    final newMessage = Message(
      id: _uuid.v4(),
      senderId: 'user2', // Current user's ID
      senderName: 'You', // Current user's name
      text: _textController.text.trim(),
      timestamp: DateTime.now(),
      isMe: true,
    );

    setState(() {
      _messages.add(newMessage);
      _textController.clear();
    });

    // Scroll to the bottom after sending a message
    _scrollController.animateTo(
      _scrollController.position.maxScrollExtent,
      duration: const Duration(milliseconds: 300),
      curve: Curves.easeOut,
    );
  }

  bool _isSameSender(int index, int prevIndex) {
    if (prevIndex < 0 || prevIndex >= _messages.length) return false;
    return _messages[index].senderId == _messages[prevIndex].senderId;
  }

  bool _isCloseInTime(int index, int prevIndex, {int minutesThreshold = 5}) {
    if (prevIndex < 0 || prevIndex >= _messages.length) return false;
    final timeDifference = _messages[index].timestamp.difference(_messages[prevIndex].timestamp).inMinutes;
    return timeDifference < minutesThreshold;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Grouped Chat'),
        backgroundColor: Theme.of(context).primaryColor,
        foregroundColor: Theme.of(context).colorScheme.onPrimary,
      ),
      body: Column(
        children: [
          Expanded(
            child: ListView.builder(
              controller: _scrollController,
              padding: const EdgeInsets.symmetric(horizontal: 8.0),
              itemCount: _messages.length,
              itemBuilder: (context, index) {
                final message = _messages[index];

                // Determine grouping flags
                final bool isFirstInGroup;
                final bool isLastInGroup;

                // Check previous message
                if (index == 0) {
                  isFirstInGroup = true;
                } else {
                  isFirstInGroup =
                      !_isSameSender(index, index - 1) || !_isCloseInTime(index, index - 1);
                }

                // Check next message
                if (index == _messages.length - 1) {
                  isLastInGroup = true;
                } else {
                  isLastInGroup =
                      !_isSameSender(index, index + 1) || !_isCloseInTime(index + 1, index);
                }

                return MessageBubble(
                  key: ValueKey(message.id), // Important for performance and state
                  message: message,
                  isFirstInGroup: isFirstInGroup,
                  isLastInGroup: isLastInGroup,
                );
              },
            ),
          ),
          _buildMessageInput(),
        ],
      ),
    );
  }

  Widget _buildMessageInput() {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
      color: Theme.of(context).cardColor,
      child: Row(
        children: [
          Expanded(
            child: TextField(
              controller: _textController,
              decoration: InputDecoration(
                hintText: 'Type a message...',
                border: OutlineInputBorder(
                  borderRadius: BorderRadius.circular(25.0),
                  borderSide: BorderSide.none,
                ),
                filled: true,
                fillColor: Theme.of(context).colorScheme.surfaceVariant,
                contentPadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
              ),
              onSubmitted: (_) => _sendMessage(),
            ),
          ),
          Padding(
            padding: const EdgeInsets.only(left: 8.0),
            child: FloatingActionButton(
              onPressed: _sendMessage,
              mini: true,
              backgroundColor: Theme.of(context).primaryColor,
              foregroundColor: Theme.of(context).colorScheme.onPrimary,
              elevation: 0,
              child: const Icon(Icons.send),
            ),
          ),
        ],
      ),
    );
  }
}

Explanation of Grouping Logic:

In the ListView.builder, for each message at index:

  • isFirstInGroup:
    • It's true if it's the very first message in the list (index == 0).
    • Otherwise, it's true if the current message's sender is different from the previous message's sender, OR if there's a significant time gap (e.g., more than 5 minutes) between the current message and the previous one. This implies a new "group" is starting.
  • isLastInGroup:
    • It's true if it's the very last message in the list (index == _messages.length - 1).
    • Otherwise, it's true if the current message's sender is different from the next message's sender, OR if there's a significant time gap between the current message and the next one. This implies the current message is the end of a "group".

These flags are then passed to the MessageBubble, which uses them to adjust its appearance (e.g., border radius, showing/hiding sender name or timestamp) to visually represent the grouping.

4. Running the Application

To run this example, create a new Flutter project and replace main.dart with the following:


import 'package:flutter/material.dart';
import 'chat_screen.dart'; // Assuming your chat_screen.dart is in the same directory

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Grouped Chat Demo',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        primarySwatch: Colors.blue,
        colorScheme: ColorScheme.fromSeed(
          seedColor: Colors.blue,
          secondaryContainer: Colors.grey[200], // For incoming messages
          onSecondaryContainer: Colors.black87,
        ),
        useMaterial3: true,
      ),
      home: const ChatScreen(),
    );
  }
}

Make sure you have uuid and intl added to your pubspec.yaml:


dependencies:
  flutter:
    sdk: flutter
  uuid: ^4.0.0
  intl: ^0.19.0 # For DateFormat

Then run flutter pub get and launch your application.

Conclusion

By carefully structuring our data, implementing robust grouping logic, and designing a flexible MessageBubble widget, we have successfully created a Flutter chat screen with intuitive message grouping. This approach significantly enhances the user experience by making chat conversations easier to follow and more visually appealing. You can further expand upon this foundation by adding features like real-time updates, different message types (images, videos), read receipts, and more complex state management.

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