import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import '../models/announcement.dart'; import '../models/announcement_comment.dart'; import 'notifications_provider.dart'; import 'profile_provider.dart'; import 'supabase_provider.dart'; import 'stream_recovery.dart'; import 'realtime_controller.dart'; import '../utils/app_time.dart'; /// Thrown when an announcement or comment is saved successfully but /// notification delivery fails. The primary action (insert) has already /// completed — callers should treat this as a non-blocking warning. class AnnouncementNotificationException implements Exception { const AnnouncementNotificationException(this.message); final String message; @override String toString() => message; } /// Streams all announcements visible to the current user (RLS filtered). final announcementsProvider = 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('announcements') .stream(primaryKey: ['id']) .order('created_at', ascending: false), onPollData: () async { final data = await client .from('announcements') .select() .order('created_at', ascending: false); return data.map(Announcement.fromMap).toList(); }, fromMap: Announcement.fromMap, channelName: 'announcements', onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, ); ref.onDispose(wrapper.dispose); return wrapper.stream.map((result) => result.data); }); /// Streams comments for a specific announcement. final announcementCommentsProvider = StreamProvider.family, String>(( ref, announcementId, ) { final client = ref.watch(supabaseClientProvider); final wrapper = StreamRecoveryWrapper( stream: client .from('announcement_comments') .stream(primaryKey: ['id']) .eq('announcement_id', announcementId) .order('created_at'), onPollData: () async { final data = await client .from('announcement_comments') .select() .eq('announcement_id', announcementId) .order('created_at'); return data.map(AnnouncementComment.fromMap).toList(); }, fromMap: AnnouncementComment.fromMap, channelName: 'announcement_comments_$announcementId', onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, ); ref.onDispose(wrapper.dispose); return wrapper.stream.map((result) => result.data); }); final announcementsControllerProvider = Provider((ref) { final client = ref.watch(supabaseClientProvider); final notifCtrl = ref.watch(notificationsControllerProvider); return AnnouncementsController(client, notifCtrl); }); class AnnouncementsController { AnnouncementsController(this._client, this._notifCtrl); final SupabaseClient _client; final NotificationsController _notifCtrl; /// Create a new announcement and send push notifications to target users. Future createAnnouncement({ required String title, required String body, required List visibleRoles, bool isTemplate = false, String? templateId, }) async { final authorId = _client.auth.currentUser?.id; if (authorId == null) return; final row = { 'author_id': authorId, 'title': title, 'body': body, 'visible_roles': visibleRoles, 'is_template': isTemplate, 'template_id': templateId, }; final result = await _client .from('announcements') .insert(row) .select('id') .single(); final announcementId = result['id'] as String; // Don't send notifications for templates (they are drafts for reuse) if (isTemplate) return; // Query users whose role matches visible_roles, excluding the author try { final profiles = await _client .from('profiles') .select('id, role') .inFilter('role', visibleRoles); final userIds = (profiles as List) .map((p) => p['id'] as String) .where((id) => id != authorId) .toList(); if (userIds.isEmpty) return; await _notifCtrl.createNotification( userIds: userIds, type: 'announcement', actorId: authorId, fields: {'announcement_id': announcementId}, pushTitle: 'New Announcement', pushBody: title.length > 100 ? '${title.substring(0, 100)}...' : title, pushData: { 'announcement_id': announcementId, 'navigate_to': '/announcements', }, ); } catch (e) { debugPrint('AnnouncementsController: notification error: $e'); throw AnnouncementNotificationException(e.toString()); } } /// Update an existing announcement. Future updateAnnouncement({ required String id, required String title, required String body, required List visibleRoles, bool? isTemplate, }) async { final payload = { 'title': title, 'body': body, 'visible_roles': visibleRoles, 'updated_at': AppTime.nowUtc().toIso8601String(), }; if (isTemplate != null) { payload['is_template'] = isTemplate; } await _client.from('announcements').update(payload).eq('id', id); } /// Delete an announcement. Future deleteAnnouncement(String id) async { await _client.from('announcements').delete().eq('id', id); } /// Fetch a template announcement's data for reposting. Future fetchTemplate(String templateId) async { final data = await _client .from('announcements') .select() .eq('id', templateId) .maybeSingle(); if (data == null) return null; return Announcement.fromMap(data); } /// Add a comment to an announcement. /// Notifies the announcement author and all previous commenters. Future addComment({ required String announcementId, required String body, }) async { final authorId = _client.auth.currentUser?.id; if (authorId == null) return; await _client.from('announcement_comments').insert({ 'announcement_id': announcementId, 'author_id': authorId, 'body': body, }); // Notify announcement author + previous commenters try { // Get the announcement author and title final announcement = await _client .from('announcements') .select('author_id, title') .eq('id', announcementId) .maybeSingle(); if (announcement == null) return; final announcementAuthorId = announcement['author_id'] as String; final announcementTitle = announcement['title'] as String? ?? 'an announcement'; // Get all unique commenters on this announcement final comments = await _client .from('announcement_comments') .select('author_id') .eq('announcement_id', announcementId); final commenterIds = (comments as List) .map((c) => c['author_id'] as String) .toSet(); // Combine author + commenters, exclude self final notifyIds = {announcementAuthorId, ...commenterIds} .where((id) => id != authorId) .toList(); if (notifyIds.isEmpty) return; // Fetch commenter's display name for a human-readable push body final commenterData = await _client .from('profiles') .select('full_name') .eq('id', authorId) .maybeSingle(); final commenterName = commenterData?['full_name'] as String? ?? 'Someone'; await _notifCtrl.createNotification( userIds: notifyIds, type: 'announcement_comment', actorId: authorId, fields: {'announcement_id': announcementId}, pushTitle: 'New Comment', pushBody: '$commenterName commented on "$announcementTitle"', pushData: { 'announcement_id': announcementId, 'navigate_to': '/announcements', }, ); } catch (e) { debugPrint('AnnouncementsController: comment notification error: $e'); throw AnnouncementNotificationException(e.toString()); } } /// Re-sends push notifications for an existing announcement. /// Intended for use by admins or the announcement author. Future resendAnnouncementNotification( Announcement announcement) async { final actorId = _client.auth.currentUser?.id; if (actorId == null) return; if (announcement.visibleRoles.isEmpty) return; try { final profiles = await _client .from('profiles') .select('id') .inFilter('role', announcement.visibleRoles); final userIds = (profiles as List) .map((p) => p['id'] as String) .where((id) => id != actorId) .toList(); if (userIds.isEmpty) return; await _notifCtrl.createNotification( userIds: userIds, type: 'announcement', actorId: actorId, fields: {'announcement_id': announcement.id}, pushTitle: 'Announcement', pushBody: announcement.title.length > 100 ? '${announcement.title.substring(0, 100)}...' : announcement.title, pushData: { 'announcement_id': announcement.id, 'navigate_to': '/announcements', }, ); } catch (e) { debugPrint('AnnouncementsController: resend notification error: $e'); throw AnnouncementNotificationException(e.toString()); } } /// Delete a comment. Future deleteComment(String id) async { await _client.from('announcement_comments').delete().eq('id', id); } }