Offline Support
This commit is contained in:
@@ -3,20 +3,65 @@ 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';
|
||||
import '../utils/app_time.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();
|
||||
}
|
||||
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')
|
||||
@@ -34,10 +79,87 @@ final notificationsProvider = StreamProvider<List<NotificationItem>>((ref) {
|
||||
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);
|
||||
return wrapper.stream.map((result) => result.data);
|
||||
|
||||
// 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) {
|
||||
@@ -52,18 +174,15 @@ final notificationsControllerProvider = Provider<NotificationsController>((
|
||||
ref,
|
||||
) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return NotificationsController(client);
|
||||
return NotificationsController(client, ref);
|
||||
});
|
||||
|
||||
class NotificationsController {
|
||||
NotificationsController(this._client);
|
||||
NotificationsController(this._client, [this._ref]);
|
||||
|
||||
final SupabaseClient _client;
|
||||
final Ref? _ref;
|
||||
|
||||
/// Internal helper that inserts notification rows and sends pushes if
|
||||
/// [targetUserIds] is provided.
|
||||
/// Internal helper that inserts notification rows and optionally sends
|
||||
/// FCM pushes. Callers should use [createNotification] instead.
|
||||
Future<void> _createAndPush(
|
||||
List<Map<String, dynamic>> rows, {
|
||||
List<String>? targetUserIds,
|
||||
@@ -77,9 +196,6 @@ class NotificationsController {
|
||||
);
|
||||
await _client.from('notifications').insert(rows);
|
||||
|
||||
// If target user IDs are provided, invoke client-side push
|
||||
// flow by calling `sendPush`. We no longer rely on the DB trigger
|
||||
// to call the edge function.
|
||||
if (targetUserIds == null || targetUserIds.isEmpty) return;
|
||||
|
||||
debugPrint('notification rows inserted; invoking client push');
|
||||
@@ -95,11 +211,6 @@ class NotificationsController {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a typed notification in the database. This method handles
|
||||
/// inserting the row(s); a PostgreSQL trigger will forward the new row to
|
||||
/// the `send_fcm` edge function, so clients do **not** directly invoke it
|
||||
/// (avoids CORS/auth problems on web). The [pushTitle]/[pushBody] values
|
||||
/// are still stored for the trigger payload.
|
||||
Future<void> createNotification({
|
||||
required List<String> userIds,
|
||||
required String type,
|
||||
@@ -132,7 +243,6 @@ class NotificationsController {
|
||||
);
|
||||
}
|
||||
|
||||
/// Convenience for mention-specific case; left for compatibility.
|
||||
Future<void> createMentionNotifications({
|
||||
required List<String> userIds,
|
||||
required String actorId,
|
||||
@@ -158,36 +268,55 @@ class NotificationsController {
|
||||
);
|
||||
}
|
||||
|
||||
/// Mark a single notification as read. Offline: queues locally.
|
||||
Future<void> markRead(String id) async {
|
||||
await _client
|
||||
.from('notifications')
|
||||
.update({'read_at': AppTime.nowUtc().toIso8601String()})
|
||||
.eq('id', id);
|
||||
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;
|
||||
await _client
|
||||
.from('notifications')
|
||||
.update({'read_at': AppTime.nowUtc().toIso8601String()})
|
||||
.eq('ticket_id', ticketId)
|
||||
.eq('user_id', userId)
|
||||
.filter('read_at', 'is', null);
|
||||
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;
|
||||
await _client
|
||||
.from('notifications')
|
||||
.update({'read_at': AppTime.nowUtc().toIso8601String()})
|
||||
.eq('task_id', taskId)
|
||||
.eq('user_id', userId)
|
||||
.filter('read_at', 'is', null);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// Store or update an FCM token for the current user.
|
||||
Future<void> registerFcmToken(String token) async {
|
||||
final userId = _client.auth.currentUser?.id;
|
||||
if (userId == null) return;
|
||||
@@ -196,8 +325,6 @@ class NotificationsController {
|
||||
debugPrint('registerFcmToken: device id missing; skipping');
|
||||
return;
|
||||
}
|
||||
// upsert using a unique constraint on (user_id, device_id) so a single
|
||||
// device keeps its token updated without overwriting other devices.
|
||||
final payload = {
|
||||
'user_id': userId,
|
||||
'device_id': deviceId,
|
||||
@@ -207,19 +334,16 @@ class NotificationsController {
|
||||
final res = await _client.from('fcm_tokens').upsert(payload);
|
||||
final dyn = res as dynamic;
|
||||
if (dyn.error != null) {
|
||||
// duplicate key or RLS issue - just log it
|
||||
debugPrint('registerFcmToken error: ${dyn.error?.message ?? dyn.error}');
|
||||
} else {
|
||||
debugPrint('registerFcmToken success for user=$userId token=$token');
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove an FCM token (e.g. when the user logs out or uninstalls).
|
||||
Future<void> unregisterFcmToken(String token) async {
|
||||
final userId = _client.auth.currentUser?.id;
|
||||
if (userId == null) return;
|
||||
final deviceId = await DeviceId.getId();
|
||||
// Prefer to delete by device_id to avoid removing other devices' tokens.
|
||||
final res = await _client
|
||||
.from('fcm_tokens')
|
||||
.delete()
|
||||
@@ -234,7 +358,6 @@ class NotificationsController {
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a push message via the `send_fcm` edge function.
|
||||
Future<void> sendPush({
|
||||
List<String>? tokens,
|
||||
List<String>? userIds,
|
||||
@@ -270,4 +393,52 @@ class NotificationsController {
|
||||
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),
|
||||
);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user