import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import '../utils/device_id.dart'; import '../brick/cache_helpers.dart'; import '../models/notification_item.dart'; import '../utils/app_time.dart'; import '../utils/snackbar.dart' show isOfflineSaveError; import 'connectivity_provider.dart'; import 'profile_provider.dart'; import 'supabase_provider.dart'; import 'stream_recovery.dart'; import 'realtime_controller.dart'; // --------------------------------------------------------------------------- // Offline-pending state // --------------------------------------------------------------------------- /// IDs of notifications marked as read offline (optimistic update). /// On reconnect, batch-patched and cleared. final offlinePendingNotificationReadsProvider = StateProvider>((ref) => const {}); /// Batch read-mark operations queued offline. /// Each entry: {'filter_type': 'ticket'|'task', 'filter_id': ..., 'user_id': ...} final offlinePendingBatchNotificationReadsProvider = StateProvider>>((ref) => const []); // --------------------------------------------------------------------------- // Read provider // --------------------------------------------------------------------------- final notificationsProvider = StreamProvider>((ref) { final userId = ref.watch(currentUserIdProvider); if (userId == null) return const Stream.empty(); final client = ref.watch(supabaseClientProvider); final pendingReadIds = ref.watch(offlinePendingNotificationReadsProvider); List applyPending(List rows) { if (pendingReadIds.isEmpty) return rows; return rows.map((n) { if (pendingReadIds.contains(n.id) && n.readAt == null) { return NotificationItem( id: n.id, userId: n.userId, actorId: n.actorId, ticketId: n.ticketId, taskId: n.taskId, leaveId: n.leaveId, passSlipId: n.passSlipId, itServiceRequestId: n.itServiceRequestId, announcementId: n.announcementId, messageId: n.messageId, type: n.type, createdAt: n.createdAt, readAt: AppTime.now(), ); } return n; }).toList(); } final wrapper = StreamRecoveryWrapper( stream: client .from('notifications') .stream(primaryKey: ['id']) .eq('user_id', userId) .order('created_at', ascending: false), onPollData: () async { final data = await client .from('notifications') .select() .eq('user_id', userId) .order('created_at', ascending: false); return data.map(NotificationItem.fromMap).toList(); }, fromMap: NotificationItem.fromMap, channelName: 'notifications', onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, onOfflineData: () async { final all = await cachedListFromBrick(); final filtered = all.where((n) => n.userId == userId).toList() ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); return applyPending(filtered); }, onCacheMirror: (rows) => mirrorBatchToBrick(rows, tag: 'notifications'), ); ref.onDispose(wrapper.dispose); // Reconnect-replay: batch-patch read status for queued marks. ref.listen(isOnlineProvider, (wasOnline, isNowOnline) async { if (wasOnline == true || !isNowOnline) return; // 1. Batch-mark individual notification reads final pendingIds = Set.from(ref.read(offlinePendingNotificationReadsProvider)); if (pendingIds.isNotEmpty) { debugPrint( '[notificationsProvider] reconnected — marking ${pendingIds.length} notification(s) read', ); try { await client .from('notifications') .update({'read_at': AppTime.nowUtc().toIso8601String()}) .inFilter('id', pendingIds.toList()) .filter('read_at', 'is', null); ref.read(offlinePendingNotificationReadsProvider.notifier).state = const {}; } catch (e) { debugPrint( '[notificationsProvider] failed to sync pending reads: $e', ); } } // 2. Replay batch read-marks for tickets and tasks final pendingBatch = List>.from( ref.read(offlinePendingBatchNotificationReadsProvider), ); if (pendingBatch.isNotEmpty) { debugPrint( '[notificationsProvider] reconnected — syncing ${pendingBatch.length} batch read(s)', ); final synced = []; for (var i = 0; i < pendingBatch.length; i++) { final entry = pendingBatch[i]; final filterType = entry['filter_type'] as String; final filterId = entry['filter_id'] as String; final uid = entry['user_id'] as String; try { final query = client .from('notifications') .update({'read_at': AppTime.nowUtc().toIso8601String()}) .eq('user_id', uid) .filter('read_at', 'is', null); if (filterType == 'ticket') { await query.eq('ticket_id', filterId); } else { await query.eq('task_id', filterId); } synced.add(i); } catch (e) { debugPrint( '[notificationsProvider] failed to sync batch read entry $i: $e', ); } } if (synced.isNotEmpty) { final remaining = pendingBatch .whereIndexed((i, _) => !synced.contains(i)) .toList(); ref.read(offlinePendingBatchNotificationReadsProvider.notifier).state = List.unmodifiable(remaining); } } }); return wrapper.stream.map((result) => applyPending(result.data)); }); final unreadNotificationsCountProvider = Provider((ref) { final notificationsAsync = ref.watch(notificationsProvider); return notificationsAsync.maybeWhen( data: (items) => items.where((item) => item.isUnread).length, orElse: () => 0, ); }); final notificationsControllerProvider = Provider(( ref, ) { final client = ref.watch(supabaseClientProvider); return NotificationsController(client, ref); }); class NotificationsController { NotificationsController(this._client, [this._ref]); final SupabaseClient _client; final Ref? _ref; Future _createAndPush( List> rows, { List? targetUserIds, String? pushTitle, String? pushBody, Map? pushData, }) async { if (rows.isEmpty) return; debugPrint( 'notifications_provider: inserting ${rows.length} rows; pushTitle=$pushTitle', ); await _client.from('notifications').insert(rows); if (targetUserIds == null || targetUserIds.isEmpty) return; debugPrint('notification rows inserted; invoking client push'); try { await sendPush( userIds: targetUserIds, title: pushTitle ?? '', body: pushBody ?? '', data: pushData, ); } catch (e) { debugPrint('sendPush failed: $e'); } } Future createNotification({ required List userIds, required String type, required String actorId, Map? fields, String? pushTitle, String? pushBody, Map? pushData, }) async { debugPrint( 'createNotification called type=$type users=${userIds.length} pushTitle=$pushTitle pushBody=$pushBody', ); if (userIds.isEmpty) return; final rows = userIds.map((userId) { return { 'user_id': userId, 'actor_id': actorId, 'type': type, ...?fields, }; }).toList(); await _createAndPush( rows, targetUserIds: userIds, pushTitle: pushTitle, pushBody: pushBody, pushData: pushData, ); } Future createMentionNotifications({ required List userIds, required String actorId, required int messageId, String? ticketId, String? taskId, }) async { return createNotification( userIds: userIds, type: 'mention', actorId: actorId, fields: { 'message_id': messageId, ...?(ticketId != null ? {'ticket_id': ticketId} : null), ...?(taskId != null ? {'task_id': taskId} : null), }, pushTitle: 'New mention', pushBody: 'You were mentioned in a message', pushData: { ...?(ticketId != null ? {'ticket_id': ticketId} : null), ...?(taskId != null ? {'task_id': taskId} : null), }, ); } /// Mark a single notification as read. Offline: queues locally. Future markRead(String id) async { try { await _client .from('notifications') .update({'read_at': AppTime.nowUtc().toIso8601String()}) .eq('id', id); } catch (e) { if (!isOfflineSaveError(e)) rethrow; _queuePendingRead(id); } } /// Mark all unread notifications for a ticket as read. Offline: queues. Future markReadForTicket(String ticketId) async { final userId = _client.auth.currentUser?.id; if (userId == null) return; try { await _client .from('notifications') .update({'read_at': AppTime.nowUtc().toIso8601String()}) .eq('ticket_id', ticketId) .eq('user_id', userId) .filter('read_at', 'is', null); } catch (e) { if (!isOfflineSaveError(e)) rethrow; _queuePendingBatchRead( filterType: 'ticket', filterId: ticketId, userId: userId); } } /// Mark all unread notifications for a task as read. Offline: queues. Future markReadForTask(String taskId) async { final userId = _client.auth.currentUser?.id; if (userId == null) return; try { await _client .from('notifications') .update({'read_at': AppTime.nowUtc().toIso8601String()}) .eq('task_id', taskId) .eq('user_id', userId) .filter('read_at', 'is', null); } catch (e) { if (!isOfflineSaveError(e)) rethrow; _queuePendingBatchRead( filterType: 'task', filterId: taskId, userId: userId); } } Future registerFcmToken(String token) async { final userId = _client.auth.currentUser?.id; if (userId == null) return; final deviceId = await DeviceId.getId(); if (deviceId.isEmpty) { debugPrint('registerFcmToken: device id missing; skipping'); return; } final payload = { 'user_id': userId, 'device_id': deviceId, 'token': token, 'created_at': DateTime.now().toUtc().toIso8601String(), }; final res = await _client.from('fcm_tokens').upsert(payload); final dyn = res as dynamic; if (dyn.error != null) { debugPrint('registerFcmToken error: ${dyn.error?.message ?? dyn.error}'); } else { debugPrint('registerFcmToken success for user=$userId token=$token'); } } Future unregisterFcmToken(String token) async { final userId = _client.auth.currentUser?.id; if (userId == null) return; final deviceId = await DeviceId.getId(); final res = await _client .from('fcm_tokens') .delete() .eq('user_id', userId) .eq('device_id', deviceId) .or('token.eq.$token'); final uDyn = res as dynamic; if (uDyn.error != null) { debugPrint( 'unregisterFcmToken error: ${uDyn.error?.message ?? uDyn.error}', ); } } Future sendPush({ List? tokens, List? userIds, required String title, required String body, Map? data, }) async { try { if (tokens != null && tokens.isNotEmpty) { debugPrint( 'invoking send_fcm with ${tokens.length} tokens, title="$title"', ); } else if (userIds != null && userIds.isNotEmpty) { debugPrint( 'invoking send_fcm with userIds=${userIds.length}, title="$title"', ); } else { debugPrint('sendPush called with neither tokens nor userIds'); return; } final bodyPayload = { 'tokens': tokens ?? [], 'user_ids': userIds ?? [], 'title': title, 'body': body, 'data': data ?? {}, }; final res = await _client.functions.invoke('send_fcm', body: bodyPayload); debugPrint('send_fcm result: $res'); } catch (err) { debugPrint('sendPush invocation error: $err'); } } // ----------------------------------------------------------------------- // Queue helpers // ----------------------------------------------------------------------- void _queuePendingRead(String id) { final ref = _ref; if (ref == null) return; final current = Set.from( ref.read(offlinePendingNotificationReadsProvider), ); current.add(id); ref.read(offlinePendingNotificationReadsProvider.notifier).state = Set.unmodifiable(current); } void _queuePendingBatchRead({ required String filterType, required String filterId, required String userId, }) { final ref = _ref; if (ref == null) return; final current = List>.from( ref.read(offlinePendingBatchNotificationReadsProvider), ); current.add({ 'filter_type': filterType, 'filter_id': filterId, 'user_id': userId, }); ref.read(offlinePendingBatchNotificationReadsProvider.notifier).state = List.unmodifiable(current); } } // --------------------------------------------------------------------------- // Extension helper for indexed iteration // --------------------------------------------------------------------------- extension _IndexedWhere on List { List whereIndexed(bool Function(int index, T item) test) { final result = []; for (var i = 0; i < length; i++) { if (test(i, this[i])) result.add(this[i]); } return result; } }