tasq/lib/providers/announcements_provider.dart

259 lines
8.1 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
final announcement = await _client
.from('announcements')
.select('author_id')
.eq('id', announcementId)
.maybeSingle();
if (announcement == null) return;
final announcementAuthorId = announcement['author_id'] as String;
// 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;
await _notifCtrl.createNotification(
userIds: notifyIds,
type: 'announcement_comment',
actorId: authorId,
fields: {'announcement_id': announcementId},
pushTitle: 'New Comment',
pushBody: body.length > 100 ? '${body.substring(0, 100)}...' : body,
pushData: {
'announcement_id': announcementId,
'navigate_to': '/announcements',
},
);
} catch (e) {
debugPrint('AnnouncementsController: comment 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);
}
}