···11+import 'package:spark/src/core/network/atproto/data/models/notification_models.dart';
22+33+/// Interface for Notification-related API endpoints
44+abstract class NotificationRepository {
55+ /// List notifications for the requesting account
66+ ///
77+ /// [limit] The number of notifications to return (default 50, max 100)
88+ /// [cursor] Pagination cursor for the next set of results
99+ /// [priority] Whether to return only priority notifications
1010+ /// [reasons] Optional list of notification reasons to filter by
1111+ Future<ListNotificationsResponse> listNotifications({
1212+ int limit = 50,
1313+ String? cursor,
1414+ bool? priority,
1515+ List<String>? reasons,
1616+ });
1717+1818+ /// Get the count of unread notifications
1919+ ///
2020+ /// [priority] Whether to count only priority notifications
2121+ Future<UnreadCountResponse> getUnreadCount({bool? priority});
2222+2323+ /// Mark notifications as seen
2424+ ///
2525+ /// [seenAt] The timestamp to mark notifications as seen at
2626+ Future<void> updateSeen(DateTime seenAt);
2727+}
···11+import 'package:spark/src/core/network/atproto/data/models/notification_models.dart';
22+33+/// Represents group of similar notifications that should be displayed together.
44+/// For example: "X and 5 others liked your post"
55+class GroupedNotification {
66+ /// The primary notification (most recent in the group)
77+ final Notification primaryNotification;
88+99+ /// All notifications in this group (including the primary)
1010+ final List<Notification> notifications;
1111+1212+ /// The reason for the notification (like, repost, follow, etc.)
1313+ String get reason => primaryNotification.reason;
1414+1515+ /// The subject being acted upon (e.g., the post being liked)
1616+ /// Null for follows
1717+ String? get reasonSubject => primaryNotification.reasonSubject?.toString();
1818+1919+ /// Number of unique actors in this group
2020+ int get actorCount => _uniqueActors.length;
2121+2222+ /// Whether this notification group has been read
2323+ bool get isRead => notifications.every((n) => n.isRead);
2424+2525+ /// The most recent indexedAt in the group
2626+ DateTime get indexedAt => primaryNotification.indexedAt;
2727+2828+ /// Unique actors in this group (deduplicated by DID)
2929+ List<Notification> get _uniqueActors {
3030+ final seen = <String>{};
3131+ return notifications.where((n) {
3232+ final did = n.author.did;
3333+ if (seen.contains(did)) return false;
3434+ seen.add(did);
3535+ return true;
3636+ }).toList();
3737+ }
3838+3939+ /// Get unique authors for display (limited to first N)
4040+ List<Notification> getUniqueAuthors({int limit = 5}) {
4141+ return _uniqueActors.take(limit).toList();
4242+ }
4343+4444+ /// Get the "others" count for display
4545+ int get othersCount => actorCount > 1 ? actorCount - 1 : 0;
4646+4747+ const GroupedNotification({
4848+ required this.primaryNotification,
4949+ required this.notifications,
5050+ });
5151+5252+ /// Create a single-notification group
5353+ factory GroupedNotification.single(Notification notification) {
5454+ return GroupedNotification(
5555+ primaryNotification: notification,
5656+ notifications: [notification],
5757+ );
5858+ }
5959+}
6060+6161+/// Groups notifications by type and subject.
6262+/// - Follows are grouped together (follow-backs are shown separately)
6363+/// - Likes on the same post are grouped
6464+/// - Reposts of the same post are grouped
6565+/// - Replies and mentions are NOT grouped
6666+List<GroupedNotification> groupNotifications(List<Notification> notifications) {
6767+ if (notifications.isEmpty) return [];
6868+6969+ final result = <GroupedNotification>[];
7070+ final followGroups = <String, List<Notification>>{};
7171+ final likeGroups = <String, List<Notification>>{};
7272+ final repostGroups = <String, List<Notification>>{};
7373+7474+ // First pass: collect all groupable notifications
7575+ for (final notification in notifications) {
7676+ switch (notification.reason) {
7777+ case 'follow':
7878+ // Check if this is a follow-back (viewer follows the author)
7979+ final isFollowBack = notification.author.viewer?.following != null;
8080+ if (isFollowBack) {
8181+ // Follow-backs are shown individually, not grouped
8282+ result.add(GroupedNotification.single(notification));
8383+ } else {
8484+ // Regular follows are grouped together
8585+ followGroups.putIfAbsent('follows', () => []).add(notification);
8686+ }
8787+ case 'like':
8888+ // Group likes by reasonSubject (the post/reply being liked)
8989+ if (notification.reasonSubject != null) {
9090+ final key = notification.reasonSubject.toString();
9191+ likeGroups.putIfAbsent(key, () => []).add(notification);
9292+ } else {
9393+ // No subject, don't group
9494+ result.add(GroupedNotification.single(notification));
9595+ }
9696+ case 'repost':
9797+ // Group reposts by reasonSubject
9898+ if (notification.reasonSubject != null) {
9999+ final key = notification.reasonSubject.toString();
100100+ repostGroups.putIfAbsent(key, () => []).add(notification);
101101+ } else {
102102+ result.add(GroupedNotification.single(notification));
103103+ }
104104+ default:
105105+ // reply, mention, etc. - don't group
106106+ result.add(GroupedNotification.single(notification));
107107+ }
108108+ }
109109+110110+ // Second pass: create grouped notifications & interleave them chronologically
111111+ final allGroups = <GroupedNotification>[...result];
112112+113113+ // Add follow groups
114114+ if (followGroups['follows']?.isNotEmpty ?? false) {
115115+ final follows = followGroups['follows']!
116116+ // Sort by most recent first
117117+ ..sort((a, b) => b.indexedAt.compareTo(a.indexedAt));
118118+ allGroups.add(
119119+ GroupedNotification(
120120+ primaryNotification: follows.first,
121121+ notifications: follows,
122122+ ),
123123+ );
124124+ }
125125+126126+ // Add like groups
127127+ for (final entry in likeGroups.entries) {
128128+ final likes = entry.value
129129+ ..sort((a, b) => b.indexedAt.compareTo(a.indexedAt));
130130+ allGroups.add(
131131+ GroupedNotification(
132132+ primaryNotification: likes.first,
133133+ notifications: likes,
134134+ ),
135135+ );
136136+ }
137137+138138+ // Add repost groups
139139+ for (final entry in repostGroups.entries) {
140140+ final reposts = entry.value
141141+ ..sort((a, b) => b.indexedAt.compareTo(a.indexedAt));
142142+ allGroups.add(
143143+ GroupedNotification(
144144+ primaryNotification: reposts.first,
145145+ notifications: reposts,
146146+ ),
147147+ );
148148+ }
149149+150150+ // Sort all groups by most recent notification
151151+ allGroups.sort((a, b) => b.indexedAt.compareTo(a.indexedAt));
152152+153153+ return allGroups;
154154+}