high priority medium complexity database pending backend specialist Tier 0

Acceptance Criteria

getNotifications(userId, {cursor, pageSize}) returns a paginated list of AppNotification objects ordered by created_at descending
Cursor-based pagination: the first page uses no cursor; subsequent pages pass the created_at timestamp of the last item from the previous page
markAsRead(notificationId) updates the is_read flag in Supabase and emits the updated unread count via the stream
markAllRead(userId) sets is_read=true for all unread notifications for the user in a single batch update
unreadCountStream returns a Stream<int> backed by Supabase Realtime that emits a new value whenever a notification row for the user is inserted or updated
Realtime stream emits only the count integer — no PII transmitted via Realtime payload (row IDs and status only, full content fetched separately)
Repository disposes the Realtime channel subscription when the Riverpod provider is disposed
All methods return Result<T, Failure> — no raw exceptions reach the UI layer
RLS policy enforced: users can only read notifications where user_id = auth.uid()

Technical Requirements

frameworks
Flutter
Riverpod
Supabase Dart SDK
Supabase Realtime
apis
Supabase PostgreSQL REST API
Supabase Realtime WebSocket API
data models
device_token
performance requirements
First page (20 items) must load within 800ms on a 4G connection
Realtime count update must appear in the UI within 1 second of the database change
markAllRead must use a single SQL UPDATE ... WHERE user_id = ? AND is_read = false — no N+1 per-notification updates
security requirements
RLS enforced on Realtime subscriptions — users only receive events for rows where user_id = auth.uid()
JWT validated on every Realtime channel subscription renewal
No sensitive notification body content transmitted via Realtime — only notification IDs and is_read status; full content fetched via REST on demand

Execution Context

Execution Tier
Tier 0

Tier 0 - 440 tasks

Implementation Notes

Use Supabase's .stream(primaryKey: ['id']) for Realtime, filtering with .eq('user_id', userId) and .eq('is_read', false), then map to a count via .listen((rows) => emit(rows.length)). For cursor pagination, add .lt('created_at', cursor) to the query when cursor is non-null, with .order('created_at', ascending: false).limit(pageSize). Store the Realtime channel reference in a final field and call supabase.removeChannel(channel) in the repository's dispose method. Expose unreadCountStream as a broadcast stream (StreamController.broadcast()) so multiple widgets can listen.

Use a RealtimeChannelConfig with event RealtimeListenTypes.postgresChanges targeting the notifications table INSERT and UPDATE events only.

Testing Requirements

Unit tests (flutter_test + Mockito): (1) first page fetch returns correct items; (2) cursor pagination returns next page excluding already-fetched items; (3) markAsRead triggers stream emission with decremented count; (4) markAllRead sets all items read in single call; (5) Realtime stream emits new count on simulated INSERT event; (6) channel is unsubscribed on dispose. Use StreamController to simulate Realtime events in tests. Target 80% branch coverage.

Component
Notification Center Screen
ui medium
Epic Risks (2)
medium impact medium prob technical

The notification badge widget depends on a persistent Supabase Realtime websocket subscription for live unread count updates. On mobile, network transitions (WiFi to cellular, background app state) can silently drop the websocket, resulting in a stale badge count that does not update until the next app foreground — reducing trust in the notification system.

Mitigation & Contingency

Mitigation: Implement connection lifecycle management in the badge widget's BLoC that re-subscribes on app foreground and on network reconnection events. Add a fallback polling query (every 60 seconds when app is foregrounded) to reconcile the badge count if the Realtime subscription is interrupted.

Contingency: If Realtime reliability proves insufficient in production, replace the live subscription with a polling approach using a configurable interval, accepting slightly delayed badge updates in exchange for reliability.

medium impact medium prob technical

The notification list item widget requires merged semantics combining title, body, timestamp, read state, and role-context icon into a single VoiceOver/TalkBack announcement. Getting the merged semantics structure right for both iOS (VoiceOver) and Android (TalkBack) simultaneously is non-trivial and common to break silently when widgets are refactored.

Mitigation & Contingency

Mitigation: Use the project's existing semantics-wrapper-widget pattern with explicit Semantics widgets and excludeSemantics on decorative children. Write accessibility widget tests using Flutter's SemanticsController to assert the exact announcement string. Test on physical devices with VoiceOver and TalkBack enabled before release.

Contingency: If merged semantics cannot be achieved cleanly on both platforms, implement platform-specific semantic trees using defaultTargetPlatform branching, ensuring each platform receives an optimal announcement even if the implementation differs.