import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:uuid/uuid.dart'; import '../brick/cache_helpers.dart'; import '../models/announcement.dart'; import '../models/announcement_comment.dart'; import '../utils/app_time.dart'; import '../utils/snackbar.dart' show isOfflineSaveError; import 'connectivity_provider.dart'; import 'notifications_provider.dart'; import 'profile_provider.dart'; import 'supabase_provider.dart'; import 'stream_recovery.dart'; import 'realtime_controller.dart'; /// Maximum rows fetched per poll. const int kAnnouncementsPageLimit = 100; /// 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; } // --------------------------------------------------------------------------- // Offline-pending state // --------------------------------------------------------------------------- /// Announcements created offline that haven't been confirmed by Supabase yet. final offlinePendingAnnouncementsProvider = StateProvider>((ref) => const []); /// Field-level updates to existing announcements made offline, keyed by id. final offlinePendingAnnouncementUpdatesProvider = StateProvider>>((ref) => const {}); /// Comments added offline, not yet confirmed by Supabase. final offlinePendingAnnouncementCommentsProvider = StateProvider>((ref) => const []); // --------------------------------------------------------------------------- // Read providers // --------------------------------------------------------------------------- /// 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 pendingNew = ref.watch(offlinePendingAnnouncementsProvider); final pendingUpdates = ref.watch(offlinePendingAnnouncementUpdatesProvider); List applyPending(List rows) { final byId = {for (final r in rows) r.id: r}; pendingUpdates.forEach((id, fields) { final existing = byId[id]; if (existing == null) return; byId[id] = Announcement( id: existing.id, authorId: existing.authorId, title: (fields['title'] as String?) ?? existing.title, body: (fields['body'] as String?) ?? existing.body, visibleRoles: (fields['visible_roles'] as List?) ?? existing.visibleRoles, isTemplate: (fields['is_template'] as bool?) ?? existing.isTemplate, templateId: fields.containsKey('template_id') ? fields['template_id'] as String? : existing.templateId, createdAt: existing.createdAt, updatedAt: fields.containsKey('updated_at') ? AppTime.parse(fields['updated_at'] as String) : existing.updatedAt, bannerEnabled: (fields['banner_enabled'] as bool?) ?? existing.bannerEnabled, bannerShowAt: fields.containsKey('banner_show_at') ? (fields['banner_show_at'] != null ? AppTime.parse(fields['banner_show_at'] as String) : null) : existing.bannerShowAt, bannerHideAt: fields.containsKey('banner_hide_at') ? (fields['banner_hide_at'] != null ? AppTime.parse(fields['banner_hide_at'] as String) : null) : existing.bannerHideAt, pushIntervalMinutes: fields.containsKey('push_interval_minutes') ? fields['push_interval_minutes'] as int? : existing.pushIntervalMinutes, ); }); for (final p in pendingNew) { byId.putIfAbsent(p.id, () => p); } return byId.values.toList() ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); } 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) .range(0, kAnnouncementsPageLimit - 1); return data.map(Announcement.fromMap).toList(); }, fromMap: Announcement.fromMap, channelName: 'announcements', onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, onOfflineData: () async { final all = await cachedListFromBrick(); return applyPending(all); }, onCacheMirror: (rows) => mirrorBatchToBrick(rows, tag: 'announcements'), ); ref.onDispose(wrapper.dispose); // Reconnect-replay: POST queued items directly, bypassing Brick's queue. ref.listen(isOnlineProvider, (wasOnline, isNowOnline) async { if (wasOnline == true || !isNowOnline) return; // 1. Replay offline-created announcements final pendingList = List.from(ref.read(offlinePendingAnnouncementsProvider)); if (pendingList.isNotEmpty) { debugPrint( '[announcementsProvider] reconnected — syncing ${pendingList.length} announcement(s)', ); final synced = []; for (final a in pendingList) { try { await client.from('announcements').insert({ 'id': a.id, 'author_id': a.authorId, 'title': a.title, 'body': a.body, 'visible_roles': a.visibleRoles, 'is_template': a.isTemplate, 'template_id': a.templateId, 'banner_enabled': a.bannerEnabled, 'banner_show_at': a.bannerShowAt?.toIso8601String(), 'banner_hide_at': a.bannerHideAt?.toIso8601String(), 'push_interval_minutes': a.pushIntervalMinutes, }); synced.add(a.id); } catch (e) { final msg = e.toString(); if (msg.contains('23505') || msg.contains('duplicate key')) { synced.add(a.id); } else { debugPrint( '[announcementsProvider] failed to sync id=${a.id}: $e', ); } } } if (synced.isNotEmpty) { final remaining = ref .read(offlinePendingAnnouncementsProvider) .where((a) => !synced.contains(a.id)) .toList(); ref.read(offlinePendingAnnouncementsProvider.notifier).state = List.unmodifiable(remaining); } } // 2. Replay offline announcement updates final pendingUpdatesMap = Map>.from( ref.read(offlinePendingAnnouncementUpdatesProvider), ); if (pendingUpdatesMap.isNotEmpty) { debugPrint( '[announcementsProvider] reconnected — syncing ${pendingUpdatesMap.length} update(s)', ); final syncedIds = []; for (final entry in pendingUpdatesMap.entries) { try { await client .from('announcements') .update(entry.value) .eq('id', entry.key); syncedIds.add(entry.key); } catch (e) { debugPrint( '[announcementsProvider] failed to sync update id=${entry.key}: $e', ); } } if (syncedIds.isNotEmpty) { final remaining = Map>.from( ref.read(offlinePendingAnnouncementUpdatesProvider), ); for (final id in syncedIds) { remaining.remove(id); } ref.read(offlinePendingAnnouncementUpdatesProvider.notifier).state = remaining; } } // 3. Replay offline comments final pendingComments = List.from( ref.read(offlinePendingAnnouncementCommentsProvider), ); if (pendingComments.isNotEmpty) { debugPrint( '[announcementsProvider] reconnected — syncing ${pendingComments.length} comment(s)', ); final synced = []; for (final c in pendingComments) { try { await client.from('announcement_comments').insert({ 'id': c.id, 'announcement_id': c.announcementId, 'author_id': c.authorId, 'body': c.body, }); synced.add(c.id); } catch (e) { final msg = e.toString(); if (msg.contains('23505') || msg.contains('duplicate key')) { synced.add(c.id); } else { debugPrint( '[announcementsProvider] failed to sync comment id=${c.id}: $e', ); } } } if (synced.isNotEmpty) { final remaining = ref .read(offlinePendingAnnouncementCommentsProvider) .where((c) => !synced.contains(c.id)) .toList(); ref.read(offlinePendingAnnouncementCommentsProvider.notifier).state = List.unmodifiable(remaining); } } }); return wrapper.stream.map((result) => applyPending(result.data)); }); /// Streams comments for a specific announcement. final announcementCommentsProvider = StreamProvider.family, String>(( ref, announcementId, ) { final client = ref.watch(supabaseClientProvider); final pendingComments = ref.watch(offlinePendingAnnouncementCommentsProvider); List applyPending(List rows) { final byId = {for (final r in rows) r.id: r}; for (final c in pendingComments) { if (c.announcementId == announcementId) { byId.putIfAbsent(c.id, () => c); } } return byId.values.toList() ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); } 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, onOfflineData: () async { final all = await cachedListFromBrick(); final filtered = all .where((c) => c.announcementId == announcementId) .toList() ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); return applyPending(filtered); }, onCacheMirror: (rows) => mirrorBatchToBrick( rows, tag: 'announcement_comments'), ); ref.onDispose(wrapper.dispose); return wrapper.stream.map((result) => applyPending(result.data)); }); /// Active banner announcements for the current user. final activeBannerAnnouncementsProvider = Provider>((ref) { final all = ref.watch(announcementsProvider).valueOrNull ?? []; return all.where((a) => !a.isTemplate && a.isBannerActive).toList(); }); // --------------------------------------------------------------------------- // Controller // --------------------------------------------------------------------------- final announcementsControllerProvider = Provider((ref) { final client = ref.watch(supabaseClientProvider); final notifCtrl = ref.watch(notificationsControllerProvider); return AnnouncementsController(client, notifCtrl, ref); }); class AnnouncementsController { AnnouncementsController(this._client, this._notifCtrl, [this._ref]); // _client is declared dynamic to allow test doubles. // ignore: avoid_dynamic_calls final dynamic _client; final dynamic _notifCtrl; final Ref? _ref; /// Create a new announcement and send push notifications to target users. /// Offline: queues a local row and returns silently. Future createAnnouncement({ required String title, required String body, required List visibleRoles, bool isTemplate = false, String? templateId, bool bannerEnabled = false, DateTime? bannerShowAt, DateTime? bannerHideAt, int? pushIntervalMinutes, }) async { final authorId = _client.auth.currentUser?.id as String?; if (authorId == null) return; final id = const Uuid().v4(); final now = AppTime.now(); final row = { 'id': id, 'author_id': authorId, 'title': title, 'body': body, 'visible_roles': visibleRoles, 'is_template': isTemplate, 'template_id': templateId, 'banner_enabled': bannerEnabled, 'banner_show_at': bannerShowAt?.toUtc().toIso8601String(), 'banner_hide_at': bannerHideAt?.toUtc().toIso8601String(), 'push_interval_minutes': pushIntervalMinutes, }; String? announcementId; try { final result = await _client .from('announcements') .insert(row) .select('id') .single(); announcementId = result['id'] as String; } catch (e) { if (!isOfflineSaveError(e)) rethrow; _queuePendingAnnouncement( Announcement( id: id, authorId: authorId, title: title, body: body, visibleRoles: visibleRoles, isTemplate: isTemplate, templateId: templateId, createdAt: now, updatedAt: now, bannerEnabled: bannerEnabled, bannerShowAt: bannerShowAt, bannerHideAt: bannerHideAt, pushIntervalMinutes: pushIntervalMinutes, ), ); return; } if (isTemplate) return; if (bannerEnabled && pushIntervalMinutes != null) return; 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((uid) => uid != 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. Offline: queues the field patch. Future updateAnnouncement({ required String id, required String title, required String body, required List visibleRoles, bool? isTemplate, bool? bannerEnabled, DateTime? bannerShowAt, DateTime? bannerHideAt, int? pushIntervalMinutes, bool clearBannerShowAt = false, bool clearBannerHideAt = false, bool clearPushInterval = false, }) async { final payload = { 'title': title, 'body': body, 'visible_roles': visibleRoles, 'updated_at': AppTime.nowUtc().toIso8601String(), }; if (isTemplate != null) payload['is_template'] = isTemplate; if (bannerEnabled != null) payload['banner_enabled'] = bannerEnabled; if (bannerShowAt != null) { payload['banner_show_at'] = bannerShowAt.toUtc().toIso8601String(); } else if (clearBannerShowAt) { payload['banner_show_at'] = null; } if (bannerHideAt != null) { payload['banner_hide_at'] = bannerHideAt.toUtc().toIso8601String(); } else if (clearBannerHideAt) { payload['banner_hide_at'] = null; } if (pushIntervalMinutes != null) { payload['push_interval_minutes'] = pushIntervalMinutes; } else if (clearPushInterval) { payload['push_interval_minutes'] = null; } try { await _client.from('announcements').update(payload).eq('id', id); } catch (e) { if (!isOfflineSaveError(e)) rethrow; _queueAnnouncementUpdate(id, payload); } } /// Update only the banner settings. Offline: queues the field patch. Future updateBannerSettings({ required String id, required bool bannerEnabled, DateTime? bannerShowAt, DateTime? bannerHideAt, int? pushIntervalMinutes, }) async { final payload = { 'banner_enabled': bannerEnabled, 'banner_show_at': bannerShowAt?.toUtc().toIso8601String(), 'banner_hide_at': bannerHideAt?.toUtc().toIso8601String(), 'push_interval_minutes': pushIntervalMinutes, 'updated_at': AppTime.nowUtc().toIso8601String(), }; try { await _client.from('announcements').update(payload).eq('id', id); } catch (e) { if (!isOfflineSaveError(e)) rethrow; _queueAnnouncementUpdate(id, payload); } } /// Immediately stops a banner by setting banner_hide_at to now. /// Offline: queues the field patch. Future dismissBanner(String id) async { final payload = { 'banner_hide_at': AppTime.nowUtc().toIso8601String(), 'updated_at': AppTime.nowUtc().toIso8601String(), }; try { await _client.from('announcements').update(payload).eq('id', id); } catch (e) { if (!isOfflineSaveError(e)) rethrow; _queueAnnouncementUpdate(id, payload); } } /// Delete an announcement. Online-only — destructive actions are not queued. 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 as Map); } /// Add a comment. Offline: queues the comment locally. Future addComment({ required String announcementId, required String body, }) async { final authorId = _client.auth.currentUser?.id as String?; if (authorId == null) return; final id = const Uuid().v4(); try { await _client.from('announcement_comments').insert({ 'id': id, 'announcement_id': announcementId, 'author_id': authorId, 'body': body, }); } catch (e) { if (!isOfflineSaveError(e)) rethrow; _queuePendingComment( AnnouncementComment( id: id, announcementId: announcementId, authorId: authorId, body: body, createdAt: AppTime.now(), ), ); return; } try { 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'; 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(); final notifyIds = {announcementAuthorId, ...commenterIds} .where((uid) => uid != authorId) .toList(); if (notifyIds.isEmpty) return; 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. Future resendAnnouncementNotification( Announcement announcement) async { final actorId = _client.auth.currentUser?.id as String?; 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((uid) => uid != 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. Online-only — destructive actions are not queued. Future deleteComment(String id) async { await _client.from('announcement_comments').delete().eq('id', id); } // --------------------------------------------------------------------------- // Queue helpers // --------------------------------------------------------------------------- void _queuePendingAnnouncement(Announcement announcement) { final ref = _ref; if (ref == null) return; final current = List.from( ref.read(offlinePendingAnnouncementsProvider), ); current.add(announcement); ref.read(offlinePendingAnnouncementsProvider.notifier).state = List.unmodifiable(current); mirrorBatchToBrick([announcement], tag: 'offline_announcement'); } void _queueAnnouncementUpdate(String id, Map fields) { final ref = _ref; if (ref == null) return; final current = Map>.from( ref.read(offlinePendingAnnouncementUpdatesProvider), ); current[id] = {...?current[id], ...fields}; ref.read(offlinePendingAnnouncementUpdatesProvider.notifier).state = current; } void _queuePendingComment(AnnouncementComment comment) { final ref = _ref; if (ref == null) return; final current = List.from( ref.read(offlinePendingAnnouncementCommentsProvider), ); current.add(comment); ref.read(offlinePendingAnnouncementCommentsProvider.notifier).state = List.unmodifiable(current); mirrorBatchToBrick([comment], tag: 'offline_announcement_comment'); } }