695 lines
24 KiB
Dart
695 lines
24 KiB
Dart
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<List<Announcement>>((ref) => const []);
|
|
|
|
/// Field-level updates to existing announcements made offline, keyed by id.
|
|
final offlinePendingAnnouncementUpdatesProvider =
|
|
StateProvider<Map<String, Map<String, dynamic>>>((ref) => const {});
|
|
|
|
/// Comments added offline, not yet confirmed by Supabase.
|
|
final offlinePendingAnnouncementCommentsProvider =
|
|
StateProvider<List<AnnouncementComment>>((ref) => const []);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Read providers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Streams all announcements visible to the current user (RLS filtered).
|
|
final announcementsProvider = StreamProvider<List<Announcement>>((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<Announcement> applyPending(List<Announcement> 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<String>?) ??
|
|
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<Announcement>(
|
|
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<Announcement>();
|
|
return applyPending(all);
|
|
},
|
|
onCacheMirror: (rows) =>
|
|
mirrorBatchToBrick<Announcement>(rows, tag: 'announcements'),
|
|
);
|
|
|
|
ref.onDispose(wrapper.dispose);
|
|
|
|
// Reconnect-replay: POST queued items directly, bypassing Brick's queue.
|
|
ref.listen<bool>(isOnlineProvider, (wasOnline, isNowOnline) async {
|
|
if (wasOnline == true || !isNowOnline) return;
|
|
|
|
// 1. Replay offline-created announcements
|
|
final pendingList =
|
|
List<Announcement>.from(ref.read(offlinePendingAnnouncementsProvider));
|
|
if (pendingList.isNotEmpty) {
|
|
debugPrint(
|
|
'[announcementsProvider] reconnected — syncing ${pendingList.length} announcement(s)',
|
|
);
|
|
final synced = <String>[];
|
|
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<String, Map<String, dynamic>>.from(
|
|
ref.read(offlinePendingAnnouncementUpdatesProvider),
|
|
);
|
|
if (pendingUpdatesMap.isNotEmpty) {
|
|
debugPrint(
|
|
'[announcementsProvider] reconnected — syncing ${pendingUpdatesMap.length} update(s)',
|
|
);
|
|
final syncedIds = <String>[];
|
|
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<String, Map<String, dynamic>>.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<AnnouncementComment>.from(
|
|
ref.read(offlinePendingAnnouncementCommentsProvider),
|
|
);
|
|
if (pendingComments.isNotEmpty) {
|
|
debugPrint(
|
|
'[announcementsProvider] reconnected — syncing ${pendingComments.length} comment(s)',
|
|
);
|
|
final synced = <String>[];
|
|
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<List<AnnouncementComment>, String>((
|
|
ref,
|
|
announcementId,
|
|
) {
|
|
final client = ref.watch(supabaseClientProvider);
|
|
|
|
final pendingComments =
|
|
ref.watch(offlinePendingAnnouncementCommentsProvider);
|
|
|
|
List<AnnouncementComment> applyPending(List<AnnouncementComment> 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<AnnouncementComment>(
|
|
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<AnnouncementComment>();
|
|
final filtered = all
|
|
.where((c) => c.announcementId == announcementId)
|
|
.toList()
|
|
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
|
return applyPending(filtered);
|
|
},
|
|
onCacheMirror: (rows) => mirrorBatchToBrick<AnnouncementComment>(
|
|
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<List<Announcement>>((ref) {
|
|
final all = ref.watch(announcementsProvider).valueOrNull ?? [];
|
|
return all.where((a) => !a.isTemplate && a.isBannerActive).toList();
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Controller
|
|
// ---------------------------------------------------------------------------
|
|
|
|
final announcementsControllerProvider =
|
|
Provider<AnnouncementsController>((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<void> createAnnouncement({
|
|
required String title,
|
|
required String body,
|
|
required List<String> 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<void> updateAnnouncement({
|
|
required String id,
|
|
required String title,
|
|
required String body,
|
|
required List<String> visibleRoles,
|
|
bool? isTemplate,
|
|
bool? bannerEnabled,
|
|
DateTime? bannerShowAt,
|
|
DateTime? bannerHideAt,
|
|
int? pushIntervalMinutes,
|
|
bool clearBannerShowAt = false,
|
|
bool clearBannerHideAt = false,
|
|
bool clearPushInterval = false,
|
|
}) async {
|
|
final payload = <String, dynamic>{
|
|
'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<void> updateBannerSettings({
|
|
required String id,
|
|
required bool bannerEnabled,
|
|
DateTime? bannerShowAt,
|
|
DateTime? bannerHideAt,
|
|
int? pushIntervalMinutes,
|
|
}) async {
|
|
final payload = <String, dynamic>{
|
|
'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<void> dismissBanner(String id) async {
|
|
final payload = <String, dynamic>{
|
|
'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<void> deleteAnnouncement(String id) async {
|
|
await _client.from('announcements').delete().eq('id', id);
|
|
}
|
|
|
|
/// Fetch a template announcement's data for reposting.
|
|
Future<Announcement?> 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<String, dynamic>);
|
|
}
|
|
|
|
/// Add a comment. Offline: queues the comment locally.
|
|
Future<void> 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 = <String>{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<void> 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<void> 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<Announcement>.from(
|
|
ref.read(offlinePendingAnnouncementsProvider),
|
|
);
|
|
current.add(announcement);
|
|
ref.read(offlinePendingAnnouncementsProvider.notifier).state =
|
|
List.unmodifiable(current);
|
|
mirrorBatchToBrick<Announcement>([announcement], tag: 'offline_announcement');
|
|
}
|
|
|
|
void _queueAnnouncementUpdate(String id, Map<String, dynamic> fields) {
|
|
final ref = _ref;
|
|
if (ref == null) return;
|
|
final current = Map<String, Map<String, dynamic>>.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<AnnouncementComment>.from(
|
|
ref.read(offlinePendingAnnouncementCommentsProvider),
|
|
);
|
|
current.add(comment);
|
|
ref.read(offlinePendingAnnouncementCommentsProvider.notifier).state =
|
|
List.unmodifiable(current);
|
|
mirrorBatchToBrick<AnnouncementComment>([comment],
|
|
tag: 'offline_announcement_comment');
|
|
}
|
|
}
|