310 lines
9.8 KiB
Dart
310 lines
9.8 KiB
Dart
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<List<Announcement>>((ref) {
|
|
final userId = ref.watch(currentUserIdProvider);
|
|
if (userId == null) return const Stream.empty();
|
|
|
|
final client = ref.watch(supabaseClientProvider);
|
|
|
|
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);
|
|
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<List<AnnouncementComment>, String>((
|
|
ref,
|
|
announcementId,
|
|
) {
|
|
final client = ref.watch(supabaseClientProvider);
|
|
|
|
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,
|
|
);
|
|
|
|
ref.onDispose(wrapper.dispose);
|
|
return wrapper.stream.map((result) => result.data);
|
|
});
|
|
|
|
final announcementsControllerProvider =
|
|
Provider<AnnouncementsController>((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<void> createAnnouncement({
|
|
required String title,
|
|
required String body,
|
|
required List<String> 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<void> updateAnnouncement({
|
|
required String id,
|
|
required String title,
|
|
required String body,
|
|
required List<String> visibleRoles,
|
|
bool? isTemplate,
|
|
}) async {
|
|
final payload = <String, dynamic>{
|
|
'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<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);
|
|
}
|
|
|
|
/// Add a comment to an announcement.
|
|
/// Notifies the announcement author and all previous commenters.
|
|
Future<void> 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 = <String>{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<void> 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<void> deleteComment(String id) async {
|
|
await _client.from('announcement_comments').delete().eq('id', id);
|
|
}
|
|
}
|