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 '../models/notification_item.dart'; import 'profile_provider.dart'; import 'supabase_provider.dart'; import 'stream_recovery.dart'; import 'realtime_controller.dart'; import '../utils/app_time.dart'; final notificationsProvider = StreamProvider>((ref) { final userId = ref.watch(currentUserIdProvider); if (userId == null) { return const Stream.empty(); } final client = ref.watch(supabaseClientProvider); 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, ); ref.onDispose(wrapper.dispose); return wrapper.stream.map((result) => 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); }); class NotificationsController { NotificationsController(this._client); final SupabaseClient _client; /// Internal helper that inserts notification rows and sends pushes if /// [targetUserIds] is provided. /// Internal helper that inserts notification rows and optionally sends /// FCM pushes. Callers should use [createNotification] instead. 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 target user IDs are provided, invoke client-side push // flow by calling `sendPush`. We no longer rely on the DB trigger // to call the edge function. 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'); } } /// Create a typed notification in the database. This method handles /// inserting the row(s); a PostgreSQL trigger will forward the new row to /// the `send_fcm` edge function, so clients do **not** directly invoke it /// (avoids CORS/auth problems on web). The [pushTitle]/[pushBody] values /// are still stored for the trigger payload. 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, ); } /// Convenience for mention-specific case; left for compatibility. 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), }, ); } Future markRead(String id) async { await _client .from('notifications') .update({'read_at': AppTime.nowUtc().toIso8601String()}) .eq('id', id); } Future markReadForTicket(String ticketId) async { final userId = _client.auth.currentUser?.id; if (userId == null) return; await _client .from('notifications') .update({'read_at': AppTime.nowUtc().toIso8601String()}) .eq('ticket_id', ticketId) .eq('user_id', userId) .filter('read_at', 'is', null); } Future markReadForTask(String taskId) async { final userId = _client.auth.currentUser?.id; if (userId == null) return; await _client .from('notifications') .update({'read_at': AppTime.nowUtc().toIso8601String()}) .eq('task_id', taskId) .eq('user_id', userId) .filter('read_at', 'is', null); } /// Store or update an FCM token for the current user. 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; } // upsert using a unique constraint on (user_id, device_id) so a single // device keeps its token updated without overwriting other devices. 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) { // duplicate key or RLS issue - just log it debugPrint('registerFcmToken error: ${dyn.error?.message ?? dyn.error}'); } else { debugPrint('registerFcmToken success for user=$userId token=$token'); } } /// Remove an FCM token (e.g. when the user logs out or uninstalls). Future unregisterFcmToken(String token) async { final userId = _client.auth.currentUser?.id; if (userId == null) return; final deviceId = await DeviceId.getId(); // Prefer to delete by device_id to avoid removing other devices' tokens. 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}', ); } } /// Send a push message via the `send_fcm` edge function. 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'); } } }