+290
-53
doc/roadmap.txt
+290
-53
doc/roadmap.txt
···
46
46
- [x] Mark all as read action in app bar
47
47
48
48
Phase 3: Notification Grouping and Polish
49
-
- [ ] GroupedNotification model and grouping algorithm
50
-
- [ ] GroupedNotificationItem widget (actor list, "and N others")
51
-
- [ ] Expand/collapse for grouped notifications
52
-
- [ ] Empty state illustration
53
-
- [ ] Loading states and skeleton screens
54
-
- [ ] Relative timestamps ("2h ago")
49
+
- [x] GroupedNotification model and grouping algorithm
50
+
- [x] GroupedNotificationItem widget (actor list, "and N others")
51
+
- [x] Expand/collapse for grouped notifications
52
+
- [x] Empty state illustration
53
+
- [x] Loading states and skeleton screens
54
+
- [x] Relative timestamps ("2h ago")
55
55
56
56
Tests:
57
-
- [ ] Repository: fetch, parse, cache, pagination
58
-
- [ ] Notifier: state transitions, refresh, load more
59
-
- [ ] Grouping algorithm with various notification types
60
-
- [ ] Mark as seen batching and offline queue
61
-
- [ ] Unread count updates
62
-
- [ ] Widget tests: list, items, badges
63
-
- [ ] Integration: fetch → display → tap → navigate
64
-
- [ ] Offline: cached display, queued seen state sync
57
+
- [x] Repository: fetch, parse, cache, pagination
58
+
- [x] Notifier: state transitions, refresh, load more
59
+
- [x] Grouping algorithm with various notification types
60
+
- [x] Mark as seen batching and offline queue
61
+
- [x] Unread count updates
62
+
- [x] Widget tests: list, items, badges
63
+
- [x] Integration: fetch → display → tap → navigate
64
+
- [x] Offline: cached display, queued seen state sync
65
65
66
66
Deliverables:
67
-
- Notifications tab functional with real data
68
-
- Unread badge in tab navigation
69
-
- Smooth pagination and pull-to-refresh
70
-
- Mark as seen syncs to Bluesky
71
-
- Notification grouping for compact display
72
-
- Deep linking to threads and profiles
73
-
- >95% test coverage
67
+
- [x] Notifications tab functional with real data
68
+
- [x] Unread badge in tab navigation
69
+
- [x] Smooth pagination and pull-to-refresh
70
+
- [x] Mark as seen syncs to Bluesky
71
+
- [x] Notification grouping for compact display
72
+
- [x] Deep linking to threads and profiles
74
73
75
74
================================================================================
76
75
M. DMs (read + write) + outbox + read-state *bsky-M*
77
76
================================================================================
78
77
79
78
DM fundamentals:
80
-
- Chat endpoints are called via user’s PDS and proxied to did:web:api.bsky.chat
79
+
- Chat endpoints are called via user's PDS and proxied to did:web:api.bsky.chat
81
80
via service proxy header, per chat endpoint docs and XRPC proxy spec.
81
+
- Outbox pattern ensures reliable delivery: queue locally, retry with backoff,
82
+
never double-send without explicit server acknowledgement.
83
+
- Read state tracked bidirectionally: local updateRead + remote unread counts.
82
84
83
-
Endpoints:
85
+
Endpoints (all registered):
84
86
- chat.bsky.convo.listConvos (GET)
85
87
- chat.bsky.convo.getMessages (GET)
86
88
- chat.bsky.convo.sendMessage (POST)
87
89
- chat.bsky.convo.acceptConvo (POST)
88
90
- chat.bsky.convo.updateRead (POST)
89
91
90
-
Tasks:
91
-
- [ ] Dio proxying:
92
-
- add `atproto-proxy` header for all chat.* calls (service proxying).
92
+
Phase 1: Data Layer and API Integration
93
93
- [ ] Drift tables:
94
-
- dm_convos, dm_members, dm_messages
95
-
- dm_outbox (local queued sends)
96
-
- [ ] Inbox UI:
97
-
- convo list, unread indicators, accept requests flow
98
-
- [ ] Convo UI:
99
-
- message list paging
100
-
- compose/send
101
-
- mark read on view (updateRead)
102
-
- [ ] Outbox worker:
103
-
- persist pending messages immediately
104
-
- retry sends safely (bounded; user-visible failure state)
105
-
- never double-send without explicit server acknowledgement
94
+
- DmConvos (convoId, membersJson, lastMessageText, lastMessageAt,
95
+
lastReadMessageId, unreadCount, isMuted, isAccepted, cachedAt)
96
+
- DmMessages (messageId, convoId, senderDid, text, sentAt, status, cachedAt)
97
+
- DmOutbox (outboxId, convoId, text, status, retryCount, createdAt,
98
+
lastAttemptAt, errorMessage)
99
+
- [ ] DAOs:
100
+
- DmConvosDao (CRUD, watch stream, update read state)
101
+
- DmMessagesDao (CRUD, watch by convoId, update status)
102
+
- DmOutboxDao (CRUD, watch pending, update retry count)
103
+
- [ ] Domain models:
104
+
- DmConversation (with computed properties for UI)
105
+
- DmMessage (with delivery status enum)
106
+
- OutboxItem (with retry logic methods)
107
+
- [ ] DmsRepository:
108
+
- fetchConversations with cursor pagination
109
+
- watchConversations stream from cache
110
+
- fetchMessages with cursor pagination
111
+
- watchMessages stream by convoId
112
+
- acceptConversation API call
113
+
- updateReadState API call
114
+
- inject `atproto-proxy: did:web:api.bsky.chat#bsky_chat` header
115
+
- [ ] OutboxRepository:
116
+
- enqueueSend (persist to outbox + display optimistically)
117
+
- processOutbox (sequential per-convo processing)
118
+
- retryMessage (user-initiated retry)
119
+
- _sendMessage with exponential backoff (5 max retries)
120
+
- atomic operations: outbox insert + message display, outbox delete + ack
121
+
122
+
Phase 2: Conversation List Screen
123
+
- [ ] ConversationListScreen:
124
+
- display conversations sorted by lastMessageAt
125
+
- pull-to-refresh for new conversations
126
+
- pagination with cursor support
127
+
- separate "Requests" section for unaccepted convos
128
+
- tap to open conversation detail
129
+
- empty state when no conversations
130
+
- [ ] ConversationListItem widget:
131
+
- participant avatar and display name
132
+
- last message preview (first line)
133
+
- timestamp (relative, e.g., "2h ago")
134
+
- unread indicator (dot or badge with count)
135
+
- message request indicator if not accepted
136
+
- [ ] ConversationListNotifier:
137
+
- watchConversations stream
138
+
- refresh() and loadMore() methods
139
+
- acceptConversation() action
140
+
141
+
Phase 3: Conversation Detail Screen
142
+
- [ ] ConversationDetailScreen:
143
+
- display messages in chronological order (newest at bottom)
144
+
- pull-to-refresh for older messages (pagination)
145
+
- scroll to bottom on new messages
146
+
- auto-mark as read on view (updateRead)
147
+
- compose input with send button
148
+
- optimistic send (show immediately with pending state)
149
+
- [ ] MessageBubble widget:
150
+
- sender avatar (other party only)
151
+
- message text content
152
+
- timestamp (relative or absolute)
153
+
- delivery status indicator (sending, sent, failed, read)
154
+
- group consecutive messages by same sender
155
+
- [ ] MessageComposer widget:
156
+
- multi-line text input (auto-expanding)
157
+
- send button (disabled when empty)
158
+
- character limit (10,000 chars)
159
+
- character counter when approaching limit
160
+
- [ ] DeliveryStatusIndicator widget:
161
+
- pending: clock icon
162
+
- sending: spinner
163
+
- sent: single checkmark
164
+
- read: double checkmark
165
+
- failed: error icon with retry button
166
+
- [ ] ConversationDetailNotifier:
167
+
- watchMessages stream for convoId
168
+
- sendMessage (enqueue in outbox + trigger worker)
169
+
- markAsRead on conversation open
170
+
171
+
Phase 4: Outbox Worker and Reliability
172
+
- [ ] OutboxWorker:
173
+
- periodic processing (every 10 seconds)
174
+
- trigger on app foreground resume
175
+
- process pending items oldest-first
176
+
- one message per conversation at a time (preserve order)
177
+
- exponential backoff (2^retryCount seconds: 2s, 4s, 8s, 16s, 32s)
178
+
- max 5 retries, then mark permanently failed
179
+
- transient errors: network, timeout → retry
180
+
- permanent errors: auth, validation → mark failed, notify user
181
+
- [ ] Error handling:
182
+
- network errors: "No internet. Message will send when online."
183
+
- server errors: "Failed to send. Tap to retry."
184
+
- auth errors: "Session expired. Please sign in."
185
+
- validation errors: "Message too long (15,000 / 10,000 chars)"
186
+
- [ ] Atomic operations:
187
+
- use database transactions for send operations
188
+
- prevent double-sends (wait for server acknowledgment)
189
+
- handle app crash during send (resume on restart)
190
+
191
+
Phase 5: Message Requests and Accept Flow
192
+
- [ ] MessageRequestCard widget:
193
+
- display sender profile (avatar, display name, handle)
194
+
- message preview
195
+
- accept and decline buttons
196
+
- [ ] Accept flow:
197
+
- call chat.bsky.convo.acceptConvo
198
+
- move conversation to main list
199
+
- enable sending messages
200
+
- [ ] Decline flow:
201
+
- hide conversation (client-side only)
202
+
- user can still accept later
203
+
204
+
Phase 6: Polish and Optimization
205
+
- [ ] Animations and transitions:
206
+
- smooth scroll to bottom
207
+
- optimistic send animation
208
+
- delivery status transitions
209
+
- [ ] Performance:
210
+
- ListView.builder for lazy rendering
211
+
- limit in-memory messages (100 visible)
212
+
- cache message heights for smooth scrolling
213
+
- avatar caching with LRU eviction
214
+
- [ ] Accessibility:
215
+
- semantic labels for all interactive elements
216
+
- screen reader support
217
+
- keyboard navigation (Cmd/Ctrl+Enter to send)
218
+
- [ ] Haptic feedback on send
219
+
- [ ] Read receipts display (when other party reads)
106
220
107
221
Tests:
108
-
- [ ] Proxy header unit test: chat.* requests include atproto-proxy.
109
-
- [ ] Outbox tests: enqueue -> send -> mark delivered; retry on transient errors
110
-
- [ ] UI tests: offline send creates pending bubble; online later resolves.
222
+
- [ ] Repository: fetch convos, fetch messages, accept, update read, parse responses
223
+
- [ ] OutboxRepository: enqueue, process, retry with backoff, handle errors
224
+
- [ ] Proxy header unit test: verify chat.* requests include atproto-proxy header
225
+
- [ ] Outbox reliability tests:
226
+
- enqueue → send → mark delivered
227
+
- enqueue → send fails → retry with backoff
228
+
- enqueue → send fails 5x → mark permanently failed
229
+
- enqueue → app crash → resume on restart → deliver
230
+
- enqueue multiple → process in order → preserve ordering
231
+
- double-send prevention: send in progress → don't send again
232
+
- [ ] DAOs: CRUD operations, streams, queries
233
+
- [ ] Notifiers: state transitions, refresh, load more
234
+
- [ ] Widget tests: list items, message bubbles, composer, delivery status
235
+
- [ ] Integration tests:
236
+
- fetch convos → display → tap → open detail
237
+
- compose → send → outbox → server → display
238
+
- send offline → queue → network restore → deliver
239
+
- receive message → display → mark read → sync
240
+
- message request → accept → move to main list
241
+
- [ ] Offline scenarios: compose offline, send when online, display cached
111
242
112
243
Deliverables:
113
-
- DMs usable end-to-end: read, accept, send, read-state, robust retries.
244
+
- [ ] Conversation list functional with unread indicators
245
+
- [ ] Message requests require acceptance
246
+
- [ ] Conversation detail with message history
247
+
- [ ] Compose and send text messages
248
+
- [ ] Outbox ensures reliable delivery (never lose messages)
249
+
- [ ] Failed sends retry automatically (bounded, user can retry)
250
+
- [ ] No double-sends occur
251
+
- [ ] Read state syncs bidirectionally
252
+
- [ ] Offline messages queue and sync
253
+
- [ ] Proxy header injected correctly
254
+
- [ ] Performance acceptable with 100+ conversations
255
+
- [ ] Test coverage exceeds 95%
114
256
115
257
================================================================================
116
258
N. Hardening (mobile) *bsky-N*
117
259
================================================================================
118
260
119
-
Tasks:
261
+
Phase 1: Code Quality and Architecture
262
+
- [ ] Feature architecture standardization:
263
+
- Audit all features for missing domain/infrastructure/application layers
264
+
- Establish clear guidelines for layer responsibilities
265
+
- Refactor features missing layers (priority: auth, settings, profile, search, thread)
266
+
- Document architecture patterns in doc/architecture.md
267
+
- [ ] Type safety improvements:
268
+
- Audit unsafe casts (334 instances found)
269
+
- Replace unsafe casts with proper null checks or code generation
270
+
- Add typed JSON parsing where appropriate
271
+
- Consider json_serializable or freezed for critical models
272
+
- [ ] Large file refactoring:
273
+
- Split feed_repository.dart (1083 lines) into focused modules
274
+
- Split profile_repository.dart (720 lines) into focused modules
275
+
- Split composer_screen.dart (481 lines) if warranted
276
+
- Target: no file over 500 lines unless justified
277
+
- [ ] Error handling standardization:
278
+
- Define standard error types for common failures (network, auth, validation)
279
+
- Standardize error display messages (no raw .toString() in UI)
280
+
- Document error handling patterns in doc/error-handling.md
281
+
- Add comprehensive error handling tests
282
+
283
+
Phase 2: Database and Persistence
284
+
- [ ] Database migration strategy:
285
+
- Design migration system for schema version changes
286
+
- Document migration approach in doc/database-migrations.md
287
+
- Test migration scenarios (upgrade, rollback, data preservation)
288
+
- Add migration integration tests before v1.0
289
+
- [ ] Database performance:
290
+
- DB query profiling with realistic data volumes
291
+
- Add indexes as needed based on profiling
292
+
- Optimize hot paths (feed queries, notification queries)
293
+
- Test with N=10k cached posts, N=1k notifications
294
+
295
+
Phase 3: Performance Optimization
120
296
- [ ] Performance:
121
-
- image caching strategy; list virtualization sanity
122
-
- DB query profiling; add indexes as needed
297
+
- Image caching strategy review and optimization
298
+
- List virtualization sanity checks (ListView.builder usage)
299
+
- Profile critical paths with Flutter DevTools
300
+
- Optimize animation controller usage in lists (skeleton widgets)
301
+
- Memory leak detection (especially in stateful widgets)
302
+
- [ ] Network efficiency:
303
+
- Audit pagination cursor handling
304
+
- Review cache invalidation strategies
305
+
- Optimize background sync frequencies
306
+
307
+
Phase 4: Quality Assurance
308
+
- [ ] Test coverage:
309
+
- Achieve >95% coverage target across all features
310
+
- Add missing integration tests (notifications grouping, DMs outbox)
311
+
- Add E2E tests for critical flows
312
+
- Widget test coverage for all custom widgets
123
313
- [ ] Accessibility:
124
-
- semantic labels, dynamic type, contrast checks
314
+
- Semantic labels for all interactive elements
315
+
- Dynamic type support and testing
316
+
- Contrast ratio checks (WCAG AA compliance)
317
+
- Screen reader testing on iOS and Android
318
+
- Keyboard navigation testing (tab focus order)
319
+
- [ ] Scroll performance tests:
320
+
- Sanity test on mid-range Android emulator profile
321
+
- iOS scroll performance validation
322
+
- Measure frame render times under load
323
+
- [ ] Observability:
324
+
- [x] Structured logging framework
325
+
- [ ] Crash reporting integration (Sentry/Firebase Crashlytics)
326
+
- [ ] Performance monitoring hooks
327
+
- [ ] Analytics event tracking foundation
328
+
329
+
Phase 5: Security and Moderation
330
+
- [ ] Security audit:
331
+
- Review secure storage usage (credentials, tokens)
332
+
- Validate input sanitization (XSS prevention)
333
+
- Audit network request security (HTTPS enforcement)
334
+
- Review dependency vulnerabilities
125
335
- [ ] Moderation basics:
126
-
- respect labels/filters returned by AppView/PDS (foundation)
127
-
- [ ] Observability hooks:
128
-
- [x] structured logging
129
-
- [ ] crash reporting integration points
336
+
- Respect labels/filters returned by AppView/PDS
337
+
- Hide/blur content based on moderation labels
338
+
- User-configurable content filters
339
+
- Block/mute functionality
340
+
341
+
Phase 6: Documentation and Polish
342
+
- [ ] Documentation completion:
343
+
- Architecture documentation (doc/architecture.md)
344
+
- Error handling patterns (doc/error-handling.md)
345
+
- Database migration guide (doc/database-migrations.md)
346
+
- Testing guide updates (doc/testing.md)
347
+
- API response schema documentation
348
+
- [ ] Code cleanup:
349
+
- Remove any remaining dead code
350
+
- Ensure all files are tracked and used
351
+
- Verify clean file system (no orphaned files)
352
+
- Final linting pass with strict analysis options
130
353
131
354
Tests:
132
-
- [ ] Scroll perf sanity test on mid-range Android emulator profile.
133
-
- [ ] A11y audit checklist pass.
355
+
- [ ] Scroll perf sanity test on mid-range Android emulator profile
356
+
- [ ] iOS scroll performance validation
357
+
- [ ] A11y audit checklist pass
358
+
- [ ] Security audit checklist pass
359
+
- [ ] Database migration tests (upgrade, rollback, data preservation)
360
+
- [ ] Error handling integration tests
361
+
- [ ] Memory leak detection tests
362
+
- [ ] Network failure resilience tests
134
363
135
364
Deliverables:
136
-
- Mobile app feels stable and responsive for daily use.
365
+
- [ ] Mobile app feels stable and responsive for daily use
366
+
- [ ] All features follow consistent architecture patterns
367
+
- [ ] Database migration strategy ready for production
368
+
- [ ] Type-safe codebase with minimal unsafe casts
369
+
- [ ] Comprehensive error handling with user-friendly messages
370
+
- [ ] Security best practices validated
371
+
- [ ] Accessibility requirements met (WCAG AA)
372
+
- [ ] Performance benchmarks passing
373
+
- [ ] Documentation complete and current
137
374
138
375
================================================================================
139
376
Initial Release (Lazurite-0.1.0-alpha)
+6
-5
lib/src/features/notifications/application/notifications_notifier.dart
+6
-5
lib/src/features/notifications/application/notifications_notifier.dart
···
4
4
import '../../../core/utils/logger_provider.dart';
5
5
import '../../../features/auth/application/auth_providers.dart';
6
6
import '../../../features/auth/domain/auth_state.dart';
7
-
import '../domain/notification.dart';
7
+
import '../domain/grouped_notification.dart';
8
8
import 'notifications_providers.dart';
9
9
10
10
part 'notifications_notifier.g.dart';
···
12
12
/// Notifier for managing notification list state.
13
13
///
14
14
/// Watches the notifications stream and provides methods for
15
-
/// refresh and load more pagination.
15
+
/// refresh and load more pagination. Returns grouped notifications
16
+
/// for compact display.
16
17
@riverpod
17
18
class NotificationsNotifier extends _$NotificationsNotifier {
18
19
Logger get _logger => ref.read(loggerProvider('NotificationsNotifier'));
···
20
21
bool get _isAuthenticated => ref.read(authProvider) is AuthStateAuthenticated;
21
22
22
23
@override
23
-
Stream<List<AppNotification>> build() {
24
+
Stream<List<GroupedNotification>> build() {
24
25
final repository = ref.watch(notificationsRepositoryProvider);
25
-
_logger.debug('Building notifications stream', {});
26
-
return repository.watchNotifications();
26
+
_logger.debug('Building grouped notifications stream', {});
27
+
return repository.watchNotifications().map(GroupedNotification.groupNotifications);
27
28
}
28
29
29
30
/// Refreshes notifications from the API.
+15
-11
lib/src/features/notifications/application/notifications_notifier.g.dart
+15
-11
lib/src/features/notifications/application/notifications_notifier.g.dart
···
11
11
/// Notifier for managing notification list state.
12
12
///
13
13
/// Watches the notifications stream and provides methods for
14
-
/// refresh and load more pagination.
14
+
/// refresh and load more pagination. Returns grouped notifications
15
+
/// for compact display.
15
16
16
17
@ProviderFor(NotificationsNotifier)
17
18
final notificationsProvider = NotificationsNotifierProvider._();
···
19
20
/// Notifier for managing notification list state.
20
21
///
21
22
/// Watches the notifications stream and provides methods for
22
-
/// refresh and load more pagination.
23
+
/// refresh and load more pagination. Returns grouped notifications
24
+
/// for compact display.
23
25
final class NotificationsNotifierProvider
24
-
extends $StreamNotifierProvider<NotificationsNotifier, List<AppNotification>> {
26
+
extends $StreamNotifierProvider<NotificationsNotifier, List<GroupedNotification>> {
25
27
/// Notifier for managing notification list state.
26
28
///
27
29
/// Watches the notifications stream and provides methods for
28
-
/// refresh and load more pagination.
30
+
/// refresh and load more pagination. Returns grouped notifications
31
+
/// for compact display.
29
32
NotificationsNotifierProvider._()
30
33
: super(
31
34
from: null,
···
45
48
NotificationsNotifier create() => NotificationsNotifier();
46
49
}
47
50
48
-
String _$notificationsNotifierHash() => r'4c8135e870f771092ea4d88fbdde28fd426b7d3f';
51
+
String _$notificationsNotifierHash() => r'287578fad2ea840c95e2dac8998c4763146fb8cd';
49
52
50
53
/// Notifier for managing notification list state.
51
54
///
52
55
/// Watches the notifications stream and provides methods for
53
-
/// refresh and load more pagination.
56
+
/// refresh and load more pagination. Returns grouped notifications
57
+
/// for compact display.
54
58
55
-
abstract class _$NotificationsNotifier extends $StreamNotifier<List<AppNotification>> {
56
-
Stream<List<AppNotification>> build();
59
+
abstract class _$NotificationsNotifier extends $StreamNotifier<List<GroupedNotification>> {
60
+
Stream<List<GroupedNotification>> build();
57
61
@$mustCallSuper
58
62
@override
59
63
void runBuild() {
60
-
final ref = this.ref as $Ref<AsyncValue<List<AppNotification>>, List<AppNotification>>;
64
+
final ref = this.ref as $Ref<AsyncValue<List<GroupedNotification>>, List<GroupedNotification>>;
61
65
final element =
62
66
ref.element
63
67
as $ClassProviderElement<
64
-
AnyNotifier<AsyncValue<List<AppNotification>>, List<AppNotification>>,
65
-
AsyncValue<List<AppNotification>>,
68
+
AnyNotifier<AsyncValue<List<GroupedNotification>>, List<GroupedNotification>>,
69
+
AsyncValue<List<GroupedNotification>>,
66
70
Object?,
67
71
Object?
68
72
>;
+127
lib/src/features/notifications/domain/grouped_notification.dart
+127
lib/src/features/notifications/domain/grouped_notification.dart
···
1
+
import '../../../infrastructure/db/app_database.dart';
2
+
import 'notification.dart';
3
+
import 'notification_type.dart';
4
+
5
+
/// Represents a group of similar notifications for compact display.
6
+
///
7
+
/// Groups notifications of the same type, on the same subject,
8
+
/// within a 24-hour window.
9
+
class GroupedNotification {
10
+
GroupedNotification({
11
+
required this.type,
12
+
required this.actors,
13
+
required this.subjectUri,
14
+
required this.mostRecentTimestamp,
15
+
required this.hasUnread,
16
+
required this.totalCount,
17
+
required this.notifications,
18
+
});
19
+
20
+
/// The notification type shared by all in this group.
21
+
final NotificationType type;
22
+
23
+
/// Actors who triggered notifications in this group.
24
+
final List<Profile> actors;
25
+
26
+
/// URI of the subject (post/profile) this group is about.
27
+
final String? subjectUri;
28
+
29
+
/// Timestamp of the most recent notification in the group.
30
+
final DateTime mostRecentTimestamp;
31
+
32
+
/// Whether any notification in this group is unread.
33
+
final bool hasUnread;
34
+
35
+
/// Total number of notifications in this group.
36
+
final int totalCount;
37
+
38
+
/// All underlying notifications in this group.
39
+
final List<AppNotification> notifications;
40
+
41
+
/// Maximum number of actors to display inline.
42
+
static const int maxDisplayActors = 5;
43
+
44
+
/// Maximum time difference for grouping (24 hours).
45
+
static const Duration groupingWindow = Duration(hours: 24);
46
+
47
+
/// Returns a formatted display text for this group.
48
+
///
49
+
/// Examples:
50
+
/// - Single actor: "Alice liked your post"
51
+
/// - Two actors: "Alice and Bob liked your post"
52
+
/// - Many actors: "Alice, Bob and 3 others liked your post"
53
+
String get displayText {
54
+
if (actors.isEmpty) return type.displayText;
55
+
56
+
final names = actors.take(2).map((a) => a.displayName ?? a.handle).toList();
57
+
final remaining = totalCount - names.length;
58
+
59
+
if (totalCount == 1) {
60
+
return '${names[0]} ${type.displayText}';
61
+
} else if (totalCount == 2) {
62
+
return '${names[0]} and ${names[1]} ${type.displayText}';
63
+
} else {
64
+
return '${names[0]}, ${names[1]} and $remaining others ${type.displayText}';
65
+
}
66
+
}
67
+
68
+
/// Groups a list of notifications by type and subject within 24 hours.
69
+
///
70
+
/// Returns a list of [GroupedNotification] sorted by most recent timestamp.
71
+
static List<GroupedNotification> groupNotifications(List<AppNotification> notifications) {
72
+
if (notifications.isEmpty) return [];
73
+
74
+
final sorted = [...notifications]..sort((a, b) => b.indexedAt.compareTo(a.indexedAt));
75
+
76
+
final groups = <GroupedNotification>[];
77
+
var currentGroup = <AppNotification>[sorted.first];
78
+
var currentType = sorted.first.type;
79
+
var currentSubject = sorted.first.reasonSubjectUri;
80
+
var groupStartTime = sorted.first.indexedAt;
81
+
82
+
for (var i = 1; i < sorted.length; i++) {
83
+
final notification = sorted[i];
84
+
final timeDiff = groupStartTime.difference(notification.indexedAt);
85
+
final sameType = notification.type == currentType;
86
+
final sameSubject = notification.reasonSubjectUri == currentSubject;
87
+
final withinWindow = timeDiff <= groupingWindow;
88
+
89
+
if (sameType && sameSubject && withinWindow) {
90
+
currentGroup.add(notification);
91
+
} else {
92
+
groups.add(_createGroup(currentGroup));
93
+
currentGroup = [notification];
94
+
currentType = notification.type;
95
+
currentSubject = notification.reasonSubjectUri;
96
+
groupStartTime = notification.indexedAt;
97
+
}
98
+
}
99
+
100
+
if (currentGroup.isNotEmpty) {
101
+
groups.add(_createGroup(currentGroup));
102
+
}
103
+
104
+
return groups;
105
+
}
106
+
107
+
static GroupedNotification _createGroup(List<AppNotification> notifications) {
108
+
final seenDids = <String>{};
109
+
final uniqueActors = <Profile>[];
110
+
for (final n in notifications) {
111
+
if (!seenDids.contains(n.actor.did)) {
112
+
seenDids.add(n.actor.did);
113
+
uniqueActors.add(n.actor);
114
+
}
115
+
}
116
+
117
+
return GroupedNotification(
118
+
type: notifications.first.type,
119
+
actors: uniqueActors,
120
+
subjectUri: notifications.first.reasonSubjectUri,
121
+
mostRecentTimestamp: notifications.first.indexedAt,
122
+
hasUnread: notifications.any((n) => !n.isRead),
123
+
totalCount: uniqueActors.length,
124
+
notifications: notifications,
125
+
);
126
+
}
127
+
}
+12
-4
lib/src/features/notifications/presentation/notifications_screen.dart
+12
-4
lib/src/features/notifications/presentation/notifications_screen.dart
···
3
3
import 'package:flutter_riverpod/flutter_riverpod.dart';
4
4
import 'package:lazurite/src/core/animations/animation_utils.dart';
5
5
import 'package:lazurite/src/core/widgets/error_view.dart';
6
-
import 'package:lazurite/src/core/widgets/loading_view.dart';
7
6
import 'package:lazurite/src/core/widgets/pull_to_refresh_wrapper.dart';
8
7
import 'package:lazurite/src/features/auth/application/auth_providers.dart';
9
8
import 'package:lazurite/src/features/auth/domain/auth_state.dart';
10
9
import 'package:lazurite/src/features/notifications/application/notifications_notifier.dart';
10
+
import 'package:lazurite/src/features/notifications/presentation/widgets/notification_list_item_skeleton.dart';
11
11
12
-
import 'widgets/notification_list_item.dart';
12
+
import 'widgets/grouped_notification_item.dart';
13
13
14
14
/// Notifications screen displaying the user's notifications.
15
15
///
···
102
102
delegate: SliverChildBuilderDelegate((context, index) {
103
103
return AnimatedItem(
104
104
index: index,
105
-
child: NotificationListItem(notification: notifications[index]),
105
+
child: GroupedNotificationItem(group: notifications[index]),
106
106
);
107
107
}, childCount: notifications.length),
108
108
),
···
111
111
),
112
112
);
113
113
},
114
-
loading: () => const LoadingView(key: ValueKey('loading')),
114
+
loading: () => Scaffold(
115
+
key: const ValueKey('loading'),
116
+
appBar: AppBar(title: const Text('Notifications')),
117
+
body: ListView.builder(
118
+
physics: const NeverScrollableScrollPhysics(),
119
+
itemCount: 5,
120
+
itemBuilder: (context, index) => const NotificationListItemSkeleton(),
121
+
),
122
+
),
115
123
error: (err, stack) => ErrorView(
116
124
key: const ValueKey('error'),
117
125
title: 'Failed to load notifications',
+233
lib/src/features/notifications/presentation/widgets/grouped_notification_item.dart
+233
lib/src/features/notifications/presentation/widgets/grouped_notification_item.dart
···
1
+
import 'package:flutter/material.dart';
2
+
import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+
import 'package:go_router/go_router.dart';
4
+
import 'package:lazurite/src/core/utils/date_formatter.dart';
5
+
import 'package:lazurite/src/core/widgets/widgets.dart';
6
+
import 'package:lazurite/src/features/notifications/application/notifications_providers.dart';
7
+
import 'package:lazurite/src/features/notifications/domain/grouped_notification.dart';
8
+
import 'package:lazurite/src/features/notifications/domain/notification_type.dart';
9
+
10
+
import 'notification_type_icon.dart';
11
+
12
+
/// A widget displaying a grouped notification with expand/collapse.
13
+
///
14
+
/// Shows multiple actors who performed the same action (e.g., "Alice, Bob
15
+
/// and 3 others liked your post") with expandable actor list.
16
+
class GroupedNotificationItem extends ConsumerStatefulWidget {
17
+
const GroupedNotificationItem({required this.group, this.onTap, super.key});
18
+
19
+
/// The grouped notification to display.
20
+
final GroupedNotification group;
21
+
22
+
/// Optional tap callback. If not provided, navigates to subject content.
23
+
final VoidCallback? onTap;
24
+
25
+
@override
26
+
ConsumerState<GroupedNotificationItem> createState() => _GroupedNotificationItemState();
27
+
}
28
+
29
+
class _GroupedNotificationItemState extends ConsumerState<GroupedNotificationItem> {
30
+
bool _isExpanded = false;
31
+
32
+
@override
33
+
Widget build(BuildContext context) {
34
+
final theme = Theme.of(context);
35
+
final colorScheme = theme.colorScheme;
36
+
final group = widget.group;
37
+
38
+
final backgroundColor = group.hasUnread ? colorScheme.surfaceContainerHighest : null;
39
+
40
+
return Card(
41
+
clipBehavior: Clip.antiAlias,
42
+
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
43
+
elevation: 1,
44
+
color: backgroundColor,
45
+
child: InkWell(
46
+
onTap: widget.onTap ?? () => _handleTap(context),
47
+
child: Column(
48
+
crossAxisAlignment: CrossAxisAlignment.start,
49
+
children: [
50
+
Padding(
51
+
padding: const EdgeInsets.all(12),
52
+
child: Row(
53
+
crossAxisAlignment: CrossAxisAlignment.start,
54
+
children: [
55
+
_buildAvatarStack(group),
56
+
const SizedBox(width: 12),
57
+
Expanded(
58
+
child: Column(
59
+
crossAxisAlignment: CrossAxisAlignment.start,
60
+
children: [
61
+
Text(
62
+
group.displayText,
63
+
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
64
+
),
65
+
const SizedBox(height: 4),
66
+
Row(
67
+
children: [
68
+
NotificationTypeIcon(type: group.type, size: 16),
69
+
const SizedBox(width: 6),
70
+
Text(
71
+
_getSubjectPreview(group),
72
+
style: theme.textTheme.bodySmall?.copyWith(
73
+
color: colorScheme.onSurfaceVariant,
74
+
),
75
+
maxLines: 1,
76
+
overflow: TextOverflow.ellipsis,
77
+
),
78
+
],
79
+
),
80
+
],
81
+
),
82
+
),
83
+
const SizedBox(width: 8),
84
+
Column(
85
+
crossAxisAlignment: CrossAxisAlignment.end,
86
+
children: [
87
+
Text(
88
+
DateFormatter.formatRelative(group.mostRecentTimestamp),
89
+
style: theme.textTheme.bodySmall?.copyWith(
90
+
color: colorScheme.onSurfaceVariant,
91
+
),
92
+
),
93
+
if (group.totalCount > 1) ...[
94
+
const SizedBox(height: 4),
95
+
GestureDetector(
96
+
onTap: _toggleExpanded,
97
+
child: Icon(
98
+
_isExpanded ? Icons.expand_less : Icons.expand_more,
99
+
size: 20,
100
+
color: colorScheme.onSurfaceVariant,
101
+
),
102
+
),
103
+
],
104
+
],
105
+
),
106
+
],
107
+
),
108
+
),
109
+
if (_isExpanded && group.totalCount > 1) _buildExpandedActorList(context, group),
110
+
],
111
+
),
112
+
),
113
+
);
114
+
}
115
+
116
+
Widget _buildAvatarStack(GroupedNotification group) {
117
+
if (group.actors.isEmpty) {
118
+
return const SizedBox(width: 40, height: 40);
119
+
}
120
+
121
+
if (group.actors.length == 1) {
122
+
return Avatar(imageUrl: group.actors.first.avatar, radius: 20);
123
+
}
124
+
125
+
final displayActors = group.actors.take(3).toList();
126
+
return SizedBox(
127
+
width: 40 + (displayActors.length - 1) * 10,
128
+
height: 40,
129
+
child: Stack(
130
+
children: [
131
+
for (var i = displayActors.length - 1; i >= 0; i--)
132
+
Positioned(
133
+
left: i * 10.0,
134
+
child: Container(
135
+
decoration: BoxDecoration(
136
+
shape: BoxShape.circle,
137
+
border: Border.all(color: Theme.of(context).colorScheme.surface, width: 2),
138
+
),
139
+
child: Avatar(imageUrl: displayActors[i].avatar, radius: 16),
140
+
),
141
+
),
142
+
],
143
+
),
144
+
);
145
+
}
146
+
147
+
Widget _buildExpandedActorList(BuildContext context, GroupedNotification group) {
148
+
final theme = Theme.of(context);
149
+
final colorScheme = theme.colorScheme;
150
+
151
+
return Container(
152
+
decoration: BoxDecoration(
153
+
color: colorScheme.surfaceContainerLow,
154
+
border: Border(top: BorderSide(color: colorScheme.outlineVariant.withAlpha(51))),
155
+
),
156
+
child: Column(
157
+
children: [
158
+
for (final actor in group.actors)
159
+
InkWell(
160
+
onTap: () => _navigateToProfile(context, actor.did),
161
+
child: Padding(
162
+
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
163
+
child: Row(
164
+
children: [
165
+
Avatar(imageUrl: actor.avatar, radius: 16),
166
+
const SizedBox(width: 12),
167
+
Expanded(
168
+
child: Column(
169
+
crossAxisAlignment: CrossAxisAlignment.start,
170
+
children: [
171
+
Text(
172
+
actor.displayName ?? actor.handle,
173
+
style: theme.textTheme.bodyMedium,
174
+
overflow: TextOverflow.ellipsis,
175
+
),
176
+
Text(
177
+
'@${actor.handle}',
178
+
style: theme.textTheme.bodySmall?.copyWith(
179
+
color: colorScheme.onSurfaceVariant,
180
+
),
181
+
overflow: TextOverflow.ellipsis,
182
+
),
183
+
],
184
+
),
185
+
),
186
+
],
187
+
),
188
+
),
189
+
),
190
+
],
191
+
),
192
+
);
193
+
}
194
+
195
+
String _getSubjectPreview(GroupedNotification group) {
196
+
if (group.type == NotificationType.follow) {
197
+
return '';
198
+
}
199
+
return group.subjectUri != null ? 'View post' : '';
200
+
}
201
+
202
+
void _toggleExpanded() {
203
+
setState(() {
204
+
_isExpanded = !_isExpanded;
205
+
});
206
+
}
207
+
208
+
void _handleTap(BuildContext context) {
209
+
final group = widget.group;
210
+
211
+
if (group.type == NotificationType.follow) {
212
+
if (group.actors.isNotEmpty) {
213
+
_navigateToProfile(context, group.actors.first.did);
214
+
}
215
+
return;
216
+
}
217
+
218
+
if (group.subjectUri != null) {
219
+
final service = ref.read(markAsSeenServiceProvider);
220
+
service.markAsSeen(group.mostRecentTimestamp);
221
+
222
+
final encodedUri = Uri.encodeComponent(group.subjectUri!);
223
+
GoRouter.of(context).push('/home/t/$encodedUri');
224
+
} else if (group.actors.isNotEmpty) {
225
+
_navigateToProfile(context, group.actors.first.did);
226
+
}
227
+
}
228
+
229
+
void _navigateToProfile(BuildContext context, String did) {
230
+
final encodedDid = Uri.encodeComponent(did);
231
+
GoRouter.of(context).push('/home/u/$encodedDid');
232
+
}
233
+
}
+164
lib/src/features/notifications/presentation/widgets/notification_list_item_skeleton.dart
+164
lib/src/features/notifications/presentation/widgets/notification_list_item_skeleton.dart
···
1
+
import 'package:flutter/material.dart';
2
+
3
+
/// A shimmer loading skeleton widget for notification list items.
4
+
///
5
+
/// Displays animated placeholder boxes mimicking the layout of a
6
+
/// notification item, providing visual feedback during content loading.
7
+
class NotificationListItemSkeleton extends StatefulWidget {
8
+
const NotificationListItemSkeleton({super.key});
9
+
10
+
@override
11
+
State<NotificationListItemSkeleton> createState() => _NotificationListItemSkeletonState();
12
+
}
13
+
14
+
class _NotificationListItemSkeletonState extends State<NotificationListItemSkeleton>
15
+
with SingleTickerProviderStateMixin {
16
+
late final AnimationController _controller;
17
+
late final Animation<double> _animation;
18
+
19
+
@override
20
+
void initState() {
21
+
super.initState();
22
+
_controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 1500))
23
+
..repeat();
24
+
_animation = Tween<double>(
25
+
begin: -1,
26
+
end: 2,
27
+
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOutSine));
28
+
}
29
+
30
+
@override
31
+
void dispose() {
32
+
_controller.dispose();
33
+
super.dispose();
34
+
}
35
+
36
+
@override
37
+
Widget build(BuildContext context) {
38
+
final theme = Theme.of(context);
39
+
final shimmerBase = theme.colorScheme.surfaceContainerHighest;
40
+
final shimmerHighlight = theme.colorScheme.surface;
41
+
42
+
return AnimatedBuilder(
43
+
animation: _animation,
44
+
builder: (context, child) {
45
+
return Card(
46
+
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
47
+
elevation: 1,
48
+
child: Padding(
49
+
padding: const EdgeInsets.all(12),
50
+
child: Row(
51
+
crossAxisAlignment: CrossAxisAlignment.start,
52
+
children: [
53
+
_ShimmerBox(
54
+
width: 40,
55
+
height: 40,
56
+
borderRadius: 20,
57
+
animation: _animation,
58
+
baseColor: shimmerBase,
59
+
highlightColor: shimmerHighlight,
60
+
),
61
+
const SizedBox(width: 12),
62
+
Expanded(
63
+
child: Column(
64
+
crossAxisAlignment: CrossAxisAlignment.start,
65
+
children: [
66
+
_ShimmerBox(
67
+
width: 120,
68
+
height: 14,
69
+
borderRadius: 4,
70
+
animation: _animation,
71
+
baseColor: shimmerBase,
72
+
highlightColor: shimmerHighlight,
73
+
),
74
+
const SizedBox(height: 6),
75
+
_ShimmerBox(
76
+
width: 80,
77
+
height: 12,
78
+
borderRadius: 4,
79
+
animation: _animation,
80
+
baseColor: shimmerBase,
81
+
highlightColor: shimmerHighlight,
82
+
),
83
+
const SizedBox(height: 8),
84
+
Row(
85
+
children: [
86
+
_ShimmerBox(
87
+
width: 16,
88
+
height: 16,
89
+
borderRadius: 4,
90
+
animation: _animation,
91
+
baseColor: shimmerBase,
92
+
highlightColor: shimmerHighlight,
93
+
),
94
+
const SizedBox(width: 6),
95
+
_ShimmerBox(
96
+
width: 100,
97
+
height: 12,
98
+
borderRadius: 4,
99
+
animation: _animation,
100
+
baseColor: shimmerBase,
101
+
highlightColor: shimmerHighlight,
102
+
),
103
+
],
104
+
),
105
+
],
106
+
),
107
+
),
108
+
const SizedBox(width: 8),
109
+
_ShimmerBox(
110
+
width: 30,
111
+
height: 12,
112
+
borderRadius: 4,
113
+
animation: _animation,
114
+
baseColor: shimmerBase,
115
+
highlightColor: shimmerHighlight,
116
+
),
117
+
],
118
+
),
119
+
),
120
+
);
121
+
},
122
+
);
123
+
}
124
+
}
125
+
126
+
/// A single shimmer effect box used in skeleton screens.
127
+
class _ShimmerBox extends StatelessWidget {
128
+
const _ShimmerBox({
129
+
required this.width,
130
+
required this.height,
131
+
required this.borderRadius,
132
+
required this.animation,
133
+
required this.baseColor,
134
+
required this.highlightColor,
135
+
});
136
+
137
+
final double width;
138
+
final double height;
139
+
final double borderRadius;
140
+
final Animation<double> animation;
141
+
final Color baseColor;
142
+
final Color highlightColor;
143
+
144
+
@override
145
+
Widget build(BuildContext context) {
146
+
return Container(
147
+
width: width,
148
+
height: height,
149
+
decoration: BoxDecoration(
150
+
borderRadius: BorderRadius.circular(borderRadius),
151
+
gradient: LinearGradient(
152
+
begin: Alignment.centerLeft,
153
+
end: Alignment.centerRight,
154
+
colors: [baseColor, highlightColor, baseColor],
155
+
stops: [
156
+
(animation.value - 0.3).clamp(0.0, 1.0),
157
+
animation.value.clamp(0.0, 1.0),
158
+
(animation.value + 0.3).clamp(0.0, 1.0),
159
+
],
160
+
),
161
+
),
162
+
);
163
+
}
164
+
}
+331
test/src/features/notifications/domain/grouped_notification_test.dart
+331
test/src/features/notifications/domain/grouped_notification_test.dart
···
1
+
import 'package:flutter_test/flutter_test.dart';
2
+
import 'package:lazurite/src/features/notifications/domain/grouped_notification.dart';
3
+
import 'package:lazurite/src/features/notifications/domain/notification.dart';
4
+
import 'package:lazurite/src/features/notifications/domain/notification_type.dart';
5
+
import 'package:lazurite/src/infrastructure/db/app_database.dart';
6
+
7
+
Profile _createProfile(String did, String handle, {String? displayName}) {
8
+
return Profile(
9
+
did: did,
10
+
handle: handle,
11
+
displayName: displayName,
12
+
description: null,
13
+
avatar: null,
14
+
banner: null,
15
+
indexedAt: null,
16
+
pronouns: null,
17
+
website: null,
18
+
createdAt: null,
19
+
verificationStatus: null,
20
+
labels: null,
21
+
pinnedPostUri: null,
22
+
);
23
+
}
24
+
25
+
AppNotification _createNotification({
26
+
required String uri,
27
+
required String actorDid,
28
+
required NotificationType type,
29
+
required DateTime indexedAt,
30
+
String? reasonSubjectUri,
31
+
bool isRead = false,
32
+
}) {
33
+
return AppNotification(
34
+
uri: uri,
35
+
actor: _createProfile(actorDid, '$actorDid.bsky', displayName: actorDid),
36
+
type: type,
37
+
reasonSubjectUri: reasonSubjectUri,
38
+
indexedAt: indexedAt,
39
+
isRead: isRead,
40
+
);
41
+
}
42
+
43
+
void main() {
44
+
group('GroupedNotification', () {
45
+
group('groupNotifications', () {
46
+
test('returns empty list for empty input', () {
47
+
final result = GroupedNotification.groupNotifications([]);
48
+
expect(result, isEmpty);
49
+
});
50
+
51
+
test('single notification returns single group', () {
52
+
final notification = _createNotification(
53
+
uri: 'at://did:plc:1/notif/1',
54
+
actorDid: 'alice',
55
+
type: NotificationType.like,
56
+
indexedAt: DateTime(2026, 1, 8, 12, 0),
57
+
reasonSubjectUri: 'at://did:plc:user/post/1',
58
+
);
59
+
60
+
final result = GroupedNotification.groupNotifications([notification]);
61
+
62
+
expect(result.length, 1);
63
+
expect(result.first.type, NotificationType.like);
64
+
expect(result.first.totalCount, 1);
65
+
expect(result.first.actors.length, 1);
66
+
});
67
+
68
+
test('groups notifications with same type and subject within 24h', () {
69
+
final baseTime = DateTime(2026, 1, 8, 12, 0);
70
+
final notifications = [
71
+
_createNotification(
72
+
uri: 'at://did:plc:1/notif/1',
73
+
actorDid: 'alice',
74
+
type: NotificationType.like,
75
+
indexedAt: baseTime,
76
+
reasonSubjectUri: 'at://did:plc:user/post/1',
77
+
),
78
+
_createNotification(
79
+
uri: 'at://did:plc:1/notif/2',
80
+
actorDid: 'bob',
81
+
type: NotificationType.like,
82
+
indexedAt: baseTime.subtract(const Duration(hours: 2)),
83
+
reasonSubjectUri: 'at://did:plc:user/post/1',
84
+
),
85
+
_createNotification(
86
+
uri: 'at://did:plc:1/notif/3',
87
+
actorDid: 'charlie',
88
+
type: NotificationType.like,
89
+
indexedAt: baseTime.subtract(const Duration(hours: 4)),
90
+
reasonSubjectUri: 'at://did:plc:user/post/1',
91
+
),
92
+
];
93
+
94
+
final result = GroupedNotification.groupNotifications(notifications);
95
+
96
+
expect(result.length, 1);
97
+
expect(result.first.totalCount, 3);
98
+
expect(result.first.actors.length, 3);
99
+
});
100
+
101
+
test('separates groups with different notification types', () {
102
+
final baseTime = DateTime(2026, 1, 8, 12, 0);
103
+
final notifications = [
104
+
_createNotification(
105
+
uri: 'at://did:plc:1/notif/1',
106
+
actorDid: 'alice',
107
+
type: NotificationType.like,
108
+
indexedAt: baseTime,
109
+
reasonSubjectUri: 'at://did:plc:user/post/1',
110
+
),
111
+
_createNotification(
112
+
uri: 'at://did:plc:1/notif/2',
113
+
actorDid: 'bob',
114
+
type: NotificationType.repost,
115
+
indexedAt: baseTime.subtract(const Duration(hours: 1)),
116
+
reasonSubjectUri: 'at://did:plc:user/post/1',
117
+
),
118
+
];
119
+
120
+
final result = GroupedNotification.groupNotifications(notifications);
121
+
122
+
expect(result.length, 2);
123
+
expect(result[0].type, NotificationType.like);
124
+
expect(result[1].type, NotificationType.repost);
125
+
});
126
+
127
+
test('separates groups with different subject URIs', () {
128
+
final baseTime = DateTime(2026, 1, 8, 12, 0);
129
+
final notifications = [
130
+
_createNotification(
131
+
uri: 'at://did:plc:1/notif/1',
132
+
actorDid: 'alice',
133
+
type: NotificationType.like,
134
+
indexedAt: baseTime,
135
+
reasonSubjectUri: 'at://did:plc:user/post/1',
136
+
),
137
+
_createNotification(
138
+
uri: 'at://did:plc:1/notif/2',
139
+
actorDid: 'bob',
140
+
type: NotificationType.like,
141
+
indexedAt: baseTime.subtract(const Duration(hours: 1)),
142
+
reasonSubjectUri: 'at://did:plc:user/post/2',
143
+
),
144
+
];
145
+
146
+
final result = GroupedNotification.groupNotifications(notifications);
147
+
148
+
expect(result.length, 2);
149
+
expect(result[0].subjectUri, 'at://did:plc:user/post/1');
150
+
expect(result[1].subjectUri, 'at://did:plc:user/post/2');
151
+
});
152
+
153
+
test('separates groups exceeding 24h window', () {
154
+
final baseTime = DateTime(2026, 1, 8, 12, 0);
155
+
final notifications = [
156
+
_createNotification(
157
+
uri: 'at://did:plc:1/notif/1',
158
+
actorDid: 'alice',
159
+
type: NotificationType.like,
160
+
indexedAt: baseTime,
161
+
reasonSubjectUri: 'at://did:plc:user/post/1',
162
+
),
163
+
_createNotification(
164
+
uri: 'at://did:plc:1/notif/2',
165
+
actorDid: 'bob',
166
+
type: NotificationType.like,
167
+
indexedAt: baseTime.subtract(const Duration(hours: 25)),
168
+
reasonSubjectUri: 'at://did:plc:user/post/1',
169
+
),
170
+
];
171
+
172
+
final result = GroupedNotification.groupNotifications(notifications);
173
+
174
+
expect(result.length, 2);
175
+
});
176
+
177
+
test('tracks hasUnread correctly', () {
178
+
final baseTime = DateTime(2026, 1, 8, 12, 0);
179
+
final notifications = [
180
+
_createNotification(
181
+
uri: 'at://did:plc:1/notif/1',
182
+
actorDid: 'alice',
183
+
type: NotificationType.like,
184
+
indexedAt: baseTime,
185
+
reasonSubjectUri: 'at://did:plc:user/post/1',
186
+
isRead: true,
187
+
),
188
+
_createNotification(
189
+
uri: 'at://did:plc:1/notif/2',
190
+
actorDid: 'bob',
191
+
type: NotificationType.like,
192
+
indexedAt: baseTime.subtract(const Duration(hours: 1)),
193
+
reasonSubjectUri: 'at://did:plc:user/post/1',
194
+
isRead: false,
195
+
),
196
+
];
197
+
198
+
final result = GroupedNotification.groupNotifications(notifications);
199
+
200
+
expect(result.first.hasUnread, true);
201
+
});
202
+
203
+
test('deduplicates actors by DID', () {
204
+
final baseTime = DateTime(2026, 1, 8, 12, 0);
205
+
final notifications = [
206
+
_createNotification(
207
+
uri: 'at://did:plc:1/notif/1',
208
+
actorDid: 'alice',
209
+
type: NotificationType.like,
210
+
indexedAt: baseTime,
211
+
reasonSubjectUri: 'at://did:plc:user/post/1',
212
+
),
213
+
_createNotification(
214
+
uri: 'at://did:plc:1/notif/2',
215
+
actorDid: 'alice',
216
+
type: NotificationType.like,
217
+
indexedAt: baseTime.subtract(const Duration(hours: 1)),
218
+
reasonSubjectUri: 'at://did:plc:user/post/1',
219
+
),
220
+
];
221
+
222
+
final result = GroupedNotification.groupNotifications(notifications);
223
+
224
+
expect(result.first.actors.length, 1);
225
+
expect(result.first.totalCount, 1);
226
+
expect(result.first.notifications.length, 2);
227
+
});
228
+
229
+
test('preserves most recent timestamp', () {
230
+
final baseTime = DateTime(2026, 1, 8, 12, 0);
231
+
final notifications = [
232
+
_createNotification(
233
+
uri: 'at://did:plc:1/notif/1',
234
+
actorDid: 'alice',
235
+
type: NotificationType.like,
236
+
indexedAt: baseTime.subtract(const Duration(hours: 2)),
237
+
reasonSubjectUri: 'at://did:plc:user/post/1',
238
+
),
239
+
_createNotification(
240
+
uri: 'at://did:plc:1/notif/2',
241
+
actorDid: 'bob',
242
+
type: NotificationType.like,
243
+
indexedAt: baseTime,
244
+
reasonSubjectUri: 'at://did:plc:user/post/1',
245
+
),
246
+
];
247
+
248
+
final result = GroupedNotification.groupNotifications(notifications);
249
+
250
+
expect(result.first.mostRecentTimestamp, baseTime);
251
+
});
252
+
});
253
+
254
+
group('displayText', () {
255
+
test('single actor returns formatted text', () {
256
+
final group = GroupedNotification(
257
+
type: NotificationType.like,
258
+
actors: [_createProfile('alice', 'alice.bsky', displayName: 'Alice')],
259
+
subjectUri: 'at://did:plc:user/post/1',
260
+
mostRecentTimestamp: DateTime.now(),
261
+
hasUnread: false,
262
+
totalCount: 1,
263
+
notifications: [],
264
+
);
265
+
266
+
expect(group.displayText, 'Alice liked your post');
267
+
});
268
+
269
+
test('two actors returns "and" format', () {
270
+
final group = GroupedNotification(
271
+
type: NotificationType.repost,
272
+
actors: [
273
+
_createProfile('alice', 'alice.bsky', displayName: 'Alice'),
274
+
_createProfile('bob', 'bob.bsky', displayName: 'Bob'),
275
+
],
276
+
subjectUri: 'at://did:plc:user/post/1',
277
+
mostRecentTimestamp: DateTime.now(),
278
+
hasUnread: false,
279
+
totalCount: 2,
280
+
notifications: [],
281
+
);
282
+
283
+
expect(group.displayText, 'Alice and Bob reposted your post');
284
+
});
285
+
286
+
test('many actors returns "and N others" format', () {
287
+
final group = GroupedNotification(
288
+
type: NotificationType.like,
289
+
actors: [
290
+
_createProfile('alice', 'alice.bsky', displayName: 'Alice'),
291
+
_createProfile('bob', 'bob.bsky', displayName: 'Bob'),
292
+
_createProfile('charlie', 'charlie.bsky', displayName: 'Charlie'),
293
+
_createProfile('dave', 'dave.bsky', displayName: 'Dave'),
294
+
_createProfile('eve', 'eve.bsky', displayName: 'Eve'),
295
+
],
296
+
subjectUri: 'at://did:plc:user/post/1',
297
+
mostRecentTimestamp: DateTime.now(),
298
+
hasUnread: false,
299
+
totalCount: 5,
300
+
notifications: [],
301
+
);
302
+
303
+
expect(group.displayText, 'Alice, Bob and 3 others liked your post');
304
+
});
305
+
306
+
test('uses handle when displayName is null', () {
307
+
final group = GroupedNotification(
308
+
type: NotificationType.follow,
309
+
actors: [_createProfile('alice', 'alice.bsky')],
310
+
subjectUri: null,
311
+
mostRecentTimestamp: DateTime.now(),
312
+
hasUnread: false,
313
+
totalCount: 1,
314
+
notifications: [],
315
+
);
316
+
317
+
expect(group.displayText, 'alice.bsky followed you');
318
+
});
319
+
});
320
+
321
+
group('constants', () {
322
+
test('maxDisplayActors is 5', () {
323
+
expect(GroupedNotification.maxDisplayActors, 5);
324
+
});
325
+
326
+
test('groupingWindow is 24 hours', () {
327
+
expect(GroupedNotification.groupingWindow, const Duration(hours: 24));
328
+
});
329
+
});
330
+
});
331
+
}
+2
-3
test/src/features/notifications/notifications_screen_test.dart
+2
-3
test/src/features/notifications/notifications_screen_test.dart
···
101
101
await tester.pump(const Duration(milliseconds: 100));
102
102
await tester.pump(const Duration(milliseconds: 100));
103
103
104
-
expect(find.text('alice.bsky'), findsWidgets);
105
-
expect(find.text('liked your post'), findsOneWidget);
104
+
expect(find.text('alice.bsky liked your post'), findsOneWidget);
106
105
});
107
106
108
107
testWidgets('shows sign in message when not authenticated', (tester) async {
···
118
117
await tester.pump(const Duration(milliseconds: 100));
119
118
await tester.pump(const Duration(milliseconds: 100));
120
119
121
-
expect(find.text('Notifications'), findsOneWidget);
120
+
expect(find.text('Notifications'), findsWidgets);
122
121
});
123
122
124
123
testWidgets('calls refresh on initial load', (tester) async {
+222
test/src/features/notifications/presentation/widgets/grouped_notification_item_test.dart
+222
test/src/features/notifications/presentation/widgets/grouped_notification_item_test.dart
···
1
+
import 'package:flutter/material.dart';
2
+
import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+
import 'package:flutter_test/flutter_test.dart';
4
+
import 'package:lazurite/src/features/notifications/application/mark_as_seen_service.dart';
5
+
import 'package:lazurite/src/features/notifications/application/notifications_providers.dart';
6
+
import 'package:lazurite/src/features/notifications/domain/grouped_notification.dart';
7
+
import 'package:lazurite/src/features/notifications/domain/notification_type.dart';
8
+
import 'package:lazurite/src/features/notifications/presentation/widgets/grouped_notification_item.dart';
9
+
import 'package:lazurite/src/infrastructure/db/app_database.dart';
10
+
import 'package:mocktail/mocktail.dart';
11
+
12
+
import '../../../../../helpers/mocks.dart';
13
+
14
+
class MockMarkAsSeenService extends Mock implements MarkAsSeenService {}
15
+
16
+
Profile _createProfile(String did, String handle, {String? displayName}) {
17
+
return Profile(
18
+
did: did,
19
+
handle: handle,
20
+
displayName: displayName,
21
+
description: null,
22
+
avatar: null,
23
+
banner: null,
24
+
indexedAt: null,
25
+
pronouns: null,
26
+
website: null,
27
+
createdAt: null,
28
+
verificationStatus: null,
29
+
labels: null,
30
+
pinnedPostUri: null,
31
+
);
32
+
}
33
+
34
+
void main() {
35
+
late MockMarkAsSeenService mockMarkAsSeenService;
36
+
late MockNotificationsRepository mockRepository;
37
+
38
+
Widget createSubject(GroupedNotification group, {VoidCallback? onTap}) {
39
+
return ProviderScope(
40
+
overrides: [
41
+
markAsSeenServiceProvider.overrideWithValue(mockMarkAsSeenService),
42
+
notificationsRepositoryProvider.overrideWithValue(mockRepository),
43
+
],
44
+
child: MaterialApp(
45
+
home: Scaffold(
46
+
body: GroupedNotificationItem(group: group, onTap: onTap),
47
+
),
48
+
),
49
+
);
50
+
}
51
+
52
+
setUp(() {
53
+
mockMarkAsSeenService = MockMarkAsSeenService();
54
+
mockRepository = MockNotificationsRepository();
55
+
56
+
registerFallbackValue(DateTime.now());
57
+
when(() => mockMarkAsSeenService.markAsSeen(any())).thenReturn(null);
58
+
});
59
+
60
+
group('GroupedNotificationItem', () {
61
+
testWidgets('displays single actor name', (tester) async {
62
+
final group = GroupedNotification(
63
+
type: NotificationType.like,
64
+
actors: [_createProfile('alice', 'alice.bsky', displayName: 'Alice')],
65
+
subjectUri: 'at://did:plc:user/post/1',
66
+
mostRecentTimestamp: DateTime.now(),
67
+
hasUnread: false,
68
+
totalCount: 1,
69
+
notifications: [],
70
+
);
71
+
72
+
await tester.pumpWidget(createSubject(group));
73
+
await tester.pump();
74
+
75
+
expect(find.text('Alice liked your post'), findsOneWidget);
76
+
});
77
+
78
+
testWidgets('displays "and N others" for multiple actors', (tester) async {
79
+
final group = GroupedNotification(
80
+
type: NotificationType.like,
81
+
actors: [
82
+
_createProfile('alice', 'alice.bsky', displayName: 'Alice'),
83
+
_createProfile('bob', 'bob.bsky', displayName: 'Bob'),
84
+
_createProfile('charlie', 'charlie.bsky', displayName: 'Charlie'),
85
+
],
86
+
subjectUri: 'at://did:plc:user/post/1',
87
+
mostRecentTimestamp: DateTime.now(),
88
+
hasUnread: false,
89
+
totalCount: 3,
90
+
notifications: [],
91
+
);
92
+
93
+
await tester.pumpWidget(createSubject(group));
94
+
await tester.pump();
95
+
96
+
expect(find.text('Alice, Bob and 1 others liked your post'), findsOneWidget);
97
+
});
98
+
99
+
testWidgets('shows expand icon for grouped notifications', (tester) async {
100
+
final group = GroupedNotification(
101
+
type: NotificationType.like,
102
+
actors: [
103
+
_createProfile('alice', 'alice.bsky', displayName: 'Alice'),
104
+
_createProfile('bob', 'bob.bsky', displayName: 'Bob'),
105
+
],
106
+
subjectUri: 'at://did:plc:user/post/1',
107
+
mostRecentTimestamp: DateTime.now(),
108
+
hasUnread: false,
109
+
totalCount: 2,
110
+
notifications: [],
111
+
);
112
+
113
+
await tester.pumpWidget(createSubject(group));
114
+
await tester.pump();
115
+
116
+
expect(find.byIcon(Icons.expand_more), findsOneWidget);
117
+
});
118
+
119
+
testWidgets('does not show expand icon for single notification', (tester) async {
120
+
final group = GroupedNotification(
121
+
type: NotificationType.follow,
122
+
actors: [_createProfile('alice', 'alice.bsky', displayName: 'Alice')],
123
+
subjectUri: null,
124
+
mostRecentTimestamp: DateTime.now(),
125
+
hasUnread: false,
126
+
totalCount: 1,
127
+
notifications: [],
128
+
);
129
+
130
+
await tester.pumpWidget(createSubject(group));
131
+
await tester.pump();
132
+
133
+
expect(find.byIcon(Icons.expand_more), findsNothing);
134
+
});
135
+
136
+
testWidgets('expands to show actor list on tap', (tester) async {
137
+
final group = GroupedNotification(
138
+
type: NotificationType.like,
139
+
actors: [
140
+
_createProfile('alice', 'alice.bsky', displayName: 'Alice'),
141
+
_createProfile('bob', 'bob.bsky', displayName: 'Bob'),
142
+
],
143
+
subjectUri: 'at://did:plc:user/post/1',
144
+
mostRecentTimestamp: DateTime.now(),
145
+
hasUnread: false,
146
+
totalCount: 2,
147
+
notifications: [],
148
+
);
149
+
150
+
await tester.pumpWidget(createSubject(group));
151
+
await tester.pump();
152
+
153
+
expect(find.text('@alice.bsky'), findsNothing);
154
+
expect(find.text('@bob.bsky'), findsNothing);
155
+
156
+
await tester.tap(find.byIcon(Icons.expand_more));
157
+
await tester.pumpAndSettle();
158
+
159
+
expect(find.text('@alice.bsky'), findsOneWidget);
160
+
expect(find.text('@bob.bsky'), findsOneWidget);
161
+
expect(find.byIcon(Icons.expand_less), findsOneWidget);
162
+
});
163
+
164
+
testWidgets('renders notification type icon', (tester) async {
165
+
final group = GroupedNotification(
166
+
type: NotificationType.repost,
167
+
actors: [_createProfile('alice', 'alice.bsky', displayName: 'Alice')],
168
+
subjectUri: 'at://did:plc:user/post/1',
169
+
mostRecentTimestamp: DateTime.now(),
170
+
hasUnread: false,
171
+
totalCount: 1,
172
+
notifications: [],
173
+
);
174
+
175
+
await tester.pumpWidget(createSubject(group));
176
+
await tester.pump();
177
+
178
+
expect(find.byIcon(Icons.repeat), findsOneWidget);
179
+
});
180
+
181
+
testWidgets('displays different background for unread', (tester) async {
182
+
final group = GroupedNotification(
183
+
type: NotificationType.like,
184
+
actors: [_createProfile('alice', 'alice.bsky', displayName: 'Alice')],
185
+
subjectUri: 'at://did:plc:user/post/1',
186
+
mostRecentTimestamp: DateTime.now(),
187
+
hasUnread: true,
188
+
totalCount: 1,
189
+
notifications: [],
190
+
);
191
+
192
+
await tester.pumpWidget(createSubject(group));
193
+
await tester.pump();
194
+
195
+
final cardFinder = find.byType(Card);
196
+
expect(cardFinder, findsOneWidget);
197
+
final card = tester.widget<Card>(cardFinder);
198
+
expect(card.color, isNotNull);
199
+
});
200
+
201
+
testWidgets('calls onTap callback when provided', (tester) async {
202
+
var tapped = false;
203
+
final group = GroupedNotification(
204
+
type: NotificationType.like,
205
+
actors: [_createProfile('alice', 'alice.bsky', displayName: 'Alice')],
206
+
subjectUri: 'at://did:plc:user/post/1',
207
+
mostRecentTimestamp: DateTime.now(),
208
+
hasUnread: false,
209
+
totalCount: 1,
210
+
notifications: [],
211
+
);
212
+
213
+
await tester.pumpWidget(createSubject(group, onTap: () => tapped = true));
214
+
await tester.pump();
215
+
216
+
await tester.tap(find.byType(InkWell).first);
217
+
await tester.pump();
218
+
219
+
expect(tapped, true);
220
+
});
221
+
});
222
+
}
+75
test/src/features/notifications/presentation/widgets/notification_list_item_skeleton_test.dart
+75
test/src/features/notifications/presentation/widgets/notification_list_item_skeleton_test.dart
···
1
+
import 'package:flutter/material.dart';
2
+
import 'package:flutter_test/flutter_test.dart';
3
+
import 'package:lazurite/src/features/notifications/presentation/widgets/notification_list_item_skeleton.dart';
4
+
5
+
void main() {
6
+
group('NotificationListItemSkeleton', () {
7
+
testWidgets('renders without error', (tester) async {
8
+
await tester.pumpWidget(
9
+
const MaterialApp(home: Scaffold(body: NotificationListItemSkeleton())),
10
+
);
11
+
12
+
expect(find.byType(NotificationListItemSkeleton), findsOneWidget);
13
+
});
14
+
15
+
testWidgets('contains Card widget', (tester) async {
16
+
await tester.pumpWidget(
17
+
const MaterialApp(home: Scaffold(body: NotificationListItemSkeleton())),
18
+
);
19
+
20
+
expect(find.byType(Card), findsOneWidget);
21
+
});
22
+
23
+
testWidgets('animates shimmer effect', (tester) async {
24
+
await tester.pumpWidget(
25
+
const MaterialApp(home: Scaffold(body: NotificationListItemSkeleton())),
26
+
);
27
+
28
+
await tester.pump();
29
+
30
+
await tester.pump(const Duration(milliseconds: 500));
31
+
32
+
expect(find.byType(NotificationListItemSkeleton), findsOneWidget);
33
+
});
34
+
35
+
testWidgets('disposes animation controller', (tester) async {
36
+
await tester.pumpWidget(
37
+
const MaterialApp(home: Scaffold(body: NotificationListItemSkeleton())),
38
+
);
39
+
40
+
await tester.pump();
41
+
42
+
await tester.pumpWidget(const MaterialApp(home: Scaffold()));
43
+
});
44
+
45
+
testWidgets('uses theme colors for shimmer', (tester) async {
46
+
await tester.pumpWidget(
47
+
MaterialApp(
48
+
theme: ThemeData.light(),
49
+
home: const Scaffold(body: NotificationListItemSkeleton()),
50
+
),
51
+
);
52
+
53
+
expect(find.byType(NotificationListItemSkeleton), findsOneWidget);
54
+
55
+
await tester.pumpWidget(
56
+
MaterialApp(
57
+
theme: ThemeData.dark(),
58
+
home: const Scaffold(body: NotificationListItemSkeleton()),
59
+
),
60
+
);
61
+
62
+
expect(find.byType(NotificationListItemSkeleton), findsOneWidget);
63
+
});
64
+
65
+
testWidgets('shows expected layout structure', (tester) async {
66
+
await tester.pumpWidget(
67
+
const MaterialApp(home: Scaffold(body: NotificationListItemSkeleton())),
68
+
);
69
+
70
+
expect(find.byType(Padding), findsWidgets);
71
+
expect(find.byType(Row), findsWidgets);
72
+
expect(find.byType(Column), findsWidgets);
73
+
});
74
+
});
75
+
}