Files
tasq/lib/providers/notifications_provider.dart
T

466 lines
15 KiB
Dart

import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../utils/device_id.dart';
import '../brick/cache_helpers.dart';
import '../models/notification_item.dart';
import '../utils/app_time.dart';
import '../utils/snackbar.dart' show isOfflineSaveError;
import 'connectivity_provider.dart';
import 'profile_provider.dart';
import 'supabase_provider.dart';
import 'stream_recovery.dart';
import 'realtime_controller.dart';
// ---------------------------------------------------------------------------
// Offline-pending state
// ---------------------------------------------------------------------------
/// IDs of notifications marked as read offline (optimistic update).
/// On reconnect, batch-patched and cleared.
final offlinePendingNotificationReadsProvider =
StateProvider<Set<String>>((ref) => const {});
/// Batch read-mark operations queued offline.
/// Each entry: {'filter_type': 'ticket'|'task', 'filter_id': ..., 'user_id': ...}
final offlinePendingBatchNotificationReadsProvider =
StateProvider<List<Map<String, dynamic>>>((ref) => const []);
// ---------------------------------------------------------------------------
// Read provider
// ---------------------------------------------------------------------------
final notificationsProvider = StreamProvider<List<NotificationItem>>((ref) {
final userId = ref.watch(currentUserIdProvider);
if (userId == null) return const Stream.empty();
final client = ref.watch(supabaseClientProvider);
final pendingReadIds = ref.watch(offlinePendingNotificationReadsProvider);
List<NotificationItem> applyPending(List<NotificationItem> rows) {
if (pendingReadIds.isEmpty) return rows;
return rows.map((n) {
if (pendingReadIds.contains(n.id) && n.readAt == null) {
return NotificationItem(
id: n.id,
userId: n.userId,
actorId: n.actorId,
ticketId: n.ticketId,
taskId: n.taskId,
leaveId: n.leaveId,
passSlipId: n.passSlipId,
itServiceRequestId: n.itServiceRequestId,
announcementId: n.announcementId,
messageId: n.messageId,
type: n.type,
createdAt: n.createdAt,
readAt: AppTime.now(),
);
}
return n;
}).toList();
}
final wrapper = StreamRecoveryWrapper<NotificationItem>(
stream: client
.from('notifications')
.stream(primaryKey: ['id'])
.eq('user_id', userId)
.order('created_at', ascending: false),
onPollData: () async {
final data = await client
.from('notifications')
.select()
.eq('user_id', userId)
.order('created_at', ascending: false);
return data.map(NotificationItem.fromMap).toList();
},
fromMap: NotificationItem.fromMap,
channelName: 'notifications',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
onOfflineData: () async {
final all = await cachedListFromBrick<NotificationItem>();
final filtered = all.where((n) => n.userId == userId).toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
return applyPending(filtered);
},
onCacheMirror: (rows) =>
mirrorBatchToBrick<NotificationItem>(rows, tag: 'notifications'),
);
ref.onDispose(wrapper.dispose);
// Reconnect-replay: batch-patch read status for queued marks.
ref.listen<bool>(isOnlineProvider, (wasOnline, isNowOnline) async {
if (wasOnline == true || !isNowOnline) return;
// 1. Batch-mark individual notification reads
final pendingIds =
Set<String>.from(ref.read(offlinePendingNotificationReadsProvider));
if (pendingIds.isNotEmpty) {
debugPrint(
'[notificationsProvider] reconnected — marking ${pendingIds.length} notification(s) read',
);
try {
await client
.from('notifications')
.update({'read_at': AppTime.nowUtc().toIso8601String()})
.inFilter('id', pendingIds.toList())
.filter('read_at', 'is', null);
ref.read(offlinePendingNotificationReadsProvider.notifier).state =
const {};
} catch (e) {
debugPrint(
'[notificationsProvider] failed to sync pending reads: $e',
);
}
}
// 2. Replay batch read-marks for tickets and tasks
final pendingBatch = List<Map<String, dynamic>>.from(
ref.read(offlinePendingBatchNotificationReadsProvider),
);
if (pendingBatch.isNotEmpty) {
debugPrint(
'[notificationsProvider] reconnected — syncing ${pendingBatch.length} batch read(s)',
);
final synced = <int>[];
for (var i = 0; i < pendingBatch.length; i++) {
final entry = pendingBatch[i];
final filterType = entry['filter_type'] as String;
final filterId = entry['filter_id'] as String;
final uid = entry['user_id'] as String;
try {
final query = client
.from('notifications')
.update({'read_at': AppTime.nowUtc().toIso8601String()})
.eq('user_id', uid)
.filter('read_at', 'is', null);
if (filterType == 'ticket') {
await query.eq('ticket_id', filterId);
} else {
await query.eq('task_id', filterId);
}
synced.add(i);
} catch (e) {
debugPrint(
'[notificationsProvider] failed to sync batch read entry $i: $e',
);
}
}
if (synced.isNotEmpty) {
final remaining = pendingBatch
.whereIndexed((i, _) => !synced.contains(i))
.toList();
ref.read(offlinePendingBatchNotificationReadsProvider.notifier).state =
List.unmodifiable(remaining);
}
}
});
return wrapper.stream.map((result) => applyPending(result.data));
});
final unreadNotificationsCountProvider = Provider<int>((ref) {
final notificationsAsync = ref.watch(notificationsProvider);
return notificationsAsync.maybeWhen(
data: (items) => items.where((item) => item.isUnread).length,
orElse: () => 0,
);
});
final notificationsControllerProvider = Provider<NotificationsController>((
ref,
) {
final client = ref.watch(supabaseClientProvider);
return NotificationsController(client, ref);
});
class NotificationsController {
NotificationsController(this._client, [this._ref]);
final SupabaseClient _client;
final Ref? _ref;
Future<void> _createAndPush(
List<Map<String, dynamic>> rows, {
List<String>? targetUserIds,
String? pushTitle,
String? pushBody,
Map<String, dynamic>? pushData,
}) async {
if (rows.isEmpty) return;
debugPrint(
'notifications_provider: inserting ${rows.length} rows; pushTitle=$pushTitle',
);
await _client.from('notifications').insert(rows);
if (targetUserIds == null || targetUserIds.isEmpty) return;
debugPrint('notification rows inserted; invoking client push');
try {
await sendPush(
userIds: targetUserIds,
title: pushTitle ?? '',
body: pushBody ?? '',
data: pushData,
);
} catch (e) {
debugPrint('sendPush failed: $e');
}
}
Future<void> createNotification({
required List<String> userIds,
required String type,
required String actorId,
Map<String, dynamic>? fields,
String? pushTitle,
String? pushBody,
Map<String, dynamic>? pushData,
}) async {
debugPrint(
'createNotification called type=$type users=${userIds.length} pushTitle=$pushTitle pushBody=$pushBody',
);
if (userIds.isEmpty) return;
final rows = userIds.map((userId) {
return <String, dynamic>{
'user_id': userId,
'actor_id': actorId,
'type': type,
...?fields,
};
}).toList();
await _createAndPush(
rows,
targetUserIds: userIds,
pushTitle: pushTitle,
pushBody: pushBody,
pushData: pushData,
);
}
Future<void> createMentionNotifications({
required List<String> userIds,
required String actorId,
required int messageId,
String? ticketId,
String? taskId,
}) async {
return createNotification(
userIds: userIds,
type: 'mention',
actorId: actorId,
fields: {
'message_id': messageId,
...?(ticketId != null ? {'ticket_id': ticketId} : null),
...?(taskId != null ? {'task_id': taskId} : null),
},
pushTitle: 'New mention',
pushBody: 'You were mentioned in a message',
pushData: {
...?(ticketId != null ? {'ticket_id': ticketId} : null),
...?(taskId != null ? {'task_id': taskId} : null),
},
);
}
/// Mark a single notification as read. Offline: queues locally.
Future<void> markRead(String id) async {
try {
await _client
.from('notifications')
.update({'read_at': AppTime.nowUtc().toIso8601String()})
.eq('id', id);
} catch (e) {
if (!isOfflineSaveError(e)) rethrow;
_queuePendingRead(id);
}
}
/// Mark all unread notifications for a ticket as read. Offline: queues.
Future<void> markReadForTicket(String ticketId) async {
final userId = _client.auth.currentUser?.id;
if (userId == null) return;
try {
await _client
.from('notifications')
.update({'read_at': AppTime.nowUtc().toIso8601String()})
.eq('ticket_id', ticketId)
.eq('user_id', userId)
.filter('read_at', 'is', null);
} catch (e) {
if (!isOfflineSaveError(e)) rethrow;
_queuePendingBatchRead(
filterType: 'ticket', filterId: ticketId, userId: userId);
}
}
/// Mark all unread notifications for a task as read. Offline: queues.
Future<void> markReadForTask(String taskId) async {
final userId = _client.auth.currentUser?.id;
if (userId == null) return;
try {
await _client
.from('notifications')
.update({'read_at': AppTime.nowUtc().toIso8601String()})
.eq('task_id', taskId)
.eq('user_id', userId)
.filter('read_at', 'is', null);
} catch (e) {
if (!isOfflineSaveError(e)) rethrow;
_queuePendingBatchRead(
filterType: 'task', filterId: taskId, userId: userId);
}
}
/// Mark all unread notifications for the current user as read.
Future<void> markAllRead() async {
final userId = _client.auth.currentUser?.id;
if (userId == null) return;
try {
await _client
.from('notifications')
.update({'read_at': AppTime.nowUtc().toIso8601String()})
.eq('user_id', userId)
.filter('read_at', 'is', null);
} catch (e) {
if (!isOfflineSaveError(e)) rethrow;
}
}
Future<void> registerFcmToken(String token) async {
final userId = _client.auth.currentUser?.id;
if (userId == null) return;
final deviceId = await DeviceId.getId();
if (deviceId.isEmpty) {
debugPrint('registerFcmToken: device id missing; skipping');
return;
}
final payload = {
'user_id': userId,
'device_id': deviceId,
'token': token,
'created_at': DateTime.now().toUtc().toIso8601String(),
};
final res = await _client.from('fcm_tokens').upsert(payload);
final dyn = res as dynamic;
if (dyn.error != null) {
debugPrint('registerFcmToken error: ${dyn.error?.message ?? dyn.error}');
} else {
debugPrint('registerFcmToken success for user=$userId token=$token');
}
}
Future<void> unregisterFcmToken(String token) async {
final userId = _client.auth.currentUser?.id;
if (userId == null) return;
final deviceId = await DeviceId.getId();
final res = await _client
.from('fcm_tokens')
.delete()
.eq('user_id', userId)
.eq('device_id', deviceId)
.or('token.eq.$token');
final uDyn = res as dynamic;
if (uDyn.error != null) {
debugPrint(
'unregisterFcmToken error: ${uDyn.error?.message ?? uDyn.error}',
);
}
}
Future<void> sendPush({
List<String>? tokens,
List<String>? userIds,
required String title,
required String body,
Map<String, dynamic>? data,
}) async {
try {
if (tokens != null && tokens.isNotEmpty) {
debugPrint(
'invoking send_fcm with ${tokens.length} tokens, title="$title"',
);
} else if (userIds != null && userIds.isNotEmpty) {
debugPrint(
'invoking send_fcm with userIds=${userIds.length}, title="$title"',
);
} else {
debugPrint('sendPush called with neither tokens nor userIds');
return;
}
final bodyPayload = <String, dynamic>{
'tokens': tokens ?? [],
'user_ids': userIds ?? [],
'title': title,
'body': body,
'data': data ?? {},
};
final res = await _client.functions.invoke('send_fcm', body: bodyPayload);
debugPrint('send_fcm result: $res');
} catch (err) {
debugPrint('sendPush invocation error: $err');
}
}
// -----------------------------------------------------------------------
// Queue helpers
// -----------------------------------------------------------------------
void _queuePendingRead(String id) {
final ref = _ref;
if (ref == null) return;
final current = Set<String>.from(
ref.read(offlinePendingNotificationReadsProvider),
);
current.add(id);
ref.read(offlinePendingNotificationReadsProvider.notifier).state =
Set.unmodifiable(current);
}
void _queuePendingBatchRead({
required String filterType,
required String filterId,
required String userId,
}) {
final ref = _ref;
if (ref == null) return;
final current = List<Map<String, dynamic>>.from(
ref.read(offlinePendingBatchNotificationReadsProvider),
);
// Idempotency: skip if same (filter_type, filter_id) is already queued.
// Mark-read is idempotent — queuing duplicates only inflates the banner count.
if (current.any((e) =>
e['filter_type'] == filterType && e['filter_id'] == filterId)) {
return;
}
current.add({
'filter_type': filterType,
'filter_id': filterId,
'user_id': userId,
});
ref.read(offlinePendingBatchNotificationReadsProvider.notifier).state =
List.unmodifiable(current);
}
}
// ---------------------------------------------------------------------------
// Extension helper for indexed iteration
// ---------------------------------------------------------------------------
extension _IndexedWhere<T> on List<T> {
List<T> whereIndexed(bool Function(int index, T item) test) {
final result = <T>[];
for (var i = 0; i < length; i++) {
if (test(i, this[i])) result.add(this[i]);
}
return result;
}
}