image

28 Feb 2026

9K

35K

Building a Notification Tray Widget with Grouped Messages in Flutter

In modern applications, effective notification management is crucial for a positive user experience. A cluttered notification tray can quickly become overwhelming, leading to important alerts being missed. This article explores how to build a sophisticated notification tray widget in Flutter that intelligently groups related messages, making it easier for users to digest and manage their incoming alerts.

The Need for Grouped Notifications

Consider an application that sends multiple notifications for a single event (e.g., several replies to a comment, multiple updates from a specific user, or a series of system alerts). Displaying each of these as a separate, individual notification can quickly fill the tray. Grouping these related messages under a single, collapsible entry provides a cleaner, more organized view, allowing users to see a summary and expand for details only when needed. This approach significantly enhances usability and reduces notification fatigue.

Core Concepts and Data Model

To implement grouped notifications, we first need a robust data model for our notifications and a mechanism to identify which notifications belong together.

Notification Data Model

Our Notification class will include essential details and a crucial groupKey:


class Notification {
  final String id;
  final String title;
  final String body;
  final DateTime timestamp;
  final String? groupKey; // Identifier for grouping (e.g., senderId, topic)
  bool isRead;

  Notification({
    required this.id,
    required this.title,
    required this.body,
    required this.timestamp,
    this.groupKey,
    this.isRead = false,
  });

  // Helper method for immutable updates
  Notification copyWith({
    String? id,
    String? title,
    String? body,
    DateTime? timestamp,
    String? groupKey,
    bool? isRead,
  }) {
    return Notification(
      id: id ?? this.id,
      title: title ?? this.title,
      body: body ?? this.body,
      timestamp: timestamp ?? this.timestamp,
      groupKey: groupKey ?? this.groupKey,
      isRead: isRead ?? this.isRead,
    );
  }
}

The groupKey is central here. Notifications with the same groupKey will be grouped together. If groupKey is null or unique, the notification will typically be displayed individually.

Notification Group Model (Helper)

To simplify rendering, we'll create a helper class to represent a group of notifications:


class NotificationGroup {
  final String groupKey;
  final List<Notification> notifications;
  bool isExpanded; // State for UI expansion

  NotificationGroup({
    required this.groupKey,
    required this.notifications,
    this.isExpanded = false,
  });
}

Building the Notification Tray Widget

Our main widget, NotificationTray, will be a StatefulWidget to manage the list of notifications and their display state (e.g., expanded/collapsed groups).

Notification Tray Widget Structure

The _NotificationTrayState will hold the core logic for managing and grouping notifications.


import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; // For formatting dates/times

// Assuming Notification and NotificationGroup classes are defined as above

class NotificationTray extends StatefulWidget {
  const NotificationTray({super.key});

  @override
  State<NotificationTray> createState() => _NotificationTrayState();
}

class _NotificationTrayState extends State<NotificationTray> {
  final List<Notification> _allNotifications = [];

  @override
  void initState() {
    super.initState();
    _addDummyNotifications(); // Populate with some initial data
  }

  // --- Notification Management Methods ---

  void _addNotification(Notification notification) {
    setState(() {
      _allNotifications.add(notification);
      _sortNotifications();
    });
  }

  void _removeNotification(String notificationId) {
    setState(() {
      _allNotifications.removeWhere((n) => n.id == notificationId);
    });
  }

  void _markNotificationAsRead(String notificationId) {
    setState(() {
      final index = _allNotifications.indexWhere((n) => n.id == notificationId);
      if (index != -1) {
        _allNotifications[index] = _allNotifications[index].copyWith(isRead: true);
      }
    });
  }

  void _markGroupAsRead(String groupKey) {
    setState(() {
      for (int i = 0; i < _allNotifications.length; i++) {
        if (_allNotifications[i].groupKey == groupKey) {
          _allNotifications[i] = _allNotifications[i].copyWith(isRead: true);
        }
      }
    });
  }

  void _dismissGroup(String groupKey) {
    setState(() {
      _allNotifications.removeWhere((n) => n.groupKey == groupKey);
    });
  }

  void _sortNotifications() {
    _allNotifications.sort((a, b) => b.timestamp.compareTo(a.timestamp)); // Newest first
  }

  // --- Grouping Logic ---

  List<dynamic> _getDisplayItems() {
    Map<String, List<Notification>> groupedByGroupKey = {};
    List<Notification> ungroupedNotifications = [];

    // Separate notifications into grouped and ungrouped
    for (var notification in _allNotifications) {
      if (notification.groupKey != null) {
        if (!groupedByGroupKey.containsKey(notification.groupKey!)) {
          groupedByGroupKey[notification.groupKey!] = [];
        }
        groupedByGroupKey[notification.groupKey!]!.add(notification);
      } else {
        ungroupedNotifications.add(notification);
      }
    }

    List<dynamic> displayItems = []; // Can contain Notification or NotificationGroup

    // Add grouped items
    groupedByGroupKey.forEach((key, notifications) {
      if (notifications.length > 1) {
        displayItems.add(NotificationGroup(groupKey: key, notifications: notifications));
      } else {
        // If only one notification in a "group", treat it as individual
        displayItems.addAll(notifications);
      }
    });

    // Add truly ungrouped items
    displayItems.addAll(ungroupedNotifications);

    // Sort the final display list (groups or individual notifications) by their latest timestamp
    displayItems.sort((a, b) {
      DateTime timeA = a is Notification
          ? a.timestamp
          : (a as NotificationGroup).notifications.first.timestamp; // Assume first is newest
      DateTime timeB = b is Notification
          ? b.timestamp
          : (b as NotificationGroup).notifications.first.timestamp;
      return timeB.compareTo(timeA); // Newest first
    });

    return displayItems;
  }

  // --- UI Building Methods ---

  Widget _buildNotificationItem(Notification notification) {
    return Card(
      margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
      color: notification.isRead ? Colors.grey[200] : Colors.white,
      child: ListTile(
        title: Text(notification.title,
            style: TextStyle(fontWeight: notification.isRead ? FontWeight.normal : FontWeight.bold)),
        subtitle: Text(
          notification.body +
              ' - ${DateFormat('h:mm a, MMM d').format(notification.timestamp)}',
          maxLines: 2,
          overflow: TextOverflow.ellipsis,
        ),
        trailing: IconButton(
          icon: const Icon(Icons.close),
          onPressed: () => _removeNotification(notification.id),
        ),
        onTap: () {
          _markNotificationAsRead(notification.id);
          // Navigate to detail page or perform action
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text('Tapped notification: ${notification.title}')),
          );
        },
      ),
    );
  }

  Widget _buildNotificationGroup(NotificationGroup group) {
    // Determine title for the group
    String groupTitle = group.groupKey;
    if (group.groupKey.startsWith('user_')) {
      groupTitle = 'Messages from User ${group.groupKey.split('_')[1]}';
    } else if (group.groupKey == 'system_alerts') {
      groupTitle = 'System Alerts';
    }

    // Determine subtitle for the group
    final unreadCount = group.notifications.where((n) => !n.isRead).length;
    final totalCount = group.notifications.length;
    final groupSubtitle = unreadCount > 0
        ? '$unreadCount unread messages'
        : '$totalCount messages';

    return Card(
      margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
      color: group.notifications.every((n) => n.isRead) ? Colors.blue[50] : Colors.blue[100],
      child: ExpansionTile(
        key: PageStorageKey(group.groupKey), // Maintain expansion state across widget rebuilds
        initiallyExpanded: group.isExpanded,
        onExpansionChanged: (expanded) {
          setState(() {
            group.isExpanded = expanded;
          });
        },
        title: Text(
          groupTitle,
          style: TextStyle(fontWeight: unreadCount > 0 ? FontWeight.bold : FontWeight.normal),
        ),
        subtitle: Text(groupSubtitle),
        trailing: Row(
          mainAxisSize: MainAxisSize.min,
          children: [
            IconButton(
              icon: const Icon(Icons.check_circle_outline),
              tooltip: 'Mark all as read',
              onPressed: () => _markGroupAsRead(group.groupKey),
            ),
            IconButton(
              icon: const Icon(Icons.delete_outline),
              tooltip: 'Dismiss group',
              onPressed: () => _dismissGroup(group.groupKey),
            ),
          ],
        ),
        children: group.notifications
            .map((notification) => Padding(
                  padding: const EdgeInsets.only(left: 16.0, right: 8.0, bottom: 4.0),
                  child: _buildNotificationItem(notification),
                ))
            .toList(),
      ),
    );
  }

  // --- Dummy Data for Demonstration ---
  void _addDummyNotifications() {
    _addNotification(Notification(
      id: '1',
      title: 'New message from Alice',
      body: 'Hey, how are you doing?',
      timestamp: DateTime.now().subtract(const Duration(minutes: 5)),
      groupKey: 'user_alice',
    ));
    _addNotification(Notification(
      id: '2',
      title: 'Reminder: Project Deadline',
      body: 'Your project report is due tomorrow.',
      timestamp: DateTime.now().subtract(const Duration(minutes: 10)),
    ));
    _addNotification(Notification(
      id: '3',
      title: 'Alice replied!',
      body: 'I\'m doing great, thanks for asking!',
      timestamp: DateTime.now().subtract(const Duration(minutes: 2)),
      groupKey: 'user_alice',
    ));
    _addNotification(Notification(
      id: '4',
      title: 'System Update Available',
      body: 'A new version of the app is ready for download.',
      timestamp: DateTime.now().subtract(const Duration(hours: 1)),
      groupKey: 'system_alerts',
    ));
    _addNotification(Notification(
      id: '5',
      title: 'Security Alert',
      body: 'Unusual activity detected on your account.',
      timestamp: DateTime.now().subtract(const Duration(hours: 2)),
      groupKey: 'system_alerts',
    ));
    _addNotification(Notification(
      id: '6',
      title: 'New Email',
      body: 'You have a new email from John Doe.',
      timestamp: DateTime.now().subtract(const Duration(minutes: 1)),
    ));
    _addNotification(Notification(
      id: '7',
      title: 'Alice sent a photo',
      body: 'Check out this cool picture!',
      timestamp: DateTime.now().subtract(const Duration(minutes: 1)),
      groupKey: 'user_alice',
    ));
  }

  @override
  Widget build(BuildContext context) {
    final displayItems = _getDisplayItems();

    return Scaffold(
      appBar: AppBar(
        title: const Text('Notification Tray'),
        actions: [
          IconButton(
            icon: const Icon(Icons.add_alert),
            onPressed: () {
              // Add a new random notification for demonstration
              _addNotification(Notification(
                id: DateTime.now().millisecondsSinceEpoch.toString(),
                title: 'New Alert',
                body: 'This is a new notification added manually.',
                timestamp: DateTime.now(),
                groupKey: (DateTime.now().second % 2 == 0) ? 'user_alice' : null, // Randomly group
              ));
            },
          ),
        ],
      ),
      body: displayItems.isEmpty
          ? const Center(child: Text('No notifications'))
          : ListView.builder(
              itemCount: displayItems.length,
              itemBuilder: (context, index) {
                final item = displayItems[index];
                if (item is Notification) {
                  return _buildNotificationItem(item);
                } else if (item is NotificationGroup) {
                  return _buildNotificationGroup(item);
                }
                return const SizedBox.shrink(); // Fallback
              },
            ),
    );
  }
}

Explanation of Key Components

  1. _allNotifications: This List<Notification> holds all raw notifications received by the tray.

  2. _getDisplayItems(): This is the heart of the grouping logic. It iterates through _allNotifications:

    • It first separates notifications into those with a groupKey and those without.
    • For notifications with a groupKey, it groups them into Map<String, List<Notification>>.
    • It then iterates through these groups:
      • If a group has more than one notification, it creates a NotificationGroup object.
      • If a "group" only has one notification, or if the notification never had a groupKey, it's treated as an individual Notification object.
    • Finally, it returns a List<dynamic> containing a mix of Notification and NotificationGroup objects, sorted by their latest timestamp.
  3. _buildNotificationItem(Notification notification): Renders a single notification using a Card and ListTile. It shows the title, body, and timestamp, along with a dismiss button. Tapping it marks it as read.

  4. _buildNotificationGroup(NotificationGroup group): Renders a group of notifications using an ExpansionTile.

    • The title summarizes the group (e.g., "Messages from User Alice").
    • The subtitle shows the count of unread/total messages.
    • It includes action buttons to mark all notifications in the group as read or to dismiss the entire group.
    • When expanded, it reveals all individual notifications within that group, each rendered by _buildNotificationItem.
    • PageStorageKey is used to maintain the expansion state of each group across rebuilds.
  5. Management Methods: _addNotification, _removeNotification, _markNotificationAsRead, _markGroupAsRead, and _dismissGroup handle the state changes, triggering setState to update the UI.

Conclusion

By implementing a grouped notification tray, you can significantly enhance the user experience in your Flutter applications. This approach reduces visual clutter, makes important information more discoverable, and provides users with powerful tools to manage their incoming alerts efficiently. The presented architecture, leveraging a clear data model and intelligent grouping logic, offers a solid foundation upon which you can build even more sophisticated notification features, such as advanced filtering, prioritization, and persistent storage mechanisms.

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