mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter

feat: notification grouping

* update DM & hardening milestone

+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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 + }