Offline Support
This commit is contained in:
@@ -1,17 +1,20 @@
|
||||
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';
|
||||
import '../utils/app_time.dart';
|
||||
|
||||
/// Maximum rows fetched per poll. Announcements are lower-volume than tasks,
|
||||
/// so 100 is sufficient; adjust if orgs routinely exceed this.
|
||||
/// Maximum rows fetched per poll.
|
||||
const int kAnnouncementsPageLimit = 100;
|
||||
|
||||
/// Thrown when an announcement or comment is saved successfully but
|
||||
@@ -24,6 +27,26 @@ class AnnouncementNotificationException implements Exception {
|
||||
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);
|
||||
@@ -31,6 +54,53 @@ final announcementsProvider = StreamProvider<List<Announcement>>((ref) {
|
||||
|
||||
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')
|
||||
@@ -47,10 +117,138 @@ final announcementsProvider = StreamProvider<List<Announcement>>((ref) {
|
||||
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);
|
||||
return wrapper.stream.map((result) => result.data);
|
||||
|
||||
// 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.
|
||||
@@ -61,6 +259,20 @@ final announcementCommentsProvider =
|
||||
) {
|
||||
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')
|
||||
@@ -78,37 +290,51 @@ final announcementCommentsProvider =
|
||||
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) => result.data);
|
||||
return wrapper.stream.map((result) => applyPending(result.data));
|
||||
});
|
||||
|
||||
/// Active banner announcements for the current user.
|
||||
/// Returns only non-template announcements whose banner is currently in its
|
||||
/// active time window ([Announcement.isBannerActive]).
|
||||
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);
|
||||
return AnnouncementsController(client, notifCtrl, ref);
|
||||
});
|
||||
|
||||
class AnnouncementsController {
|
||||
AnnouncementsController(this._client, this._notifCtrl);
|
||||
AnnouncementsController(this._client, this._notifCtrl, [this._ref]);
|
||||
|
||||
// _client is declared dynamic to allow test doubles that mimic only the
|
||||
// subset of methods used by this class. In production it is a SupabaseClient.
|
||||
// _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,
|
||||
@@ -120,10 +346,14 @@ class AnnouncementsController {
|
||||
DateTime? bannerHideAt,
|
||||
int? pushIntervalMinutes,
|
||||
}) async {
|
||||
final authorId = _client.auth.currentUser?.id;
|
||||
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,
|
||||
@@ -136,23 +366,39 @@ class AnnouncementsController {
|
||||
'push_interval_minutes': pushIntervalMinutes,
|
||||
};
|
||||
|
||||
final result = await _client
|
||||
.from('announcements')
|
||||
.insert(row)
|
||||
.select('id')
|
||||
.single();
|
||||
final announcementId = result['id'] as String;
|
||||
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;
|
||||
}
|
||||
|
||||
// Don't send notifications for templates (they are drafts for reuse)
|
||||
if (isTemplate) return;
|
||||
|
||||
// Skip the one-time creation push when a scheduled banner push is
|
||||
// configured. The banner scheduler will send the first push on its own
|
||||
// interval, so firing an extra push here would result in two back-to-back
|
||||
// notifications for the same announcement.
|
||||
if (bannerEnabled && pushIntervalMinutes != null) return;
|
||||
|
||||
// Query users whose role matches visible_roles, excluding the author
|
||||
try {
|
||||
final profiles = await _client
|
||||
.from('profiles')
|
||||
@@ -160,9 +406,8 @@ class AnnouncementsController {
|
||||
.inFilter('role', visibleRoles);
|
||||
final userIds = (profiles as List)
|
||||
.map((p) => p['id'] as String)
|
||||
.where((id) => id != authorId)
|
||||
.where((uid) => uid != authorId)
|
||||
.toList();
|
||||
|
||||
if (userIds.isEmpty) return;
|
||||
|
||||
await _notifCtrl.createNotification(
|
||||
@@ -183,7 +428,7 @@ class AnnouncementsController {
|
||||
}
|
||||
}
|
||||
|
||||
/// Update an existing announcement.
|
||||
/// Update an existing announcement. Offline: queues the field patch.
|
||||
Future<void> updateAnnouncement({
|
||||
required String id,
|
||||
required String title,
|
||||
@@ -221,11 +466,16 @@ class AnnouncementsController {
|
||||
} else if (clearPushInterval) {
|
||||
payload['push_interval_minutes'] = null;
|
||||
}
|
||||
await _client.from('announcements').update(payload).eq('id', id);
|
||||
|
||||
try {
|
||||
await _client.from('announcements').update(payload).eq('id', id);
|
||||
} catch (e) {
|
||||
if (!isOfflineSaveError(e)) rethrow;
|
||||
_queueAnnouncementUpdate(id, payload);
|
||||
}
|
||||
}
|
||||
|
||||
/// Update only the banner settings on an existing announcement.
|
||||
/// Intended for the "Manage Banner" popup available to the poster and admins.
|
||||
/// Update only the banner settings. Offline: queues the field patch.
|
||||
Future<void> updateBannerSettings({
|
||||
required String id,
|
||||
required bool bannerEnabled,
|
||||
@@ -233,25 +483,37 @@ class AnnouncementsController {
|
||||
DateTime? bannerHideAt,
|
||||
int? pushIntervalMinutes,
|
||||
}) async {
|
||||
await _client.from('announcements').update({
|
||||
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(),
|
||||
}).eq('id', id);
|
||||
};
|
||||
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.
|
||||
/// Usable by the poster or an admin.
|
||||
/// Immediately stops a banner by setting banner_hide_at to now.
|
||||
/// Offline: queues the field patch.
|
||||
Future<void> dismissBanner(String id) async {
|
||||
await _client.from('announcements').update({
|
||||
final payload = <String, dynamic>{
|
||||
'banner_hide_at': AppTime.nowUtc().toIso8601String(),
|
||||
'updated_at': AppTime.nowUtc().toIso8601String(),
|
||||
}).eq('id', id);
|
||||
};
|
||||
try {
|
||||
await _client.from('announcements').update(payload).eq('id', id);
|
||||
} catch (e) {
|
||||
if (!isOfflineSaveError(e)) rethrow;
|
||||
_queueAnnouncementUpdate(id, payload);
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete an announcement.
|
||||
/// Delete an announcement. Online-only — destructive actions are not queued.
|
||||
Future<void> deleteAnnouncement(String id) async {
|
||||
await _client.from('announcements').delete().eq('id', id);
|
||||
}
|
||||
@@ -264,27 +526,41 @@ class AnnouncementsController {
|
||||
.eq('id', templateId)
|
||||
.maybeSingle();
|
||||
if (data == null) return null;
|
||||
return Announcement.fromMap(data);
|
||||
return Announcement.fromMap(data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
/// Add a comment to an announcement.
|
||||
/// Notifies the announcement author and all previous commenters.
|
||||
/// Add a comment. Offline: queues the comment locally.
|
||||
Future<void> addComment({
|
||||
required String announcementId,
|
||||
required String body,
|
||||
}) async {
|
||||
final authorId = _client.auth.currentUser?.id;
|
||||
final authorId = _client.auth.currentUser?.id as String?;
|
||||
if (authorId == null) return;
|
||||
|
||||
await _client.from('announcement_comments').insert({
|
||||
'announcement_id': announcementId,
|
||||
'author_id': authorId,
|
||||
'body': body,
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
// Notify announcement author + previous commenters
|
||||
try {
|
||||
// Get the announcement author and title
|
||||
final announcement = await _client
|
||||
.from('announcements')
|
||||
.select('author_id, title')
|
||||
@@ -296,23 +572,18 @@ class AnnouncementsController {
|
||||
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();
|
||||
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)
|
||||
.where((uid) => uid != 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')
|
||||
@@ -340,10 +611,9 @@ class AnnouncementsController {
|
||||
}
|
||||
|
||||
/// 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;
|
||||
final actorId = _client.auth.currentUser?.id as String?;
|
||||
if (actorId == null) return;
|
||||
if (announcement.visibleRoles.isEmpty) return;
|
||||
|
||||
@@ -354,9 +624,8 @@ class AnnouncementsController {
|
||||
.inFilter('role', announcement.visibleRoles);
|
||||
final userIds = (profiles as List)
|
||||
.map((p) => p['id'] as String)
|
||||
.where((id) => id != actorId)
|
||||
.where((uid) => uid != actorId)
|
||||
.toList();
|
||||
|
||||
if (userIds.isEmpty) return;
|
||||
|
||||
await _notifCtrl.createNotification(
|
||||
@@ -379,8 +648,47 @@ class AnnouncementsController {
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a comment.
|
||||
/// 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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../brick/cache_helpers.dart';
|
||||
import '../models/attendance_log.dart';
|
||||
import '../utils/app_time.dart';
|
||||
import '../utils/snackbar.dart' show isOfflineSaveError;
|
||||
import 'connectivity_provider.dart';
|
||||
import 'profile_provider.dart';
|
||||
import 'reports_provider.dart';
|
||||
import 'supabase_provider.dart';
|
||||
import 'stream_recovery.dart';
|
||||
import 'realtime_controller.dart';
|
||||
|
||||
/// Date range for attendance logbook, defaults to "Last 7 Days".
|
||||
/// Date range for attendance logbook, defaults to "Today".
|
||||
final attendanceDateRangeProvider = StateProvider<ReportDateRange>((ref) {
|
||||
final now = AppTime.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
@@ -25,12 +30,32 @@ final attendanceDateRangeProvider = StateProvider<ReportDateRange>((ref) {
|
||||
/// Filter for logbook users (multi-select). If empty, all users are shown.
|
||||
final attendanceUserFilterProvider = StateProvider<List<String>>((ref) => []);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Offline-pending state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Check-ins created offline (typed model — merged into live stream).
|
||||
final offlinePendingCheckInsProvider =
|
||||
StateProvider<List<AttendanceLog>>((ref) => const []);
|
||||
|
||||
/// Raw RPC params for offline check-ins — used for reconnect replay.
|
||||
/// Each entry: {p_duty_id, p_lat, p_lng, localId}
|
||||
final offlinePendingCheckInRawProvider =
|
||||
StateProvider<List<Map<String, dynamic>>>((ref) => const []);
|
||||
|
||||
/// Attendance field updates made offline, keyed by attendance log id.
|
||||
/// Covers: checkouts, verification status updates, skipVerification.
|
||||
final offlinePendingAttendanceUpdatesProvider =
|
||||
StateProvider<Map<String, Map<String, dynamic>>>((ref) => const {});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// All visible attendance logs (own for standard, all for admin/dispatcher/it_staff).
|
||||
final attendanceLogsProvider = StreamProvider<List<AttendanceLog>>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
|
||||
// Use .select() so the stream is only recreated when the user id or role
|
||||
// actually changes (not on avatar/name edits, etc.).
|
||||
final profileId = ref.watch(
|
||||
currentProfileProvider.select((p) => p.valueOrNull?.id),
|
||||
);
|
||||
@@ -45,6 +70,56 @@ final attendanceLogsProvider = StreamProvider<List<AttendanceLog>>((ref) {
|
||||
profileRole == 'dispatcher' ||
|
||||
profileRole == 'it_staff';
|
||||
|
||||
final pendingCheckIns = ref.watch(offlinePendingCheckInsProvider);
|
||||
final pendingUpdates = ref.watch(offlinePendingAttendanceUpdatesProvider);
|
||||
|
||||
List<AttendanceLog> applyPending(List<AttendanceLog> rows) {
|
||||
final byId = {for (final r in rows) r.id: r};
|
||||
|
||||
// Apply pending checkouts and other field updates
|
||||
pendingUpdates.forEach((id, fields) {
|
||||
final existing = byId[id];
|
||||
if (existing == null) return;
|
||||
byId[id] = AttendanceLog(
|
||||
id: existing.id,
|
||||
userId: existing.userId,
|
||||
dutyScheduleId: existing.dutyScheduleId,
|
||||
shiftType: existing.shiftType,
|
||||
checkInAt: existing.checkInAt,
|
||||
checkInLat: existing.checkInLat,
|
||||
checkInLng: existing.checkInLng,
|
||||
checkOutAt: fields.containsKey('check_out_at')
|
||||
? (fields['check_out_at'] != null
|
||||
? AppTime.parse(fields['check_out_at'] as String)
|
||||
: null)
|
||||
: existing.checkOutAt,
|
||||
checkOutLat: (fields['check_out_lat'] as num?)?.toDouble() ??
|
||||
existing.checkOutLat,
|
||||
checkOutLng: (fields['check_out_lng'] as num?)?.toDouble() ??
|
||||
existing.checkOutLng,
|
||||
justification: existing.justification,
|
||||
checkOutJustification:
|
||||
(fields['check_out_justification'] as String?) ??
|
||||
existing.checkOutJustification,
|
||||
verificationStatus:
|
||||
(fields['verification_status'] as String?) ??
|
||||
existing.verificationStatus,
|
||||
checkInVerificationPhotoUrl: existing.checkInVerificationPhotoUrl,
|
||||
checkOutVerificationPhotoUrl: existing.checkOutVerificationPhotoUrl,
|
||||
);
|
||||
});
|
||||
|
||||
// Append offline check-ins not yet on server
|
||||
for (final p in pendingCheckIns) {
|
||||
if (hasFullAccess || p.userId == profileId) {
|
||||
byId.putIfAbsent(p.id, () => p);
|
||||
}
|
||||
}
|
||||
|
||||
return byId.values.toList()
|
||||
..sort((a, b) => b.checkInAt.compareTo(a.checkInAt));
|
||||
}
|
||||
|
||||
final wrapper = StreamRecoveryWrapper<AttendanceLog>(
|
||||
stream: hasFullAccess
|
||||
? client
|
||||
@@ -68,55 +143,225 @@ final attendanceLogsProvider = StreamProvider<List<AttendanceLog>>((ref) {
|
||||
fromMap: AttendanceLog.fromMap,
|
||||
channelName: 'attendance_logs',
|
||||
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
|
||||
onOfflineData: () async {
|
||||
final all = await cachedListFromBrick<AttendanceLog>();
|
||||
final filtered = hasFullAccess
|
||||
? all
|
||||
: all.where((log) => log.userId == profileId).toList();
|
||||
filtered.sort((a, b) => b.checkInAt.compareTo(a.checkInAt));
|
||||
return applyPending(filtered);
|
||||
},
|
||||
onCacheMirror: (rows) =>
|
||||
mirrorBatchToBrick<AttendanceLog>(rows, tag: 'attendance_logs'),
|
||||
);
|
||||
|
||||
ref.onDispose(wrapper.dispose);
|
||||
return wrapper.stream.map((result) => result.data);
|
||||
|
||||
// Reconnect-replay: POST/PATCH queued items directly, bypassing Brick's queue.
|
||||
ref.listen<bool>(isOnlineProvider, (wasOnline, isNowOnline) async {
|
||||
if (wasOnline == true || !isNowOnline) return;
|
||||
|
||||
// 1. Replay offline check-ins
|
||||
final pendingRaw = List<Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingCheckInRawProvider),
|
||||
);
|
||||
if (pendingRaw.isNotEmpty) {
|
||||
debugPrint(
|
||||
'[attendanceLogsProvider] reconnected — syncing ${pendingRaw.length} check-in(s)',
|
||||
);
|
||||
final syncedLocalIds = <String>[];
|
||||
for (final params in pendingRaw) {
|
||||
final localId = params['localId'] as String;
|
||||
try {
|
||||
await client.rpc(
|
||||
'attendance_check_in',
|
||||
params: {
|
||||
'p_duty_id': params['p_duty_id'],
|
||||
'p_lat': params['p_lat'],
|
||||
'p_lng': params['p_lng'],
|
||||
},
|
||||
);
|
||||
syncedLocalIds.add(localId);
|
||||
} catch (e) {
|
||||
final msg = e.toString();
|
||||
if (msg.contains('23505') || msg.contains('duplicate key')) {
|
||||
syncedLocalIds.add(localId);
|
||||
} else {
|
||||
debugPrint(
|
||||
'[attendanceLogsProvider] failed to sync check-in localId=$localId: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (syncedLocalIds.isNotEmpty) {
|
||||
final remainingRaw = ref
|
||||
.read(offlinePendingCheckInRawProvider)
|
||||
.where((p) => !syncedLocalIds.contains(p['localId'] as String))
|
||||
.toList();
|
||||
ref.read(offlinePendingCheckInRawProvider.notifier).state =
|
||||
List.unmodifiable(remainingRaw);
|
||||
|
||||
final remainingModels = ref
|
||||
.read(offlinePendingCheckInsProvider)
|
||||
.where((l) => !syncedLocalIds.contains(l.id))
|
||||
.toList();
|
||||
ref.read(offlinePendingCheckInsProvider.notifier).state =
|
||||
List.unmodifiable(remainingModels);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Replay pending checkouts and field updates (direct PATCH)
|
||||
final pendingUpdatesMap = Map<String, Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingAttendanceUpdatesProvider),
|
||||
);
|
||||
if (pendingUpdatesMap.isNotEmpty) {
|
||||
debugPrint(
|
||||
'[attendanceLogsProvider] reconnected — syncing ${pendingUpdatesMap.length} attendance update(s)',
|
||||
);
|
||||
final syncedIds = <String>[];
|
||||
for (final entry in pendingUpdatesMap.entries) {
|
||||
final attendanceId = entry.key;
|
||||
final fields = Map<String, dynamic>.from(entry.value);
|
||||
|
||||
// If this was a checkout via RPC, call the RPC instead of a raw PATCH
|
||||
if (fields.containsKey('_rpc_checkout')) {
|
||||
fields.remove('_rpc_checkout');
|
||||
try {
|
||||
await client.rpc(
|
||||
'attendance_check_out',
|
||||
params: {
|
||||
'p_attendance_id': attendanceId,
|
||||
'p_lat': fields['check_out_lat'],
|
||||
'p_lng': fields['check_out_lng'],
|
||||
'p_justification': fields['check_out_justification'],
|
||||
},
|
||||
);
|
||||
syncedIds.add(attendanceId);
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'[attendanceLogsProvider] failed to sync checkout id=$attendanceId: $e',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await client
|
||||
.from('attendance_logs')
|
||||
.update(fields)
|
||||
.eq('id', attendanceId);
|
||||
syncedIds.add(attendanceId);
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'[attendanceLogsProvider] failed to sync update id=$attendanceId: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (syncedIds.isNotEmpty) {
|
||||
final remaining = Map<String, Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingAttendanceUpdatesProvider),
|
||||
);
|
||||
for (final id in syncedIds) {
|
||||
remaining.remove(id);
|
||||
}
|
||||
ref.read(offlinePendingAttendanceUpdatesProvider.notifier).state =
|
||||
remaining;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return wrapper.stream.map((result) => applyPending(result.data));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Controller
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
final attendanceControllerProvider = Provider<AttendanceController>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return AttendanceController(client);
|
||||
return AttendanceController(client, ref);
|
||||
});
|
||||
|
||||
class AttendanceController {
|
||||
AttendanceController(this._client);
|
||||
AttendanceController(this._client, [this._ref]);
|
||||
|
||||
final SupabaseClient _client;
|
||||
final Ref? _ref;
|
||||
|
||||
/// Check in to a duty schedule. Returns the attendance log ID.
|
||||
/// Offline: queues locally via StateProvider, returns local UUID.
|
||||
Future<String?> checkIn({
|
||||
required String dutyScheduleId,
|
||||
required double lat,
|
||||
required double lng,
|
||||
String shiftType = 'normal',
|
||||
}) async {
|
||||
final data = await _client.rpc(
|
||||
'attendance_check_in',
|
||||
params: {'p_duty_id': dutyScheduleId, 'p_lat': lat, 'p_lng': lng},
|
||||
);
|
||||
return data as String?;
|
||||
try {
|
||||
final data = await _client.rpc(
|
||||
'attendance_check_in',
|
||||
params: {'p_duty_id': dutyScheduleId, 'p_lat': lat, 'p_lng': lng},
|
||||
);
|
||||
return data as String?;
|
||||
} catch (e) {
|
||||
if (!isOfflineSaveError(e)) rethrow;
|
||||
|
||||
final userId = _client.auth.currentUser?.id;
|
||||
if (userId == null) rethrow;
|
||||
|
||||
final id = const Uuid().v4();
|
||||
final log = AttendanceLog(
|
||||
id: id,
|
||||
userId: userId,
|
||||
dutyScheduleId: dutyScheduleId,
|
||||
shiftType: shiftType,
|
||||
checkInAt: AppTime.now(),
|
||||
checkInLat: lat,
|
||||
checkInLng: lng,
|
||||
verificationStatus: 'pending',
|
||||
);
|
||||
|
||||
_queuePendingCheckIn(
|
||||
log,
|
||||
{'p_duty_id': dutyScheduleId, 'p_lat': lat, 'p_lng': lng, 'localId': id},
|
||||
);
|
||||
debugPrint('[AttendanceController] checkIn queued offline: $id');
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check out from an attendance log.
|
||||
/// Check out from an attendance log. Offline: queues the checkout params.
|
||||
Future<void> checkOut({
|
||||
required String attendanceId,
|
||||
required double lat,
|
||||
required double lng,
|
||||
String? justification,
|
||||
}) async {
|
||||
await _client.rpc(
|
||||
'attendance_check_out',
|
||||
params: {
|
||||
'p_attendance_id': attendanceId,
|
||||
'p_lat': lat,
|
||||
'p_lng': lng,
|
||||
'p_justification': justification,
|
||||
},
|
||||
);
|
||||
try {
|
||||
await _client.rpc(
|
||||
'attendance_check_out',
|
||||
params: {
|
||||
'p_attendance_id': attendanceId,
|
||||
'p_lat': lat,
|
||||
'p_lng': lng,
|
||||
'p_justification': justification,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
if (!isOfflineSaveError(e)) rethrow;
|
||||
|
||||
_queueAttendanceUpdate(attendanceId, {
|
||||
'_rpc_checkout': true,
|
||||
'check_out_at': AppTime.now().toIso8601String(),
|
||||
'check_out_lat': lat,
|
||||
'check_out_lng': lng,
|
||||
'check_out_justification': justification,
|
||||
});
|
||||
debugPrint(
|
||||
'[AttendanceController] checkOut queued offline: $attendanceId',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Overtime check-in (no pre-existing schedule required).
|
||||
/// Creates an overtime duty schedule + attendance log in one RPC call.
|
||||
/// Overtime check-in (online-only — requires server-side schedule creation).
|
||||
Future<String?> overtimeCheckIn({
|
||||
required double lat,
|
||||
required double lng,
|
||||
@@ -130,41 +375,99 @@ class AttendanceController {
|
||||
}
|
||||
|
||||
/// Upload a verification selfie and update the attendance log.
|
||||
/// Offline: queues the status update only (photo bytes are not persisted).
|
||||
Future<void> uploadVerification({
|
||||
required String attendanceId,
|
||||
required Uint8List bytes,
|
||||
required String fileName,
|
||||
required String status, // 'verified', 'unverified'
|
||||
required String status,
|
||||
bool isCheckOut = false,
|
||||
}) async {
|
||||
final userId = _client.auth.currentUser!.id;
|
||||
final ext = fileName.split('.').last.toLowerCase();
|
||||
final prefix = isCheckOut ? 'checkout' : 'checkin';
|
||||
final path = '$userId/${prefix}_$attendanceId.$ext';
|
||||
await _client.storage
|
||||
.from('attendance-verification')
|
||||
.uploadBinary(
|
||||
path,
|
||||
bytes,
|
||||
fileOptions: const FileOptions(upsert: true),
|
||||
);
|
||||
final url = _client.storage
|
||||
.from('attendance-verification')
|
||||
.getPublicUrl(path);
|
||||
final column = isCheckOut
|
||||
? 'check_out_verification_photo_url'
|
||||
: 'check_in_verification_photo_url';
|
||||
await _client
|
||||
.from('attendance_logs')
|
||||
.update({'verification_status': status, column: url})
|
||||
.eq('id', attendanceId);
|
||||
|
||||
try {
|
||||
await _client.storage
|
||||
.from('attendance-verification')
|
||||
.uploadBinary(
|
||||
path,
|
||||
bytes,
|
||||
fileOptions: const FileOptions(upsert: true),
|
||||
);
|
||||
final url = _client.storage
|
||||
.from('attendance-verification')
|
||||
.getPublicUrl(path);
|
||||
final column = isCheckOut
|
||||
? 'check_out_verification_photo_url'
|
||||
: 'check_in_verification_photo_url';
|
||||
await _client
|
||||
.from('attendance_logs')
|
||||
.update({'verification_status': status, column: url})
|
||||
.eq('id', attendanceId);
|
||||
} catch (e) {
|
||||
if (!isOfflineSaveError(e)) rethrow;
|
||||
// Queue status update without photo URL — photo bytes are not persisted
|
||||
// across sessions; user will need to re-upload photo when online.
|
||||
_queueAttendanceUpdate(attendanceId, {'verification_status': status});
|
||||
debugPrint(
|
||||
'[AttendanceController] uploadVerification status queued offline (photo not persisted): $attendanceId',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark an attendance log as skipped verification.
|
||||
/// Mark an attendance log as skipped verification. Offline: queues the patch.
|
||||
Future<void> skipVerification(String attendanceId) async {
|
||||
await _client
|
||||
.from('attendance_logs')
|
||||
.update({'verification_status': 'skipped'})
|
||||
.eq('id', attendanceId);
|
||||
try {
|
||||
await _client
|
||||
.from('attendance_logs')
|
||||
.update({'verification_status': 'skipped'})
|
||||
.eq('id', attendanceId);
|
||||
} catch (e) {
|
||||
if (!isOfflineSaveError(e)) rethrow;
|
||||
_queueAttendanceUpdate(attendanceId, {'verification_status': 'skipped'});
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Queue helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
void _queuePendingCheckIn(
|
||||
AttendanceLog log,
|
||||
Map<String, dynamic> rpcParams,
|
||||
) {
|
||||
final ref = _ref;
|
||||
if (ref == null) return;
|
||||
|
||||
final currentModels = List<AttendanceLog>.from(
|
||||
ref.read(offlinePendingCheckInsProvider),
|
||||
);
|
||||
currentModels.add(log);
|
||||
ref.read(offlinePendingCheckInsProvider.notifier).state =
|
||||
List.unmodifiable(currentModels);
|
||||
|
||||
final currentRaw = List<Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingCheckInRawProvider),
|
||||
);
|
||||
currentRaw.add(rpcParams);
|
||||
ref.read(offlinePendingCheckInRawProvider.notifier).state =
|
||||
List.unmodifiable(currentRaw);
|
||||
|
||||
mirrorBatchToBrick<AttendanceLog>([log], tag: 'offline_checkin');
|
||||
}
|
||||
|
||||
void _queueAttendanceUpdate(
|
||||
String attendanceId,
|
||||
Map<String, dynamic> fields,
|
||||
) {
|
||||
final ref = _ref;
|
||||
if (ref == null) return;
|
||||
final current = Map<String, Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingAttendanceUpdatesProvider),
|
||||
);
|
||||
current[attendanceId] = {...?current[attendanceId], ...fields};
|
||||
ref.read(offlinePendingAttendanceUpdatesProvider.notifier).state = current;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../brick/cache_helpers.dart';
|
||||
import '../models/chat_message.dart';
|
||||
import '../utils/app_time.dart';
|
||||
import '../utils/snackbar.dart' show isOfflineSaveError;
|
||||
import 'connectivity_provider.dart';
|
||||
import 'supabase_provider.dart';
|
||||
import 'stream_recovery.dart';
|
||||
import 'realtime_controller.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Offline-pending state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Pending chat messages keyed by threadId.
|
||||
/// Messages sync on reconnect when the thread's provider is active.
|
||||
final offlinePendingChatMessagesProvider =
|
||||
StateProvider<Map<String, List<ChatMessage>>>((ref) => const {});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Real-time chat messages for a swap request thread.
|
||||
final chatMessagesProvider = StreamProvider.family<List<ChatMessage>, String>((
|
||||
ref,
|
||||
@@ -13,6 +32,19 @@ final chatMessagesProvider = StreamProvider.family<List<ChatMessage>, String>((
|
||||
) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
|
||||
final pendingByThread = ref.watch(offlinePendingChatMessagesProvider);
|
||||
final pendingForThread = pendingByThread[threadId] ?? const [];
|
||||
|
||||
List<ChatMessage> applyPending(List<ChatMessage> rows) {
|
||||
if (pendingForThread.isEmpty) return rows;
|
||||
final byId = {for (final r in rows) r.id: r};
|
||||
for (final p in pendingForThread) {
|
||||
byId.putIfAbsent(p.id, () => p);
|
||||
}
|
||||
return byId.values.toList()
|
||||
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||
}
|
||||
|
||||
final wrapper = StreamRecoveryWrapper<ChatMessage>(
|
||||
stream: client
|
||||
.from('chat_messages')
|
||||
@@ -30,32 +62,126 @@ final chatMessagesProvider = StreamProvider.family<List<ChatMessage>, String>((
|
||||
fromMap: ChatMessage.fromMap,
|
||||
channelName: 'chat_messages_$threadId',
|
||||
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
|
||||
onOfflineData: () async {
|
||||
final all = await cachedListFromBrick<ChatMessage>();
|
||||
final filtered = all
|
||||
.where((m) => m.threadId == threadId)
|
||||
.toList()
|
||||
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||
return applyPending(filtered);
|
||||
},
|
||||
onCacheMirror: (rows) =>
|
||||
mirrorBatchToBrick<ChatMessage>(rows, tag: 'chat_messages'),
|
||||
);
|
||||
|
||||
ref.onDispose(wrapper.dispose);
|
||||
return wrapper.stream.map((result) => result.data);
|
||||
|
||||
// Reconnect-replay: fires when this thread's screen is active.
|
||||
ref.listen<bool>(isOnlineProvider, (wasOnline, isNowOnline) async {
|
||||
if (wasOnline == true || !isNowOnline) return;
|
||||
|
||||
final pending = List<ChatMessage>.from(
|
||||
(ref.read(offlinePendingChatMessagesProvider)[threadId] ?? []),
|
||||
);
|
||||
if (pending.isEmpty) return;
|
||||
|
||||
debugPrint(
|
||||
'[chatMessagesProvider($threadId)] reconnected — syncing ${pending.length} message(s)',
|
||||
);
|
||||
final synced = <String>[];
|
||||
for (final msg in pending) {
|
||||
try {
|
||||
await client.from('chat_messages').insert({
|
||||
'id': msg.id,
|
||||
'thread_id': msg.threadId,
|
||||
'sender_id': msg.senderId,
|
||||
'body': msg.body,
|
||||
});
|
||||
synced.add(msg.id);
|
||||
} catch (e) {
|
||||
final s = e.toString();
|
||||
if (s.contains('23505') || s.contains('duplicate key')) {
|
||||
synced.add(msg.id);
|
||||
} else {
|
||||
debugPrint(
|
||||
'[chatMessagesProvider] failed to sync message id=${msg.id}: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (synced.isNotEmpty) {
|
||||
final current = Map<String, List<ChatMessage>>.from(
|
||||
ref.read(offlinePendingChatMessagesProvider),
|
||||
);
|
||||
final remaining =
|
||||
(current[threadId] ?? []).where((m) => !synced.contains(m.id)).toList();
|
||||
if (remaining.isEmpty) {
|
||||
current.remove(threadId);
|
||||
} else {
|
||||
current[threadId] = remaining;
|
||||
}
|
||||
ref.read(offlinePendingChatMessagesProvider.notifier).state =
|
||||
Map.unmodifiable(current);
|
||||
}
|
||||
});
|
||||
|
||||
return wrapper.stream.map((result) => applyPending(result.data));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Controller
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
final chatControllerProvider = Provider<ChatController>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return ChatController(client);
|
||||
return ChatController(client, ref);
|
||||
});
|
||||
|
||||
class ChatController {
|
||||
ChatController(this._client);
|
||||
ChatController(this._client, [this._ref]);
|
||||
|
||||
final SupabaseClient _client;
|
||||
final Ref? _ref;
|
||||
|
||||
/// Send a message to a thread. Offline: queues locally.
|
||||
Future<void> sendMessage({
|
||||
required String threadId,
|
||||
required String body,
|
||||
}) async {
|
||||
final userId = _client.auth.currentUser?.id;
|
||||
if (userId == null) throw Exception('Not authenticated');
|
||||
await _client.from('chat_messages').insert({
|
||||
'thread_id': threadId,
|
||||
'sender_id': userId,
|
||||
'body': body,
|
||||
});
|
||||
|
||||
final id = const Uuid().v4();
|
||||
|
||||
try {
|
||||
await _client.from('chat_messages').insert({
|
||||
'id': id,
|
||||
'thread_id': threadId,
|
||||
'sender_id': userId,
|
||||
'body': body,
|
||||
});
|
||||
} catch (e) {
|
||||
if (!isOfflineSaveError(e)) rethrow;
|
||||
|
||||
final message = ChatMessage(
|
||||
id: id,
|
||||
threadId: threadId,
|
||||
senderId: userId,
|
||||
body: body,
|
||||
createdAt: AppTime.now(),
|
||||
);
|
||||
|
||||
final ref = _ref;
|
||||
if (ref != null) {
|
||||
final current = Map<String, List<ChatMessage>>.from(
|
||||
ref.read(offlinePendingChatMessagesProvider),
|
||||
);
|
||||
current[threadId] = [...(current[threadId] ?? []), message];
|
||||
ref.read(offlinePendingChatMessagesProvider.notifier).state =
|
||||
Map.unmodifiable(current);
|
||||
}
|
||||
mirrorBatchToBrick<ChatMessage>([message], tag: 'offline_chat');
|
||||
debugPrint('[ChatController] sendMessage queued offline: $id');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import '../brick/cache_warmer.dart';
|
||||
import '../brick/repository.dart';
|
||||
import 'stream_recovery.dart';
|
||||
|
||||
/// Current online/offline status.
|
||||
/// Defaults to true so the UI doesn't flash an offline banner on startup.
|
||||
final isOnlineProvider = StateProvider<bool>((ref) => true);
|
||||
|
||||
/// Activating this provider starts a periodic connectivity poll.
|
||||
/// Watch it from the root widget to keep it alive.
|
||||
final connectivityMonitorProvider = Provider.autoDispose<void>((ref) {
|
||||
// Wire up the global online check for StreamRecoveryWrapper so all stream
|
||||
// wrappers can suppress recovery while offline without per-instance setup.
|
||||
StreamRecoveryWrapper.setIsOnlineCallback(() => ref.read(isOnlineProvider));
|
||||
|
||||
bool previouslyOnline = true;
|
||||
|
||||
Future<void> check() async {
|
||||
final online = await ConnectivityMonitor.check();
|
||||
final wasOnline = ref.read(isOnlineProvider);
|
||||
if (online != wasOnline) {
|
||||
ref.read(isOnlineProvider.notifier).state = online;
|
||||
}
|
||||
if (previouslyOnline && !online) {
|
||||
_onDisconnected();
|
||||
}
|
||||
if (!previouslyOnline && online) {
|
||||
_onReconnected();
|
||||
}
|
||||
previouslyOnline = online;
|
||||
}
|
||||
|
||||
// Run immediately, then every 5 seconds.
|
||||
check();
|
||||
final timer = Timer.periodic(const Duration(seconds: 5), (_) => check());
|
||||
ref.onDispose(timer.cancel);
|
||||
});
|
||||
|
||||
/// Pings the app's own backend to determine online status.
|
||||
class ConnectivityMonitor {
|
||||
static Future<bool> check() async {
|
||||
try {
|
||||
final res = await http
|
||||
.head(Uri.parse('https://tasq.crmc.ph'))
|
||||
.timeout(const Duration(seconds: 5));
|
||||
return res.statusCode < 500;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onDisconnected() {
|
||||
debugPrint('[Connectivity] Went offline — pausing Brick request queue');
|
||||
// Stop the retry loop so the queue doesn't spam network errors while offline.
|
||||
// Queued writes are preserved in SQLite and will sync when _onReconnected fires.
|
||||
AppRepository.offlineQueue?.stop();
|
||||
}
|
||||
|
||||
void _onReconnected() {
|
||||
debugPrint('[Connectivity] Back online — queued writes will sync via Brick');
|
||||
if (!AppRepository.isConfigured) return;
|
||||
|
||||
// Resume the Brick HTTP queue so queued writes (e.g. offline task creation)
|
||||
// are replayed against Supabase now that the network is available again.
|
||||
AppRepository.offlineQueue?.start();
|
||||
|
||||
// Restart any Supabase realtime streams that were suppressed while offline.
|
||||
// Streams that disconnected due to no network are NOT in an error-retry loop
|
||||
// (recovery was suppressed); this kick-starts them back to live updates.
|
||||
StreamRecoveryWrapper.notifyAllOnlineRestored();
|
||||
|
||||
// Re-warm the Brick cache so the local SQLite mirror reflects any changes
|
||||
// that happened on the server while we were offline. Fire-and-forget — it
|
||||
// runs in the background and never throws.
|
||||
final user = Supabase.instance.client.auth.currentUser;
|
||||
if (user != null) {
|
||||
CacheWarmer.warmAll(Supabase.instance.client);
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,16 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../brick/cache_helpers.dart';
|
||||
import '../models/it_service_request.dart';
|
||||
import '../models/it_service_request_assignment.dart';
|
||||
import '../models/it_service_request_activity_log.dart';
|
||||
import '../models/it_service_request_action.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 'user_offices_provider.dart';
|
||||
@@ -60,6 +66,27 @@ final itServiceRequestQueryProvider = StateProvider<ItServiceRequestQuery>(
|
||||
(ref) => const ItServiceRequestQuery(),
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Offline-pending state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// IT Service Requests created offline (typed model — merged into live stream).
|
||||
final offlinePendingItServiceRequestsProvider =
|
||||
StateProvider<List<ItServiceRequest>>((ref) => const []);
|
||||
|
||||
/// Raw RPC params for offline-created requests — used for reconnect replay.
|
||||
final offlinePendingItServiceRequestRawProvider =
|
||||
StateProvider<List<Map<String, dynamic>>>((ref) => const []);
|
||||
|
||||
/// Field-level updates to existing requests made offline, keyed by request id.
|
||||
final offlinePendingItServiceRequestUpdatesProvider =
|
||||
StateProvider<Map<String, Map<String, dynamic>>>((ref) => const {});
|
||||
|
||||
/// Pending action-taken entries submitted offline.
|
||||
/// Each entry: {id, request_id, user_id, action_taken}
|
||||
final offlinePendingIsrActionsProvider =
|
||||
StateProvider<List<Map<String, dynamic>>>((ref) => const []);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stream providers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -71,6 +98,24 @@ final itServiceRequestsProvider = StreamProvider<List<ItServiceRequest>>((ref) {
|
||||
final profile = ref.watch(currentProfileProvider).valueOrNull;
|
||||
final userOfficesAsync = ref.watch(userOfficesProvider);
|
||||
|
||||
final pendingNew = ref.watch(offlinePendingItServiceRequestsProvider);
|
||||
final pendingUpdates =
|
||||
ref.watch(offlinePendingItServiceRequestUpdatesProvider);
|
||||
|
||||
List<ItServiceRequest> applyPending(List<ItServiceRequest> 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] = _applyUpdateFields(existing, fields);
|
||||
});
|
||||
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<ItServiceRequest>(
|
||||
stream: client
|
||||
.from('it_service_requests')
|
||||
@@ -87,12 +132,140 @@ final itServiceRequestsProvider = StreamProvider<List<ItServiceRequest>>((ref) {
|
||||
fromMap: ItServiceRequest.fromMap,
|
||||
channelName: 'it_service_requests',
|
||||
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
|
||||
onOfflineData: () async {
|
||||
final all = await cachedListFromBrick<ItServiceRequest>();
|
||||
all.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
return applyPending(all);
|
||||
},
|
||||
onCacheMirror: (rows) =>
|
||||
mirrorBatchToBrick<ItServiceRequest>(rows, tag: 'it_service_requests'),
|
||||
);
|
||||
|
||||
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 requests (via RPC)
|
||||
final pendingRaw = List<Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingItServiceRequestRawProvider),
|
||||
);
|
||||
if (pendingRaw.isNotEmpty) {
|
||||
debugPrint(
|
||||
'[itServiceRequestsProvider] reconnected — syncing ${pendingRaw.length} request(s)',
|
||||
);
|
||||
final syncedIds = <String>[];
|
||||
for (final params in pendingRaw) {
|
||||
final id = params['p_id'] as String;
|
||||
try {
|
||||
await client.rpc(
|
||||
'insert_it_service_request_with_number',
|
||||
params: params,
|
||||
);
|
||||
syncedIds.add(id);
|
||||
} catch (e) {
|
||||
final msg = e.toString();
|
||||
if (msg.contains('23505') || msg.contains('duplicate key')) {
|
||||
syncedIds.add(id);
|
||||
} else {
|
||||
debugPrint(
|
||||
'[itServiceRequestsProvider] failed to sync id=$id: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (syncedIds.isNotEmpty) {
|
||||
final remainingRaw = ref
|
||||
.read(offlinePendingItServiceRequestRawProvider)
|
||||
.where((p) => !syncedIds.contains(p['p_id'] as String))
|
||||
.toList();
|
||||
ref.read(offlinePendingItServiceRequestRawProvider.notifier).state =
|
||||
List.unmodifiable(remainingRaw);
|
||||
|
||||
final remainingModels = ref
|
||||
.read(offlinePendingItServiceRequestsProvider)
|
||||
.where((r) => !syncedIds.contains(r.id))
|
||||
.toList();
|
||||
ref.read(offlinePendingItServiceRequestsProvider.notifier).state =
|
||||
List.unmodifiable(remainingModels);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Replay offline field/status updates
|
||||
final pendingUpdatesMap = Map<String, Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingItServiceRequestUpdatesProvider),
|
||||
);
|
||||
if (pendingUpdatesMap.isNotEmpty) {
|
||||
debugPrint(
|
||||
'[itServiceRequestsProvider] reconnected — syncing ${pendingUpdatesMap.length} update(s)',
|
||||
);
|
||||
final syncedIds = <String>[];
|
||||
for (final entry in pendingUpdatesMap.entries) {
|
||||
try {
|
||||
await client
|
||||
.from('it_service_requests')
|
||||
.update(entry.value)
|
||||
.eq('id', entry.key);
|
||||
syncedIds.add(entry.key);
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'[itServiceRequestsProvider] failed to sync update id=${entry.key}: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (syncedIds.isNotEmpty) {
|
||||
final remaining = Map<String, Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingItServiceRequestUpdatesProvider),
|
||||
);
|
||||
for (final id in syncedIds) {
|
||||
remaining.remove(id);
|
||||
}
|
||||
ref.read(offlinePendingItServiceRequestUpdatesProvider.notifier).state =
|
||||
remaining;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Replay pending action-taken entries
|
||||
final pendingActions = List<Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingIsrActionsProvider),
|
||||
);
|
||||
if (pendingActions.isNotEmpty) {
|
||||
debugPrint(
|
||||
'[itServiceRequestsProvider] reconnected — syncing ${pendingActions.length} action(s)',
|
||||
);
|
||||
final syncedIds = <String>[];
|
||||
for (final action in pendingActions) {
|
||||
final id = action['id'] as String;
|
||||
try {
|
||||
await client
|
||||
.from('it_service_request_actions')
|
||||
.upsert(action, onConflict: 'request_id,user_id');
|
||||
syncedIds.add(id);
|
||||
} catch (e) {
|
||||
final msg = e.toString();
|
||||
if (msg.contains('23505') || msg.contains('duplicate key')) {
|
||||
syncedIds.add(id);
|
||||
} else {
|
||||
debugPrint(
|
||||
'[itServiceRequestsProvider] failed to sync action id=$id: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (syncedIds.isNotEmpty) {
|
||||
final remaining = ref
|
||||
.read(offlinePendingIsrActionsProvider)
|
||||
.where((a) => !syncedIds.contains(a['id'] as String))
|
||||
.toList();
|
||||
ref.read(offlinePendingIsrActionsProvider.notifier).state =
|
||||
List.unmodifiable(remaining);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return wrapper.stream.map((result) {
|
||||
var items = result.data;
|
||||
// Standard users see only requests from their offices or created by them
|
||||
var items = applyPending(result.data);
|
||||
if (profile != null && profile.role == 'standard') {
|
||||
final officeIds = (userOfficesAsync.valueOrNull ?? [])
|
||||
.where((a) => a.userId == userId)
|
||||
@@ -145,6 +318,15 @@ final itServiceRequestAssignmentsProvider =
|
||||
onStatusChanged: ref
|
||||
.read(realtimeControllerProvider)
|
||||
.handleChannelStatus,
|
||||
onOfflineData: () async {
|
||||
final all = await cachedListFromBrick<ItServiceRequestAssignment>();
|
||||
all.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
return all;
|
||||
},
|
||||
onCacheMirror: (rows) => mirrorBatchToBrick<ItServiceRequestAssignment>(
|
||||
rows,
|
||||
tag: 'isr_assignments',
|
||||
),
|
||||
);
|
||||
|
||||
ref.onDispose(wrapper.dispose);
|
||||
@@ -177,6 +359,19 @@ final itServiceRequestActivityLogsProvider =
|
||||
onStatusChanged: ref
|
||||
.read(realtimeControllerProvider)
|
||||
.handleChannelStatus,
|
||||
onOfflineData: () async {
|
||||
final all = await cachedListFromBrick<ItServiceRequestActivityLog>();
|
||||
final filtered = all
|
||||
.where((l) => l.requestId == requestId)
|
||||
.toList()
|
||||
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
return filtered;
|
||||
},
|
||||
onCacheMirror: (rows) =>
|
||||
mirrorBatchToBrick<ItServiceRequestActivityLog>(
|
||||
rows,
|
||||
tag: 'isr_activity_logs',
|
||||
),
|
||||
);
|
||||
|
||||
ref.onDispose(wrapper.dispose);
|
||||
@@ -209,6 +404,18 @@ final itServiceRequestActionsProvider =
|
||||
onStatusChanged: ref
|
||||
.read(realtimeControllerProvider)
|
||||
.handleChannelStatus,
|
||||
onOfflineData: () async {
|
||||
final all = await cachedListFromBrick<ItServiceRequestAction>();
|
||||
final filtered = all
|
||||
.where((a) => a.requestId == requestId)
|
||||
.toList()
|
||||
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
return filtered;
|
||||
},
|
||||
onCacheMirror: (rows) => mirrorBatchToBrick<ItServiceRequestAction>(
|
||||
rows,
|
||||
tag: 'isr_actions',
|
||||
),
|
||||
);
|
||||
|
||||
ref.onDispose(wrapper.dispose);
|
||||
@@ -222,15 +429,19 @@ final itServiceRequestActionsProvider =
|
||||
final itServiceRequestControllerProvider = Provider<ItServiceRequestController>(
|
||||
(ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return ItServiceRequestController(client);
|
||||
return ItServiceRequestController(client, ref);
|
||||
},
|
||||
);
|
||||
|
||||
class ItServiceRequestController {
|
||||
ItServiceRequestController(this._client);
|
||||
ItServiceRequestController(this._client, [this._ref]);
|
||||
final SupabaseClient _client;
|
||||
final Ref? _ref;
|
||||
|
||||
/// Creates a new IT Service Request with auto-generated number.
|
||||
///
|
||||
/// Returns `{'id': ..., 'request_number': ...}` on success. If offline,
|
||||
/// queues locally and returns `{'id': localId, 'request_number': null, 'pending': true}`.
|
||||
Future<Map<String, dynamic>> createRequest({
|
||||
required String eventName,
|
||||
required List<String> services,
|
||||
@@ -243,18 +454,22 @@ class ItServiceRequestController {
|
||||
final userId = _client.auth.currentUser?.id;
|
||||
if (userId == null) throw Exception('Not authenticated');
|
||||
|
||||
final offlineId = const Uuid().v4();
|
||||
final rpcParams = {
|
||||
'p_id': offlineId,
|
||||
'p_event_name': eventName,
|
||||
'p_services': services,
|
||||
'p_creator_id': userId,
|
||||
'p_office_id': officeId,
|
||||
'p_requested_by': requestedBy,
|
||||
'p_requested_by_user_id': requestedByUserId,
|
||||
'p_status': status,
|
||||
};
|
||||
|
||||
try {
|
||||
final result = await _client.rpc(
|
||||
'insert_it_service_request_with_number',
|
||||
params: {
|
||||
'p_event_name': eventName,
|
||||
'p_services': services,
|
||||
'p_creator_id': userId,
|
||||
'p_office_id': officeId,
|
||||
'p_requested_by': requestedBy,
|
||||
'p_requested_by_user_id': requestedByUserId,
|
||||
'p_status': status,
|
||||
},
|
||||
params: rpcParams,
|
||||
);
|
||||
|
||||
final row = result is List
|
||||
@@ -269,7 +484,6 @@ class ItServiceRequestController {
|
||||
.eq('id', requestId);
|
||||
}
|
||||
|
||||
// Activity log
|
||||
await _client.from('it_service_request_activity_logs').insert({
|
||||
'request_id': requestId,
|
||||
'actor_id': userId,
|
||||
@@ -278,12 +492,36 @@ class ItServiceRequestController {
|
||||
|
||||
return {'id': requestId, 'request_number': row['request_number']};
|
||||
} catch (e) {
|
||||
debugPrint('createRequest error: $e');
|
||||
rethrow;
|
||||
if (!isOfflineSaveError(e)) {
|
||||
debugPrint('createRequest error: $e');
|
||||
rethrow;
|
||||
}
|
||||
|
||||
final now = DateTime.now().toUtc();
|
||||
final local = ItServiceRequest(
|
||||
id: offlineId,
|
||||
services: services,
|
||||
eventName: eventName,
|
||||
status: status,
|
||||
outsidePremiseAllowed: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
creatorId: userId,
|
||||
officeId: officeId,
|
||||
requestedBy: requestedBy,
|
||||
requestedByUserId: requestedByUserId,
|
||||
servicesOther: servicesOther,
|
||||
);
|
||||
|
||||
_queuePendingRequest(local, rpcParams);
|
||||
debugPrint(
|
||||
'[ItServiceRequestController] createRequest queued offline: $offlineId',
|
||||
);
|
||||
return {'id': offlineId, 'request_number': null, 'pending': true};
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates IT Service Request fields.
|
||||
/// Updates IT Service Request fields. Offline: queues the field patch.
|
||||
Future<void> updateRequest({
|
||||
required String requestId,
|
||||
String? eventName,
|
||||
@@ -345,22 +583,26 @@ class ItServiceRequestController {
|
||||
|
||||
if (updates.isEmpty) return;
|
||||
|
||||
await _client
|
||||
.from('it_service_requests')
|
||||
.update(updates)
|
||||
.eq('id', requestId);
|
||||
try {
|
||||
await _client
|
||||
.from('it_service_requests')
|
||||
.update(updates)
|
||||
.eq('id', requestId);
|
||||
|
||||
// Log updated fields
|
||||
final userId = _client.auth.currentUser?.id;
|
||||
await _client.from('it_service_request_activity_logs').insert({
|
||||
'request_id': requestId,
|
||||
'actor_id': userId,
|
||||
'action_type': 'updated',
|
||||
'meta': {'fields': updates.keys.toList()},
|
||||
});
|
||||
final userId = _client.auth.currentUser?.id;
|
||||
await _client.from('it_service_request_activity_logs').insert({
|
||||
'request_id': requestId,
|
||||
'actor_id': userId,
|
||||
'action_type': 'updated',
|
||||
'meta': {'fields': updates.keys.toList()},
|
||||
});
|
||||
} catch (e) {
|
||||
if (!isOfflineSaveError(e)) rethrow;
|
||||
_queueRequestUpdate(requestId, updates);
|
||||
}
|
||||
}
|
||||
|
||||
/// Update only the status of an IT Service Request.
|
||||
/// Update only the status of an IT Service Request. Offline: queues.
|
||||
Future<void> updateStatus({
|
||||
required String requestId,
|
||||
required String status,
|
||||
@@ -383,44 +625,56 @@ class ItServiceRequestController {
|
||||
}
|
||||
}
|
||||
|
||||
await _client
|
||||
.from('it_service_requests')
|
||||
.update(updates)
|
||||
.eq('id', requestId);
|
||||
try {
|
||||
await _client
|
||||
.from('it_service_requests')
|
||||
.update(updates)
|
||||
.eq('id', requestId);
|
||||
|
||||
await _client.from('it_service_request_activity_logs').insert({
|
||||
'request_id': requestId,
|
||||
'actor_id': userId,
|
||||
'action_type': 'status_changed',
|
||||
'meta': {'status': status},
|
||||
});
|
||||
await _client.from('it_service_request_activity_logs').insert({
|
||||
'request_id': requestId,
|
||||
'actor_id': userId,
|
||||
'action_type': 'status_changed',
|
||||
'meta': {'status': status},
|
||||
});
|
||||
} catch (e) {
|
||||
if (!isOfflineSaveError(e)) rethrow;
|
||||
_queueRequestUpdate(requestId, updates);
|
||||
}
|
||||
}
|
||||
|
||||
/// Approve a request (admin only). Sets status to 'scheduled'.
|
||||
/// Approve a request (admin only). Offline: queues the status patch.
|
||||
Future<void> approveRequest({
|
||||
required String requestId,
|
||||
required String approverName,
|
||||
}) async {
|
||||
final userId = _client.auth.currentUser?.id;
|
||||
await _client
|
||||
.from('it_service_requests')
|
||||
.update({
|
||||
'status': 'scheduled',
|
||||
'approved_by': approverName,
|
||||
'approved_by_user_id': userId,
|
||||
'approved_at': DateTime.now().toUtc().toIso8601String(),
|
||||
})
|
||||
.eq('id', requestId);
|
||||
final updates = <String, dynamic>{
|
||||
'status': 'scheduled',
|
||||
'approved_by': approverName,
|
||||
'approved_by_user_id': userId,
|
||||
'approved_at': DateTime.now().toUtc().toIso8601String(),
|
||||
};
|
||||
|
||||
await _client.from('it_service_request_activity_logs').insert({
|
||||
'request_id': requestId,
|
||||
'actor_id': userId,
|
||||
'action_type': 'approved',
|
||||
});
|
||||
try {
|
||||
await _client
|
||||
.from('it_service_requests')
|
||||
.update(updates)
|
||||
.eq('id', requestId);
|
||||
|
||||
await _client.from('it_service_request_activity_logs').insert({
|
||||
'request_id': requestId,
|
||||
'actor_id': userId,
|
||||
'action_type': 'approved',
|
||||
});
|
||||
} catch (e) {
|
||||
if (!isOfflineSaveError(e)) rethrow;
|
||||
_queueRequestUpdate(requestId, updates);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Assignment management
|
||||
// Assignment management (online-only — admin/dispatcher function)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
Future<void> assignStaff({
|
||||
@@ -463,7 +717,7 @@ class ItServiceRequestController {
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Action Taken
|
||||
// Action Taken — offline-safe
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
Future<String> createOrUpdateAction({
|
||||
@@ -473,31 +727,44 @@ class ItServiceRequestController {
|
||||
final userId = _client.auth.currentUser?.id;
|
||||
if (userId == null) throw Exception('Not authenticated');
|
||||
|
||||
// Check if action already exists for this user
|
||||
final existing = await _client
|
||||
.from('it_service_request_actions')
|
||||
.select('id')
|
||||
.eq('request_id', requestId)
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle();
|
||||
final actionId = const Uuid().v4();
|
||||
|
||||
if (existing != null) {
|
||||
await _client
|
||||
try {
|
||||
final existing = await _client
|
||||
.from('it_service_request_actions')
|
||||
.update({'action_taken': actionTaken})
|
||||
.eq('id', existing['id']);
|
||||
return existing['id'] as String;
|
||||
} else {
|
||||
final result = await _client
|
||||
.from('it_service_request_actions')
|
||||
.insert({
|
||||
'request_id': requestId,
|
||||
'user_id': userId,
|
||||
'action_taken': actionTaken,
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
return result['id'] as String;
|
||||
.eq('request_id', requestId)
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle();
|
||||
|
||||
if (existing != null) {
|
||||
await _client
|
||||
.from('it_service_request_actions')
|
||||
.update({'action_taken': actionTaken})
|
||||
.eq('id', existing['id']);
|
||||
return existing['id'] as String;
|
||||
} else {
|
||||
final result = await _client
|
||||
.from('it_service_request_actions')
|
||||
.insert({
|
||||
'id': actionId,
|
||||
'request_id': requestId,
|
||||
'user_id': userId,
|
||||
'action_taken': actionTaken,
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
return result['id'] as String;
|
||||
}
|
||||
} catch (e) {
|
||||
if (!isOfflineSaveError(e)) rethrow;
|
||||
_queuePendingAction({
|
||||
'id': actionId,
|
||||
'request_id': requestId,
|
||||
'user_id': userId,
|
||||
'action_taken': actionTaken,
|
||||
});
|
||||
return actionId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -550,7 +817,6 @@ class ItServiceRequestController {
|
||||
}
|
||||
|
||||
Future<void> deleteEvidence({required String evidenceId}) async {
|
||||
// Get path first
|
||||
final row = await _client
|
||||
.from('it_service_request_evidence')
|
||||
.select('file_path')
|
||||
@@ -630,7 +896,6 @@ class ItServiceRequestController {
|
||||
await _client.storage.from('it_service_attachments').remove([path]);
|
||||
}
|
||||
|
||||
/// List evidence attachments for a request from the database.
|
||||
Future<List<Map<String, dynamic>>> listEvidence(String requestId) async {
|
||||
final rows = await _client
|
||||
.from('it_service_request_evidence')
|
||||
@@ -651,4 +916,151 @@ class ItServiceRequestController {
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Queue helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
void _queuePendingRequest(
|
||||
ItServiceRequest model,
|
||||
Map<String, dynamic> rpcParams,
|
||||
) {
|
||||
final ref = _ref;
|
||||
if (ref == null) return;
|
||||
|
||||
final currentModels = List<ItServiceRequest>.from(
|
||||
ref.read(offlinePendingItServiceRequestsProvider),
|
||||
);
|
||||
currentModels.add(model);
|
||||
ref.read(offlinePendingItServiceRequestsProvider.notifier).state =
|
||||
List.unmodifiable(currentModels);
|
||||
|
||||
final currentRaw = List<Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingItServiceRequestRawProvider),
|
||||
);
|
||||
currentRaw.add(rpcParams);
|
||||
ref.read(offlinePendingItServiceRequestRawProvider.notifier).state =
|
||||
List.unmodifiable(currentRaw);
|
||||
|
||||
mirrorBatchToBrick<ItServiceRequest>([model], tag: 'offline_isr');
|
||||
}
|
||||
|
||||
void _queueRequestUpdate(String requestId, Map<String, dynamic> fields) {
|
||||
final ref = _ref;
|
||||
if (ref == null) return;
|
||||
final current = Map<String, Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingItServiceRequestUpdatesProvider),
|
||||
);
|
||||
current[requestId] = {...?current[requestId], ...fields};
|
||||
ref.read(offlinePendingItServiceRequestUpdatesProvider.notifier).state =
|
||||
current;
|
||||
}
|
||||
|
||||
void _queuePendingAction(Map<String, dynamic> action) {
|
||||
final ref = _ref;
|
||||
if (ref == null) return;
|
||||
final current = List<Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingIsrActionsProvider),
|
||||
);
|
||||
// Replace existing entry for the same request+user if present
|
||||
final existing = current.indexWhere(
|
||||
(a) =>
|
||||
a['request_id'] == action['request_id'] &&
|
||||
a['user_id'] == action['user_id'],
|
||||
);
|
||||
if (existing >= 0) {
|
||||
current[existing] = action;
|
||||
} else {
|
||||
current.add(action);
|
||||
}
|
||||
ref.read(offlinePendingIsrActionsProvider.notifier).state =
|
||||
List.unmodifiable(current);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Field-patch helper (avoids copyWith pattern on a model with 25+ fields)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ItServiceRequest _applyUpdateFields(
|
||||
ItServiceRequest r,
|
||||
Map<String, dynamic> f,
|
||||
) {
|
||||
DateTime? parseOpt(String key) {
|
||||
if (!f.containsKey(key)) return null;
|
||||
final v = f[key];
|
||||
return v == null ? null : AppTime.parse(v as String);
|
||||
}
|
||||
|
||||
return ItServiceRequest(
|
||||
id: r.id,
|
||||
requestNumber: r.requestNumber,
|
||||
services: f.containsKey('services')
|
||||
? (f['services'] as List<dynamic>).cast<String>()
|
||||
: r.services,
|
||||
servicesOther: f.containsKey('services_other')
|
||||
? f['services_other'] as String?
|
||||
: r.servicesOther,
|
||||
eventName: (f['event_name'] as String?) ?? r.eventName,
|
||||
eventDetails: f.containsKey('event_details')
|
||||
? f['event_details'] as String?
|
||||
: r.eventDetails,
|
||||
eventDate:
|
||||
f.containsKey('event_date') ? parseOpt('event_date') : r.eventDate,
|
||||
eventEndDate: f.containsKey('event_end_date')
|
||||
? parseOpt('event_end_date')
|
||||
: r.eventEndDate,
|
||||
dryRunDate: f.containsKey('dry_run_date')
|
||||
? parseOpt('dry_run_date')
|
||||
: r.dryRunDate,
|
||||
dryRunEndDate: f.containsKey('dry_run_end_date')
|
||||
? parseOpt('dry_run_end_date')
|
||||
: r.dryRunEndDate,
|
||||
contactPerson: f.containsKey('contact_person')
|
||||
? f['contact_person'] as String?
|
||||
: r.contactPerson,
|
||||
contactNumber: f.containsKey('contact_number')
|
||||
? f['contact_number'] as String?
|
||||
: r.contactNumber,
|
||||
remarks:
|
||||
f.containsKey('remarks') ? f['remarks'] as String? : r.remarks,
|
||||
officeId:
|
||||
f.containsKey('office_id') ? f['office_id'] as String? : r.officeId,
|
||||
requestedBy: f.containsKey('requested_by')
|
||||
? f['requested_by'] as String?
|
||||
: r.requestedBy,
|
||||
requestedByUserId: f.containsKey('requested_by_user_id')
|
||||
? f['requested_by_user_id'] as String?
|
||||
: r.requestedByUserId,
|
||||
approvedBy: f.containsKey('approved_by')
|
||||
? f['approved_by'] as String?
|
||||
: r.approvedBy,
|
||||
approvedByUserId: f.containsKey('approved_by_user_id')
|
||||
? f['approved_by_user_id'] as String?
|
||||
: r.approvedByUserId,
|
||||
approvedAt: f.containsKey('approved_at')
|
||||
? parseOpt('approved_at')
|
||||
: r.approvedAt,
|
||||
status: (f['status'] as String?) ?? r.status,
|
||||
outsidePremiseAllowed:
|
||||
(f['outside_premise_allowed'] as bool?) ?? r.outsidePremiseAllowed,
|
||||
cancellationReason: f.containsKey('cancellation_reason')
|
||||
? f['cancellation_reason'] as String?
|
||||
: r.cancellationReason,
|
||||
cancelledAt: f.containsKey('cancelled_at')
|
||||
? parseOpt('cancelled_at')
|
||||
: r.cancelledAt,
|
||||
creatorId: r.creatorId,
|
||||
createdAt: r.createdAt,
|
||||
updatedAt: r.updatedAt,
|
||||
completedAt: f.containsKey('completed_at')
|
||||
? parseOpt('completed_at')
|
||||
: r.completedAt,
|
||||
dateTimeReceived: f.containsKey('date_time_received')
|
||||
? parseOpt('date_time_received')
|
||||
: r.dateTimeReceived,
|
||||
dateTimeChecked: f.containsKey('date_time_checked')
|
||||
? parseOpt('date_time_checked')
|
||||
: r.dateTimeChecked,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,42 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../brick/cache_helpers.dart';
|
||||
import '../models/leave_of_absence.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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Leaves filed offline that haven't been confirmed by Supabase yet. Merged
|
||||
/// into [leavesProvider] so the UI shows them immediately. Cleared as the
|
||||
/// realtime stream confirms each id.
|
||||
final offlinePendingLeavesProvider =
|
||||
StateProvider<List<LeaveOfAbsence>>((ref) => const []);
|
||||
|
||||
/// Status changes (approve/reject/cancel) made offline, keyed by leave id.
|
||||
/// On reconnect the listener PATCHes each entry and clears the map.
|
||||
final offlinePendingLeaveUpdatesProvider =
|
||||
StateProvider<Map<String, Map<String, dynamic>>>((ref) => const {});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// All visible leaves (own for standard, all for admin/dispatcher/it_staff).
|
||||
///
|
||||
/// Consumers should **not** treat every record as an active absence; the UI
|
||||
/// layers (dashboard, logbook) explicitly filter to `status == 'approved'` and
|
||||
/// verify the leave overlaps the current time. This prevents rejected or
|
||||
/// pending applications from inadvertently influencing schedules or status
|
||||
/// computations.
|
||||
/// verify the leave overlaps the current time.
|
||||
final leavesProvider = StreamProvider<List<LeaveOfAbsence>>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
final profileAsync = ref.watch(currentProfileProvider);
|
||||
@@ -27,6 +49,40 @@ final leavesProvider = StreamProvider<List<LeaveOfAbsence>>((ref) {
|
||||
profile.role == 'dispatcher' ||
|
||||
profile.role == 'it_staff';
|
||||
|
||||
// Watch pending state — provider rebuilds when offline writes arrive so the
|
||||
// closed-over `pending*` values feed into `onOfflineData` synchronously.
|
||||
final pendingNew = ref.watch(offlinePendingLeavesProvider);
|
||||
final pendingUpdates = ref.watch(offlinePendingLeaveUpdatesProvider);
|
||||
|
||||
List<LeaveOfAbsence> applyPending(List<LeaveOfAbsence> rows) {
|
||||
final byId = {for (final r in rows) r.id: r};
|
||||
// Merge offline status updates onto existing leaves
|
||||
pendingUpdates.forEach((id, fields) {
|
||||
final existing = byId[id];
|
||||
if (existing == null) return;
|
||||
byId[id] = LeaveOfAbsence(
|
||||
id: existing.id,
|
||||
userId: existing.userId,
|
||||
leaveType: existing.leaveType,
|
||||
justification: existing.justification,
|
||||
startTime: existing.startTime,
|
||||
endTime: existing.endTime,
|
||||
status: (fields['status'] as String?) ?? existing.status,
|
||||
filedBy: existing.filedBy,
|
||||
createdAt: existing.createdAt,
|
||||
);
|
||||
});
|
||||
// Append offline-created leaves not yet on the server
|
||||
for (final p in pendingNew) {
|
||||
if (hasFullAccess || p.userId == profile.id) {
|
||||
byId.putIfAbsent(p.id, () => p);
|
||||
}
|
||||
}
|
||||
final merged = byId.values.toList()
|
||||
..sort((a, b) => b.startTime.compareTo(a.startTime));
|
||||
return merged;
|
||||
}
|
||||
|
||||
final wrapper = StreamRecoveryWrapper<LeaveOfAbsence>(
|
||||
stream: hasFullAccess
|
||||
? client
|
||||
@@ -50,24 +106,113 @@ final leavesProvider = StreamProvider<List<LeaveOfAbsence>>((ref) {
|
||||
fromMap: LeaveOfAbsence.fromMap,
|
||||
channelName: 'leave_of_absence',
|
||||
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
|
||||
onOfflineData: () async {
|
||||
final all = await cachedListFromBrick<LeaveOfAbsence>();
|
||||
final filtered = hasFullAccess
|
||||
? all
|
||||
: all.where((l) => l.userId == profile.id).toList();
|
||||
return applyPending(filtered);
|
||||
},
|
||||
onCacheMirror: (rows) =>
|
||||
mirrorBatchToBrick<LeaveOfAbsence>(rows, tag: 'leave_of_absence'),
|
||||
);
|
||||
|
||||
ref.onDispose(wrapper.dispose);
|
||||
return wrapper.stream.map((result) => result.data);
|
||||
|
||||
// Reconnect-replay: directly POST queued items, bypassing Brick's queue
|
||||
// which can block on stale entries (504, deadlock, etc.).
|
||||
ref.listen<bool>(isOnlineProvider, (wasOnline, isNowOnline) async {
|
||||
if (wasOnline == true || !isNowOnline) return;
|
||||
|
||||
// 1. Replay offline-created leaves
|
||||
final pending = List<LeaveOfAbsence>.from(ref.read(offlinePendingLeavesProvider));
|
||||
if (pending.isNotEmpty) {
|
||||
debugPrint('[leavesProvider] reconnected — syncing ${pending.length} leave(s)');
|
||||
final synced = <String>[];
|
||||
for (final leave in pending) {
|
||||
try {
|
||||
await client.from('leave_of_absence').insert({
|
||||
'id': leave.id,
|
||||
'user_id': leave.userId,
|
||||
'leave_type': leave.leaveType,
|
||||
'justification': leave.justification,
|
||||
'start_time': leave.startTime.toIso8601String(),
|
||||
'end_time': leave.endTime.toIso8601String(),
|
||||
'status': leave.status,
|
||||
'filed_by': leave.filedBy,
|
||||
});
|
||||
synced.add(leave.id);
|
||||
} catch (e) {
|
||||
final msg = e.toString();
|
||||
if (msg.contains('23505') || msg.contains('duplicate key')) {
|
||||
synced.add(leave.id); // already in DB — drop pending entry
|
||||
} else {
|
||||
debugPrint('[leavesProvider] failed to sync leave id=${leave.id}: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (synced.isNotEmpty) {
|
||||
final remaining = ref.read(offlinePendingLeavesProvider)
|
||||
.where((l) => !synced.contains(l.id))
|
||||
.toList();
|
||||
ref.read(offlinePendingLeavesProvider.notifier).state =
|
||||
List.unmodifiable(remaining);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Replay offline status updates
|
||||
final pendingMap = Map<String, Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingLeaveUpdatesProvider),
|
||||
);
|
||||
if (pendingMap.isNotEmpty) {
|
||||
debugPrint(
|
||||
'[leavesProvider] reconnected — syncing ${pendingMap.length} status edit(s)',
|
||||
);
|
||||
final syncedIds = <String>[];
|
||||
for (final entry in pendingMap.entries) {
|
||||
try {
|
||||
await client
|
||||
.from('leave_of_absence')
|
||||
.update(entry.value)
|
||||
.eq('id', entry.key);
|
||||
syncedIds.add(entry.key);
|
||||
} catch (e) {
|
||||
debugPrint('[leavesProvider] failed to sync edit id=${entry.key}: $e');
|
||||
}
|
||||
}
|
||||
if (syncedIds.isNotEmpty) {
|
||||
final remaining = Map<String, Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingLeaveUpdatesProvider),
|
||||
);
|
||||
for (final id in syncedIds) {
|
||||
remaining.remove(id);
|
||||
}
|
||||
ref.read(offlinePendingLeaveUpdatesProvider.notifier).state = remaining;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return wrapper.stream.map((result) => applyPending(result.data));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Controller
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
final leaveControllerProvider = Provider<LeaveController>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return LeaveController(client);
|
||||
return LeaveController(client, ref);
|
||||
});
|
||||
|
||||
class LeaveController {
|
||||
LeaveController(this._client);
|
||||
LeaveController(this._client, [this._ref]);
|
||||
|
||||
final SupabaseClient _client;
|
||||
final Ref? _ref;
|
||||
|
||||
/// File a leave of absence for the current user.
|
||||
/// Caller controls auto-approval based on role policy.
|
||||
/// Offline: queues a local row + raw payload, returns silently.
|
||||
Future<void> fileLeave({
|
||||
required String leaveType,
|
||||
required String justification,
|
||||
@@ -76,43 +221,90 @@ class LeaveController {
|
||||
required bool autoApprove,
|
||||
}) async {
|
||||
final uid = _client.auth.currentUser!.id;
|
||||
final id = const Uuid().v4();
|
||||
final status = autoApprove ? 'approved' : 'pending';
|
||||
final payload = {
|
||||
'id': id,
|
||||
'user_id': uid,
|
||||
'leave_type': leaveType,
|
||||
'justification': justification,
|
||||
'start_time': startTime.toIso8601String(),
|
||||
'end_time': endTime.toIso8601String(),
|
||||
'status': autoApprove ? 'approved' : 'pending',
|
||||
'status': status,
|
||||
'filed_by': uid,
|
||||
};
|
||||
|
||||
final insertedRaw = await _client
|
||||
.from('leave_of_absence')
|
||||
.insert(payload)
|
||||
.select()
|
||||
.maybeSingle();
|
||||
final Map<String, dynamic>? inserted = insertedRaw is Map<String, dynamic>
|
||||
? insertedRaw
|
||||
: null;
|
||||
Map<String, dynamic>? inserted;
|
||||
try {
|
||||
final insertedRaw = await _client
|
||||
.from('leave_of_absence')
|
||||
.insert(payload)
|
||||
.select()
|
||||
.maybeSingle();
|
||||
inserted = insertedRaw is Map<String, dynamic> ? insertedRaw : null;
|
||||
} catch (e) {
|
||||
if (!isOfflineSaveError(e)) rethrow;
|
||||
_queuePendingLeave(
|
||||
LeaveOfAbsence(
|
||||
id: id,
|
||||
userId: uid,
|
||||
leaveType: leaveType,
|
||||
justification: justification,
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
status: status,
|
||||
filedBy: uid,
|
||||
createdAt: AppTime.now(),
|
||||
),
|
||||
);
|
||||
return; // Don't try to notify admins — we're offline.
|
||||
}
|
||||
|
||||
// If this was filed as pending, notify admins for approval
|
||||
final status = payload['status'] as String;
|
||||
if (status != 'pending') return;
|
||||
await _notifyAdminsOfFiling(inserted: inserted, leaveId: id, actorId: uid);
|
||||
}
|
||||
|
||||
void _queuePendingLeave(LeaveOfAbsence leave) {
|
||||
final ref = _ref;
|
||||
if (ref == null) return;
|
||||
final current = List<LeaveOfAbsence>.from(
|
||||
ref.read(offlinePendingLeavesProvider),
|
||||
);
|
||||
current.add(leave);
|
||||
ref.read(offlinePendingLeavesProvider.notifier).state =
|
||||
List.unmodifiable(current);
|
||||
mirrorBatchToBrick<LeaveOfAbsence>([leave], tag: 'offline_leave');
|
||||
}
|
||||
|
||||
void _queueLeaveStatusUpdate(String leaveId, Map<String, dynamic> fields) {
|
||||
final ref = _ref;
|
||||
if (ref == null) return;
|
||||
final current = Map<String, Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingLeaveUpdatesProvider),
|
||||
);
|
||||
current[leaveId] = {...?current[leaveId], ...fields};
|
||||
ref.read(offlinePendingLeaveUpdatesProvider.notifier).state = current;
|
||||
}
|
||||
|
||||
Future<void> _notifyAdminsOfFiling({
|
||||
required Map<String, dynamic>? inserted,
|
||||
required String leaveId,
|
||||
required String actorId,
|
||||
}) async {
|
||||
try {
|
||||
final adminIds = await _fetchRoleUserIds(
|
||||
roles: const ['admin'],
|
||||
excludeUserId: uid,
|
||||
excludeUserId: actorId,
|
||||
);
|
||||
if (adminIds.isEmpty) return;
|
||||
|
||||
// Resolve actor display name for nicer push text
|
||||
String actorName = 'Someone';
|
||||
try {
|
||||
final p = await _client
|
||||
.from('profiles')
|
||||
.select('full_name,display_name,name')
|
||||
.eq('id', uid)
|
||||
.eq('id', actorId)
|
||||
.maybeSingle();
|
||||
if (p != null) {
|
||||
if (p['full_name'] != null) {
|
||||
@@ -125,32 +317,24 @@ class LeaveController {
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
final leaveId = (inserted ?? <String, dynamic>{})['id']?.toString() ?? '';
|
||||
final title = 'Leave Filed for Approval';
|
||||
final body = '$actorName filed a leave request that requires approval.';
|
||||
final notificationId = (inserted ?? <String, dynamic>{})['id']
|
||||
?.toString();
|
||||
final notificationId = (inserted ?? <String, dynamic>{})['id']?.toString();
|
||||
|
||||
final dataPayload = <String, dynamic>{
|
||||
'type': 'leave_filed',
|
||||
'leave_id': leaveId,
|
||||
...?(notificationId != null
|
||||
? {'notification_id': notificationId}
|
||||
: null),
|
||||
...?(notificationId != null ? {'notification_id': notificationId} : null),
|
||||
};
|
||||
|
||||
await _client
|
||||
.from('notifications')
|
||||
.insert(
|
||||
await _client.from('notifications').insert(
|
||||
adminIds
|
||||
.map(
|
||||
(userId) => {
|
||||
'user_id': userId,
|
||||
'actor_id': uid,
|
||||
'type': 'leave_filed',
|
||||
'leave_id': leaveId,
|
||||
},
|
||||
)
|
||||
.map((userId) => {
|
||||
'user_id': userId,
|
||||
'actor_id': actorId,
|
||||
'type': 'leave_filed',
|
||||
'leave_id': leaveId,
|
||||
})
|
||||
.toList(),
|
||||
);
|
||||
|
||||
@@ -175,14 +359,21 @@ class LeaveController {
|
||||
final userId = _client.auth.currentUser?.id;
|
||||
if (userId == null) throw Exception('Not authenticated');
|
||||
|
||||
// Update status first; then notify the requester.
|
||||
await _client
|
||||
.from('leave_of_absence')
|
||||
.update({'status': 'approved'})
|
||||
.eq('id', leaveId);
|
||||
try {
|
||||
await _client
|
||||
.from('leave_of_absence')
|
||||
.update({'status': 'approved'})
|
||||
.eq('id', leaveId);
|
||||
} catch (e) {
|
||||
if (!isOfflineSaveError(e)) rethrow;
|
||||
_queueLeaveStatusUpdate(leaveId, {'status': 'approved'});
|
||||
return;
|
||||
}
|
||||
|
||||
// Notify requestor
|
||||
await _notifyRequester(leaveId: leaveId, actorId: userId, approved: true);
|
||||
// Notify requestor (best-effort)
|
||||
try {
|
||||
await _notifyRequester(leaveId: leaveId, actorId: userId, approved: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// Reject a leave request.
|
||||
@@ -190,13 +381,20 @@ class LeaveController {
|
||||
final userId = _client.auth.currentUser?.id;
|
||||
if (userId == null) throw Exception('Not authenticated');
|
||||
|
||||
await _client
|
||||
.from('leave_of_absence')
|
||||
.update({'status': 'rejected'})
|
||||
.eq('id', leaveId);
|
||||
try {
|
||||
await _client
|
||||
.from('leave_of_absence')
|
||||
.update({'status': 'rejected'})
|
||||
.eq('id', leaveId);
|
||||
} catch (e) {
|
||||
if (!isOfflineSaveError(e)) rethrow;
|
||||
_queueLeaveStatusUpdate(leaveId, {'status': 'rejected'});
|
||||
return;
|
||||
}
|
||||
|
||||
// Notify requestor
|
||||
await _notifyRequester(leaveId: leaveId, actorId: userId, approved: false);
|
||||
try {
|
||||
await _notifyRequester(leaveId: leaveId, actorId: userId, approved: false);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _notifyRequester({
|
||||
@@ -266,10 +464,15 @@ class LeaveController {
|
||||
|
||||
/// Cancel an approved leave.
|
||||
Future<void> cancelLeave(String leaveId) async {
|
||||
await _client
|
||||
.from('leave_of_absence')
|
||||
.update({'status': 'cancelled'})
|
||||
.eq('id', leaveId);
|
||||
try {
|
||||
await _client
|
||||
.from('leave_of_absence')
|
||||
.update({'status': 'cancelled'})
|
||||
.eq('id', leaveId);
|
||||
} catch (e) {
|
||||
if (!isOfflineSaveError(e)) rethrow;
|
||||
_queueLeaveStatusUpdate(leaveId, {'status': 'cancelled'});
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<String>> _fetchRoleUserIds({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,34 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../brick/cache_helpers.dart';
|
||||
import '../models/pass_slip.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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Pass slips filed offline that haven't been confirmed by Supabase yet.
|
||||
final offlinePendingPassSlipsProvider =
|
||||
StateProvider<List<PassSlip>>((ref) => const []);
|
||||
|
||||
/// Status edits (approve/reject/complete) made offline, keyed by slip id.
|
||||
final offlinePendingPassSlipUpdatesProvider =
|
||||
StateProvider<Map<String, Map<String, dynamic>>>((ref) => const {});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// All visible pass slips (own for staff, all for admin/dispatcher).
|
||||
final passSlipsProvider = StreamProvider<List<PassSlip>>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
@@ -18,6 +39,50 @@ final passSlipsProvider = StreamProvider<List<PassSlip>>((ref) {
|
||||
final isAdmin = profile.role == 'admin' || profile.role == 'dispatcher';
|
||||
final hasFullAccess = isAdmin || profile.role == 'programmer';
|
||||
|
||||
final pendingNew = ref.watch(offlinePendingPassSlipsProvider);
|
||||
final pendingUpdates = ref.watch(offlinePendingPassSlipUpdatesProvider);
|
||||
|
||||
List<PassSlip> applyPending(List<PassSlip> 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] = PassSlip(
|
||||
id: existing.id,
|
||||
userId: existing.userId,
|
||||
dutyScheduleId: existing.dutyScheduleId,
|
||||
reason: existing.reason,
|
||||
status: (fields['status'] as String?) ?? existing.status,
|
||||
requestedAt: existing.requestedAt,
|
||||
approvedBy: (fields['approved_by'] as String?) ?? existing.approvedBy,
|
||||
approvedAt: fields.containsKey('approved_at')
|
||||
? (fields['approved_at'] is String
|
||||
? AppTime.parse(fields['approved_at'] as String)
|
||||
: existing.approvedAt)
|
||||
: existing.approvedAt,
|
||||
slipStart: fields.containsKey('slip_start')
|
||||
? (fields['slip_start'] is String
|
||||
? AppTime.parse(fields['slip_start'] as String)
|
||||
: existing.slipStart)
|
||||
: existing.slipStart,
|
||||
slipEnd: fields.containsKey('slip_end')
|
||||
? (fields['slip_end'] is String
|
||||
? AppTime.parse(fields['slip_end'] as String)
|
||||
: existing.slipEnd)
|
||||
: existing.slipEnd,
|
||||
requestedStart: existing.requestedStart,
|
||||
);
|
||||
});
|
||||
for (final p in pendingNew) {
|
||||
if (hasFullAccess || p.userId == profile.id) {
|
||||
byId.putIfAbsent(p.id, () => p);
|
||||
}
|
||||
}
|
||||
final merged = byId.values.toList()
|
||||
..sort((a, b) => b.requestedAt.compareTo(a.requestedAt));
|
||||
return merged;
|
||||
}
|
||||
|
||||
final wrapper = StreamRecoveryWrapper<PassSlip>(
|
||||
stream: hasFullAccess
|
||||
? client
|
||||
@@ -41,10 +106,90 @@ final passSlipsProvider = StreamProvider<List<PassSlip>>((ref) {
|
||||
fromMap: PassSlip.fromMap,
|
||||
channelName: 'pass_slips',
|
||||
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
|
||||
onOfflineData: () async {
|
||||
final all = await cachedListFromBrick<PassSlip>();
|
||||
final filtered = hasFullAccess
|
||||
? all
|
||||
: all.where((s) => s.userId == profile.id).toList();
|
||||
return applyPending(filtered);
|
||||
},
|
||||
onCacheMirror: (rows) =>
|
||||
mirrorBatchToBrick<PassSlip>(rows, tag: 'pass_slips'),
|
||||
);
|
||||
|
||||
ref.onDispose(wrapper.dispose);
|
||||
return wrapper.stream.map((result) => result.data);
|
||||
|
||||
// Reconnect-replay
|
||||
ref.listen<bool>(isOnlineProvider, (wasOnline, isNowOnline) async {
|
||||
if (wasOnline == true || !isNowOnline) return;
|
||||
|
||||
final pending = List<PassSlip>.from(ref.read(offlinePendingPassSlipsProvider));
|
||||
if (pending.isNotEmpty) {
|
||||
debugPrint('[passSlipsProvider] reconnected — syncing ${pending.length} slip(s)');
|
||||
final synced = <String>[];
|
||||
for (final slip in pending) {
|
||||
try {
|
||||
await client.from('pass_slips').insert({
|
||||
'id': slip.id,
|
||||
'user_id': slip.userId,
|
||||
'duty_schedule_id': slip.dutyScheduleId,
|
||||
'reason': slip.reason,
|
||||
'status': slip.status,
|
||||
'requested_at': slip.requestedAt.toIso8601String(),
|
||||
if (slip.requestedStart != null)
|
||||
'requested_start': slip.requestedStart!.toIso8601String(),
|
||||
});
|
||||
synced.add(slip.id);
|
||||
} catch (e) {
|
||||
final msg = e.toString();
|
||||
if (msg.contains('23505') || msg.contains('duplicate key')) {
|
||||
synced.add(slip.id);
|
||||
} else {
|
||||
debugPrint('[passSlipsProvider] failed to sync slip id=${slip.id}: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (synced.isNotEmpty) {
|
||||
final remaining = ref.read(offlinePendingPassSlipsProvider)
|
||||
.where((s) => !synced.contains(s.id))
|
||||
.toList();
|
||||
ref.read(offlinePendingPassSlipsProvider.notifier).state =
|
||||
List.unmodifiable(remaining);
|
||||
}
|
||||
}
|
||||
|
||||
final pendingMap = Map<String, Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingPassSlipUpdatesProvider),
|
||||
);
|
||||
if (pendingMap.isNotEmpty) {
|
||||
debugPrint(
|
||||
'[passSlipsProvider] reconnected — syncing ${pendingMap.length} status edit(s)',
|
||||
);
|
||||
final syncedIds = <String>[];
|
||||
for (final entry in pendingMap.entries) {
|
||||
try {
|
||||
await client
|
||||
.from('pass_slips')
|
||||
.update(entry.value)
|
||||
.eq('id', entry.key);
|
||||
syncedIds.add(entry.key);
|
||||
} catch (e) {
|
||||
debugPrint('[passSlipsProvider] failed to sync edit id=${entry.key}: $e');
|
||||
}
|
||||
}
|
||||
if (syncedIds.isNotEmpty) {
|
||||
final remaining = Map<String, Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingPassSlipUpdatesProvider),
|
||||
);
|
||||
for (final id in syncedIds) {
|
||||
remaining.remove(id);
|
||||
}
|
||||
ref.read(offlinePendingPassSlipUpdatesProvider.notifier).state = remaining;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return wrapper.stream.map((result) => applyPending(result.data));
|
||||
});
|
||||
|
||||
/// Currently active pass slip for the logged-in user (approved, not completed).
|
||||
@@ -65,15 +210,42 @@ final activePassSlipsProvider = Provider<List<PassSlip>>((ref) {
|
||||
return slips.where((s) => s.isActive).toList();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Controller
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
final passSlipControllerProvider = Provider<PassSlipController>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return PassSlipController(client);
|
||||
return PassSlipController(client, ref);
|
||||
});
|
||||
|
||||
class PassSlipController {
|
||||
PassSlipController(this._client);
|
||||
PassSlipController(this._client, [this._ref]);
|
||||
|
||||
final SupabaseClient _client;
|
||||
final Ref? _ref;
|
||||
|
||||
void _queuePendingSlip(PassSlip slip) {
|
||||
final ref = _ref;
|
||||
if (ref == null) return;
|
||||
final current = List<PassSlip>.from(
|
||||
ref.read(offlinePendingPassSlipsProvider),
|
||||
);
|
||||
current.add(slip);
|
||||
ref.read(offlinePendingPassSlipsProvider.notifier).state =
|
||||
List.unmodifiable(current);
|
||||
mirrorBatchToBrick<PassSlip>([slip], tag: 'offline_pass_slip');
|
||||
}
|
||||
|
||||
void _queueSlipStatusUpdate(String slipId, Map<String, dynamic> fields) {
|
||||
final ref = _ref;
|
||||
if (ref == null) return;
|
||||
final current = Map<String, Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingPassSlipUpdatesProvider),
|
||||
);
|
||||
current[slipId] = {...?current[slipId], ...fields};
|
||||
ref.read(offlinePendingPassSlipUpdatesProvider.notifier).state = current;
|
||||
}
|
||||
|
||||
Future<void> requestSlip({
|
||||
required String dutyScheduleId,
|
||||
@@ -82,26 +254,42 @@ class PassSlipController {
|
||||
}) async {
|
||||
final userId = _client.auth.currentUser?.id;
|
||||
if (userId == null) throw Exception('Not authenticated');
|
||||
final id = const Uuid().v4();
|
||||
final now = DateTime.now().toUtc();
|
||||
final payload = {
|
||||
'id': id,
|
||||
'user_id': userId,
|
||||
'duty_schedule_id': dutyScheduleId,
|
||||
'reason': reason,
|
||||
'status': 'pending',
|
||||
'requested_at': DateTime.now().toUtc().toIso8601String(),
|
||||
'requested_at': now.toIso8601String(),
|
||||
if (requestedStart != null)
|
||||
'requested_start': requestedStart.toUtc().toIso8601String(),
|
||||
};
|
||||
|
||||
final insertedRaw = await _client
|
||||
.from('pass_slips')
|
||||
.insert(payload)
|
||||
.select()
|
||||
.maybeSingle();
|
||||
final Map<String, dynamic>? inserted = insertedRaw is Map<String, dynamic>
|
||||
? insertedRaw
|
||||
: null;
|
||||
Map<String, dynamic>? inserted;
|
||||
try {
|
||||
final insertedRaw = await _client
|
||||
.from('pass_slips')
|
||||
.insert(payload)
|
||||
.select()
|
||||
.maybeSingle();
|
||||
inserted = insertedRaw is Map<String, dynamic> ? insertedRaw : null;
|
||||
} catch (e) {
|
||||
if (!isOfflineSaveError(e)) rethrow;
|
||||
_queuePendingSlip(PassSlip(
|
||||
id: id,
|
||||
userId: userId,
|
||||
dutyScheduleId: dutyScheduleId,
|
||||
reason: reason,
|
||||
status: 'pending',
|
||||
requestedAt: AppTime.toAppTime(now),
|
||||
requestedStart: requestedStart,
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
// Notify admins for approval
|
||||
// Notify admins for approval (best-effort)
|
||||
try {
|
||||
final adminIds = await _fetchRoleUserIds(
|
||||
roles: const ['admin'],
|
||||
@@ -109,7 +297,6 @@ class PassSlipController {
|
||||
);
|
||||
if (adminIds.isEmpty) return;
|
||||
|
||||
// Resolve actor display name for nice push text
|
||||
String actorName = 'Someone';
|
||||
try {
|
||||
final p = await _client
|
||||
@@ -128,32 +315,25 @@ class PassSlipController {
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
final slipId = (inserted ?? <String, dynamic>{})['id']?.toString() ?? '';
|
||||
final slipId = (inserted ?? <String, dynamic>{})['id']?.toString() ?? id;
|
||||
final title = 'Pass Slip Filed for Approval';
|
||||
final body = '$actorName filed a pass slip that requires approval.';
|
||||
final notificationId = (inserted ?? <String, dynamic>{})['id']
|
||||
?.toString();
|
||||
final notificationId = (inserted ?? <String, dynamic>{})['id']?.toString();
|
||||
|
||||
final dataPayload = <String, dynamic>{
|
||||
'type': 'pass_slip_filed',
|
||||
'pass_slip_id': slipId,
|
||||
...?(notificationId != null
|
||||
? {'notification_id': notificationId}
|
||||
: null),
|
||||
...?(notificationId != null ? {'notification_id': notificationId} : null),
|
||||
};
|
||||
|
||||
await _client
|
||||
.from('notifications')
|
||||
.insert(
|
||||
await _client.from('notifications').insert(
|
||||
adminIds
|
||||
.map(
|
||||
(adminId) => {
|
||||
'user_id': adminId,
|
||||
'actor_id': userId,
|
||||
'type': 'pass_slip_filed',
|
||||
'pass_slip_id': slipId,
|
||||
},
|
||||
)
|
||||
.map((adminId) => {
|
||||
'user_id': adminId,
|
||||
'actor_id': userId,
|
||||
'type': 'pass_slip_filed',
|
||||
'pass_slip_id': slipId,
|
||||
})
|
||||
.toList(),
|
||||
);
|
||||
|
||||
@@ -169,7 +349,6 @@ class PassSlipController {
|
||||
debugPrint('pass slip send_fcm result: $res');
|
||||
} catch (e) {
|
||||
debugPrint('pass slip send_fcm error: $e');
|
||||
// Non-fatal: keep slip request working even if send_fcm fails
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,49 +356,67 @@ class PassSlipController {
|
||||
final userId = _client.auth.currentUser?.id;
|
||||
if (userId == null) throw Exception('Not authenticated');
|
||||
|
||||
// Determine slip start time based on requested_start
|
||||
final nowUtc = DateTime.now().toUtc();
|
||||
String slipStartIso = nowUtc.toIso8601String();
|
||||
|
||||
final row = await _client
|
||||
.from('pass_slips')
|
||||
.select('requested_start')
|
||||
.eq('id', slipId)
|
||||
.maybeSingle();
|
||||
if (row != null && row['requested_start'] != null) {
|
||||
final requestedStart = DateTime.parse(row['requested_start'] as String);
|
||||
if (requestedStart.isAfter(nowUtc)) {
|
||||
slipStartIso = requestedStart.toIso8601String();
|
||||
// Try to determine slip start time online; if offline use now.
|
||||
String slipStartIso = nowUtc.toIso8601String();
|
||||
try {
|
||||
final row = await _client
|
||||
.from('pass_slips')
|
||||
.select('requested_start')
|
||||
.eq('id', slipId)
|
||||
.maybeSingle();
|
||||
if (row != null && row['requested_start'] != null) {
|
||||
final requestedStart = DateTime.parse(row['requested_start'] as String);
|
||||
if (requestedStart.isAfter(nowUtc)) {
|
||||
slipStartIso = requestedStart.toIso8601String();
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Stick with nowUtc.
|
||||
}
|
||||
|
||||
await _client
|
||||
.from('pass_slips')
|
||||
.update({
|
||||
'status': 'approved',
|
||||
'approved_by': userId,
|
||||
'approved_at': nowUtc.toIso8601String(),
|
||||
'slip_start': slipStartIso,
|
||||
})
|
||||
.eq('id', slipId);
|
||||
final fields = {
|
||||
'status': 'approved',
|
||||
'approved_by': userId,
|
||||
'approved_at': nowUtc.toIso8601String(),
|
||||
'slip_start': slipStartIso,
|
||||
};
|
||||
|
||||
await _notifyRequester(slipId: slipId, actorId: userId, approved: true);
|
||||
try {
|
||||
await _client.from('pass_slips').update(fields).eq('id', slipId);
|
||||
} catch (e) {
|
||||
if (!isOfflineSaveError(e)) rethrow;
|
||||
_queueSlipStatusUpdate(slipId, fields);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await _notifyRequester(slipId: slipId, actorId: userId, approved: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> rejectSlip(String slipId) async {
|
||||
final userId = _client.auth.currentUser?.id;
|
||||
if (userId == null) throw Exception('Not authenticated');
|
||||
|
||||
await _client
|
||||
.from('pass_slips')
|
||||
.update({
|
||||
'status': 'rejected',
|
||||
'approved_by': userId,
|
||||
'approved_at': DateTime.now().toUtc().toIso8601String(),
|
||||
})
|
||||
.eq('id', slipId);
|
||||
final fields = {
|
||||
'status': 'rejected',
|
||||
'approved_by': userId,
|
||||
'approved_at': DateTime.now().toUtc().toIso8601String(),
|
||||
};
|
||||
|
||||
await _notifyRequester(slipId: slipId, actorId: userId, approved: false);
|
||||
try {
|
||||
await _client.from('pass_slips').update(fields).eq('id', slipId);
|
||||
} catch (e) {
|
||||
if (!isOfflineSaveError(e)) rethrow;
|
||||
_queueSlipStatusUpdate(slipId, fields);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await _notifyRequester(slipId: slipId, actorId: userId, approved: false);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _notifyRequester({
|
||||
@@ -288,13 +485,16 @@ class PassSlipController {
|
||||
}
|
||||
|
||||
Future<void> completeSlip(String slipId) async {
|
||||
await _client
|
||||
.from('pass_slips')
|
||||
.update({
|
||||
'status': 'completed',
|
||||
'slip_end': DateTime.now().toUtc().toIso8601String(),
|
||||
})
|
||||
.eq('id', slipId);
|
||||
final fields = {
|
||||
'status': 'completed',
|
||||
'slip_end': DateTime.now().toUtc().toIso8601String(),
|
||||
};
|
||||
try {
|
||||
await _client.from('pass_slips').update(fields).eq('id', slipId);
|
||||
} catch (e) {
|
||||
if (!isOfflineSaveError(e)) rethrow;
|
||||
_queueSlipStatusUpdate(slipId, fields);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<String>> _fetchRoleUserIds({
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import '../brick/cache_helpers.dart';
|
||||
import '../models/profile.dart';
|
||||
import '../utils/snackbar.dart' show isOfflineSaveError;
|
||||
import 'auth_provider.dart';
|
||||
import 'connectivity_provider.dart';
|
||||
import 'supabase_provider.dart';
|
||||
import 'stream_recovery.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Offline-pending state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Pending full-name updates made offline, keyed by userId.
|
||||
final offlinePendingProfileUpdatesProvider =
|
||||
StateProvider<Map<String, String>>((ref) => const {});
|
||||
|
||||
final currentUserIdProvider = Provider<String?>((ref) {
|
||||
final authState = ref.watch(authStateChangesProvider);
|
||||
// Be explicit about loading/error to avoid dynamic dispatch problems.
|
||||
@@ -37,9 +49,52 @@ final currentProfileProvider = StreamProvider<Profile?>((ref) {
|
||||
return data == null ? [] : [Profile.fromMap(data)];
|
||||
},
|
||||
fromMap: Profile.fromMap,
|
||||
onOfflineData: () async {
|
||||
final all = await cachedListFromBrick<Profile>();
|
||||
return all.where((p) => p.id == userId).toList();
|
||||
},
|
||||
onCacheMirror: (rows) => mirrorBatchToBrick<Profile>(
|
||||
rows.whereType<Profile>().toList(),
|
||||
tag: 'profile_current',
|
||||
),
|
||||
);
|
||||
|
||||
ref.onDispose(wrapper.dispose);
|
||||
|
||||
ref.listen<bool>(isOnlineProvider, (wasOnline, isNowOnline) async {
|
||||
if (wasOnline == true || !isNowOnline) return;
|
||||
final pending = Map<String, String>.from(
|
||||
ref.read(offlinePendingProfileUpdatesProvider),
|
||||
);
|
||||
if (pending.isEmpty) return;
|
||||
debugPrint(
|
||||
'[currentProfileProvider] reconnected — syncing ${pending.length} profile update(s)',
|
||||
);
|
||||
final synced = <String>[];
|
||||
for (final entry in pending.entries) {
|
||||
try {
|
||||
await client
|
||||
.from('profiles')
|
||||
.update({'full_name': entry.value})
|
||||
.eq('id', entry.key);
|
||||
synced.add(entry.key);
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'[currentProfileProvider] failed to sync full_name for ${entry.key}: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (synced.isNotEmpty) {
|
||||
final remaining = Map<String, String>.from(
|
||||
ref.read(offlinePendingProfileUpdatesProvider),
|
||||
);
|
||||
for (final id in synced) {
|
||||
remaining.remove(id);
|
||||
}
|
||||
ref.read(offlinePendingProfileUpdatesProvider.notifier).state = remaining;
|
||||
}
|
||||
});
|
||||
|
||||
return wrapper.stream.map((result) {
|
||||
return result.data.isEmpty ? null : result.data.first;
|
||||
});
|
||||
@@ -58,6 +113,13 @@ final profilesProvider = StreamProvider<List<Profile>>((ref) {
|
||||
return data.map(Profile.fromMap).toList();
|
||||
},
|
||||
fromMap: Profile.fromMap,
|
||||
onOfflineData: () async {
|
||||
final all = await cachedListFromBrick<Profile>();
|
||||
all.sort((a, b) => a.fullName.compareTo(b.fullName));
|
||||
return all;
|
||||
},
|
||||
onCacheMirror: (rows) =>
|
||||
mirrorBatchToBrick<Profile>(rows, tag: 'profiles'),
|
||||
);
|
||||
|
||||
ref.onDispose(wrapper.dispose);
|
||||
@@ -67,23 +129,36 @@ final profilesProvider = StreamProvider<List<Profile>>((ref) {
|
||||
/// Controller for the current user's profile (update full name / password).
|
||||
final profileControllerProvider = Provider<ProfileController>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return ProfileController(client);
|
||||
return ProfileController(client, ref);
|
||||
});
|
||||
|
||||
class ProfileController {
|
||||
ProfileController(this._client);
|
||||
ProfileController(this._client, [this._ref]);
|
||||
|
||||
final SupabaseClient _client;
|
||||
final Ref? _ref;
|
||||
|
||||
/// Update the `profiles.full_name` for the given user id.
|
||||
/// Offline: queues the update for replay on reconnect.
|
||||
Future<void> updateFullName({
|
||||
required String userId,
|
||||
required String fullName,
|
||||
}) async {
|
||||
await _client
|
||||
.from('profiles')
|
||||
.update({'full_name': fullName})
|
||||
.eq('id', userId);
|
||||
try {
|
||||
await _client
|
||||
.from('profiles')
|
||||
.update({'full_name': fullName})
|
||||
.eq('id', userId);
|
||||
} catch (e) {
|
||||
if (!isOfflineSaveError(e)) rethrow;
|
||||
final ref = _ref;
|
||||
if (ref == null) return;
|
||||
final current = Map<String, String>.from(
|
||||
ref.read(offlinePendingProfileUpdatesProvider),
|
||||
);
|
||||
current[userId] = fullName;
|
||||
ref.read(offlinePendingProfileUpdatesProvider.notifier).state = current;
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the current user's password (works for OAuth users too).
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import 'stream_recovery.dart';
|
||||
import 'supabase_provider.dart';
|
||||
|
||||
final realtimeControllerProvider = ChangeNotifierProvider<RealtimeController>((
|
||||
ref,
|
||||
) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
// ChangeNotifierProvider automatically disposes the notifier; no need
|
||||
// for ref.onDispose here — adding it causes a double-dispose assertion.
|
||||
return RealtimeController(client);
|
||||
return RealtimeController();
|
||||
});
|
||||
|
||||
/// Per-channel realtime controller for UI skeleton indicators.
|
||||
@@ -27,42 +21,12 @@ final realtimeControllerProvider = ChangeNotifierProvider<RealtimeController>((
|
||||
/// - Per-channel recovering state for pinpoint skeleton indicators
|
||||
/// - Auth token refreshes for realtime connections
|
||||
class RealtimeController extends ChangeNotifier {
|
||||
final SupabaseClient _client;
|
||||
bool _disposed = false;
|
||||
|
||||
/// Channels currently in a recovering/polling/stale state.
|
||||
final Set<String> _recoveringChannels = {};
|
||||
|
||||
StreamSubscription<AuthState>? _authSub;
|
||||
|
||||
RealtimeController(this._client) {
|
||||
_init();
|
||||
}
|
||||
|
||||
void _init() {
|
||||
try {
|
||||
_authSub = _client.auth.onAuthStateChange.listen((data) {
|
||||
final event = data.event;
|
||||
if (event == AuthChangeEvent.tokenRefreshed) {
|
||||
_ensureTokenFresh();
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('RealtimeController._init error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _ensureTokenFresh() async {
|
||||
if (_disposed) return;
|
||||
try {
|
||||
final authDynamic = _client.auth as dynamic;
|
||||
if (authDynamic.refreshSession != null) {
|
||||
await authDynamic.refreshSession?.call();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('RealtimeController: token refresh failed: $e');
|
||||
}
|
||||
}
|
||||
RealtimeController();
|
||||
|
||||
// ── Per-channel status ─────────────────────────────────────────────────
|
||||
|
||||
@@ -97,13 +61,17 @@ class RealtimeController extends ChangeNotifier {
|
||||
|
||||
/// Convenience callback suitable for [StreamRecoveryWrapper.onStatusChanged].
|
||||
///
|
||||
/// Routes [StreamConnectionStatus] to the appropriate mark method.
|
||||
/// Both `connected` and `polling` are treated as "recovered" because
|
||||
/// polling is a functional fallback that still delivers data — the user
|
||||
/// doesn't need to see a reconnection indicator while data flows via REST.
|
||||
/// Routes [StreamConnectionStatus] to the appropriate mark method:
|
||||
/// - `connected` — live stream; no indicator needed.
|
||||
/// - `polling` — REST fallback; data is fresh, no skeleton needed.
|
||||
/// - `offline` — device has no internet; Brick cache is active. The offline
|
||||
/// banner already communicates this state, so the reconnection skeleton
|
||||
/// should NOT show — it would be misleading while real data is on screen.
|
||||
/// - Everything else (`recovering`, `stale`, `failed`) — show skeleton.
|
||||
void handleChannelStatus(String channel, StreamConnectionStatus status) {
|
||||
if (status == StreamConnectionStatus.connected ||
|
||||
status == StreamConnectionStatus.polling) {
|
||||
status == StreamConnectionStatus.polling ||
|
||||
status == StreamConnectionStatus.offline) {
|
||||
markChannelRecovered(channel);
|
||||
} else {
|
||||
markChannelRecovering(channel);
|
||||
@@ -126,7 +94,6 @@ class RealtimeController extends ChangeNotifier {
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_authSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../brick/cache_helpers.dart';
|
||||
import '../models/service.dart';
|
||||
import 'supabase_provider.dart';
|
||||
import 'stream_recovery.dart';
|
||||
@@ -17,6 +18,13 @@ final servicesProvider = StreamProvider<List<Service>>((ref) {
|
||||
fromMap: Service.fromMap,
|
||||
channelName: 'services',
|
||||
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
|
||||
onOfflineData: () async {
|
||||
final all = await cachedListFromBrick<Service>();
|
||||
all.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||
return all;
|
||||
},
|
||||
onCacheMirror: (rows) =>
|
||||
mirrorBatchToBrick<Service>(rows, tag: 'services'),
|
||||
);
|
||||
|
||||
ref.onDispose(wrapper.dispose);
|
||||
|
||||
@@ -18,6 +18,12 @@ enum StreamConnectionStatus {
|
||||
|
||||
/// Fatal error; stream will not recover without manual intervention.
|
||||
failed,
|
||||
|
||||
/// Device is offline. The realtime subscription is paused; Brick's local
|
||||
/// SQLite cache is the active data source. No reconnection is attempted
|
||||
/// until [notifyOnlineRestored] is called. The UI should show the offline
|
||||
/// banner but NOT a skeleton/loading indicator.
|
||||
offline,
|
||||
}
|
||||
|
||||
/// Represents the result of a polling attempt.
|
||||
@@ -70,7 +76,19 @@ typedef ChannelStatusCallback =
|
||||
/// and connection status tracking. Provides graceful degradation when the
|
||||
/// realtime connection fails.
|
||||
///
|
||||
/// ## Offline behaviour
|
||||
///
|
||||
/// Recovery is **suppressed** when the device is offline (as reported by
|
||||
/// [setIsOnlineCallback]). In that state the wrapper marks the channel as
|
||||
/// `recovering` but does **not** schedule timers or fire polls — Brick's
|
||||
/// offline queue handles local data until connectivity returns.
|
||||
///
|
||||
/// When the device comes back online, call [notifyAllOnlineRestored] (done
|
||||
/// automatically by `connectivity_provider._onReconnected`) to restart every
|
||||
/// live wrapper whose stream is not already connected.
|
||||
///
|
||||
/// Error handling:
|
||||
/// - **Offline**: recovery suppressed; restarts on [notifyAllOnlineRestored].
|
||||
/// - **Timeout**: detected and handled internally with exponential backoff.
|
||||
/// - **ChannelRateLimitReached**: detected and handled with a longer minimum
|
||||
/// delay (5 s) before retrying. During recovery, a REST poll keeps data
|
||||
@@ -92,6 +110,39 @@ typedef ChannelStatusCallback =
|
||||
/// );
|
||||
/// ```
|
||||
class StreamRecoveryWrapper<T> {
|
||||
// ── Static online check ───────────────────────────────────────────────────
|
||||
|
||||
/// Returns whether the device currently has a working internet connection.
|
||||
///
|
||||
/// Defaults to `() => true` (recovery enabled) until the connectivity
|
||||
/// provider overrides it via [setIsOnlineCallback].
|
||||
static bool Function() _globalIsOnline = () => true;
|
||||
|
||||
/// Configure the global online check. Call this once from
|
||||
/// `connectivityMonitorProvider` so all wrappers suppress recovery while
|
||||
/// offline without needing per-instance wiring.
|
||||
static void setIsOnlineCallback(bool Function() fn) {
|
||||
_globalIsOnline = fn;
|
||||
}
|
||||
|
||||
// ── Static registry (WeakReference so GC can collect disposed wrappers) ──
|
||||
|
||||
static final List<WeakReference<StreamRecoveryWrapper>> _instances = [];
|
||||
|
||||
/// Notify every live wrapper that connectivity has been restored.
|
||||
///
|
||||
/// Wrappers whose stream is already [StreamConnectionStatus.connected] are
|
||||
/// skipped. All others call [retry] to restart the Supabase realtime
|
||||
/// subscription. Called by `connectivity_provider._onReconnected`.
|
||||
static void notifyAllOnlineRestored() {
|
||||
_instances.removeWhere((r) => r.target == null);
|
||||
for (final ref in List.of(_instances)) {
|
||||
ref.target?.notifyOnlineRestored();
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
final Stream<List<Map<String, dynamic>>> _realtimeStream;
|
||||
final Future<List<T>> Function() _onPollData;
|
||||
final T Function(Map<String, dynamic>) _fromMap;
|
||||
@@ -105,6 +156,17 @@ class StreamRecoveryWrapper<T> {
|
||||
/// skeleton indicators in the UI.
|
||||
final ChannelStatusCallback? _onStatusChanged;
|
||||
|
||||
/// Optional callback to fetch data from the local offline cache (e.g. Brick
|
||||
/// SQLite) when the device is offline. Called once when offline is first
|
||||
/// detected to populate the stream so the UI shows cached data instead of
|
||||
/// an empty list. If null, an empty offline result is emitted.
|
||||
final Future<List<T>> Function()? _onOfflineData;
|
||||
|
||||
/// Optional callback invoked with each batch of live data so the provider
|
||||
/// can mirror it into a local cache (e.g. Brick SQLite). Fires for every
|
||||
/// realtime emission. Errors are caught and logged; mirroring is best-effort.
|
||||
final void Function(List<T> rows)? _onCacheMirror;
|
||||
|
||||
StreamConnectionStatus _connectionStatus = StreamConnectionStatus.connected;
|
||||
int _recoveryAttempts = 0;
|
||||
Timer? _pollingTimer;
|
||||
@@ -115,6 +177,11 @@ class StreamRecoveryWrapper<T> {
|
||||
bool _disposed = false;
|
||||
bool _listening = false;
|
||||
|
||||
/// True once live data has been emitted at least once. Used to distinguish
|
||||
/// "offline at startup (never received data)" from "reconnecting after
|
||||
/// having had data" for the race-condition fix.
|
||||
bool _hasEmittedData = false;
|
||||
|
||||
StreamRecoveryWrapper({
|
||||
required Stream<List<Map<String, dynamic>>> stream,
|
||||
required Future<List<T>> Function() onPollData,
|
||||
@@ -122,11 +189,21 @@ class StreamRecoveryWrapper<T> {
|
||||
StreamRecoveryConfig config = const StreamRecoveryConfig(),
|
||||
this.channelName = 'unknown',
|
||||
ChannelStatusCallback? onStatusChanged,
|
||||
Future<List<T>> Function()? onOfflineData,
|
||||
void Function(List<T> rows)? onCacheMirror,
|
||||
}) : _realtimeStream = stream,
|
||||
_onPollData = onPollData,
|
||||
_fromMap = fromMap,
|
||||
_config = config,
|
||||
_onStatusChanged = onStatusChanged;
|
||||
_onStatusChanged = onStatusChanged,
|
||||
_onOfflineData = onOfflineData,
|
||||
_onCacheMirror = onCacheMirror {
|
||||
// Register in the static registry so [notifyAllOnlineRestored] can reach
|
||||
// this instance without requiring call-site changes.
|
||||
_instances.add(WeakReference(this));
|
||||
// Prune any dead references (disposed wrappers) to keep the list small.
|
||||
_instances.removeWhere((r) => r.target == null);
|
||||
}
|
||||
|
||||
/// The wrapped stream that emits recovery results with metadata.
|
||||
///
|
||||
@@ -161,6 +238,10 @@ class StreamRecoveryWrapper<T> {
|
||||
void _onRealtimeData(List<Map<String, dynamic>> rows) {
|
||||
if (_disposed) return;
|
||||
|
||||
// Mark that live data has been received at least once. This is used in
|
||||
// the offline race-condition check in [_onRealtimeError].
|
||||
_hasEmittedData = true;
|
||||
|
||||
// When recovering, don't reset _recoveryAttempts immediately.
|
||||
// Supabase streams emit an initial REST fetch before the realtime
|
||||
// channel is established. If the channel keeps failing, resetting
|
||||
@@ -183,9 +264,22 @@ class StreamRecoveryWrapper<T> {
|
||||
}
|
||||
|
||||
_setStatus(StreamConnectionStatus.connected);
|
||||
final converted = rows.map(_fromMap).toList();
|
||||
|
||||
// Mirror live data into the local cache so cold-launching offline still
|
||||
// shows recent state. Best-effort — never throws into the consumer.
|
||||
final mirror = _onCacheMirror;
|
||||
if (mirror != null) {
|
||||
try {
|
||||
mirror(converted);
|
||||
} catch (e) {
|
||||
debugPrint('StreamRecoveryWrapper[$channelName]: mirror failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
_emit(
|
||||
StreamRecoveryResult<T>(
|
||||
data: rows.map(_fromMap).toList(),
|
||||
data: converted,
|
||||
connectionStatus: StreamConnectionStatus.connected,
|
||||
isStale: false,
|
||||
),
|
||||
@@ -198,6 +292,37 @@ class StreamRecoveryWrapper<T> {
|
||||
// Cancel any stability timer — the connection is not stable.
|
||||
_stabilityTimer?.cancel();
|
||||
|
||||
// Determine whether this error means the device is offline.
|
||||
//
|
||||
// Two cases handled:
|
||||
// 1. Connectivity monitor already updated → _globalIsOnline() is false.
|
||||
// 2. Race condition at startup: the realtime stream fails (SocketException)
|
||||
// BEFORE ConnectivityMonitor.check() completes (it takes up to 5 s).
|
||||
// In this window _globalIsOnline() still returns the default `true`
|
||||
// but the device is actually offline. We detect this by requiring:
|
||||
// (a) the error is a raw network connectivity failure, AND
|
||||
// (b) no live data has been received yet on this wrapper.
|
||||
// If data was already flowing and then dropped, we follow normal
|
||||
// recovery so the user gets their data back when connectivity
|
||||
// restores.
|
||||
final isOffline = !_globalIsOnline() ||
|
||||
(!_hasEmittedData && _isNetworkConnectivityError(error));
|
||||
|
||||
if (isOffline) {
|
||||
debugPrint(
|
||||
'StreamRecoveryWrapper[$channelName]: offline — suppressing recovery',
|
||||
);
|
||||
_setStatus(StreamConnectionStatus.offline);
|
||||
// Emit offline data so the StreamProvider exits AsyncLoading state.
|
||||
// Without this emission the provider stays in loading indefinitely when
|
||||
// offline at startup, causing the skeleton shimmer to show even though
|
||||
// Brick's SQLite cache has data.
|
||||
if (!_hasEmittedData) {
|
||||
unawaited(_emitOfflineData());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final isRateLimit = _isRateLimitError(error);
|
||||
final isTimeout = _isTimeoutError(error);
|
||||
final tag = isRateLimit
|
||||
@@ -260,6 +385,21 @@ class StreamRecoveryWrapper<T> {
|
||||
void _onRealtimeDone() {
|
||||
if (_disposed) return;
|
||||
debugPrint('StreamRecoveryWrapper[$channelName]: stream completed');
|
||||
|
||||
// If offline, the channel closed because the network dropped. Suppress
|
||||
// the restart — [notifyOnlineRestored] will handle it when we're back.
|
||||
if (!_globalIsOnline()) {
|
||||
debugPrint(
|
||||
'StreamRecoveryWrapper[$channelName]: offline — suppressing stream restart',
|
||||
);
|
||||
_setStatus(StreamConnectionStatus.offline);
|
||||
// Emit offline data to unblock any StreamProvider still in AsyncLoading.
|
||||
if (!_hasEmittedData) {
|
||||
unawaited(_emitOfflineData());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Attempt to reconnect once if the stream closes unexpectedly.
|
||||
if (_recoveryAttempts < _config.maxRecoveryAttempts) {
|
||||
_onRealtimeError(StateError('realtime stream completed unexpectedly'));
|
||||
@@ -282,8 +422,23 @@ class StreamRecoveryWrapper<T> {
|
||||
|
||||
Future<void> _pollOnce() async {
|
||||
if (_disposed) return;
|
||||
// Don't poll when offline — it will fail and mislead the status tracker.
|
||||
if (!_globalIsOnline()) return;
|
||||
try {
|
||||
final data = await _onPollData();
|
||||
|
||||
// Mirror polled data into the local cache too.
|
||||
final mirror = _onCacheMirror;
|
||||
if (mirror != null) {
|
||||
try {
|
||||
mirror(data);
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'StreamRecoveryWrapper[$channelName]: poll mirror failed: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_emit(
|
||||
StreamRecoveryResult<T>(
|
||||
data: data,
|
||||
@@ -330,6 +485,58 @@ class StreamRecoveryWrapper<T> {
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Returns true when [error] is a raw network-connectivity failure
|
||||
/// (socket closed, no route to host, DNS failure, etc.) rather than an
|
||||
/// application-level error from the server.
|
||||
///
|
||||
/// Used in the startup race-condition fix: if the realtime stream errors
|
||||
/// with a connectivity failure before the connectivity monitor has updated
|
||||
/// [_globalIsOnline], we treat it as offline so the device shows cached
|
||||
/// data instead of entering the recovery/shimmer loop.
|
||||
static bool _isNetworkConnectivityError(Object error) {
|
||||
final msg = error.toString().toLowerCase();
|
||||
return msg.contains('socketexception') ||
|
||||
msg.contains('connection refused') ||
|
||||
msg.contains('network is unreachable') ||
|
||||
msg.contains('no route to host') ||
|
||||
msg.contains('failed host lookup') ||
|
||||
msg.contains('connection reset') ||
|
||||
msg.contains('broken pipe') ||
|
||||
msg.contains('connection timed out') ||
|
||||
(msg.contains('clientexception') && msg.contains('network')) ||
|
||||
msg.contains('errno = 111') || // ECONNREFUSED
|
||||
msg.contains('errno = 101'); // ENETUNREACH
|
||||
}
|
||||
|
||||
/// Fetches data from the offline cache (via [_onOfflineData] callback when
|
||||
/// provided, otherwise an empty list) and emits it as an
|
||||
/// [StreamConnectionStatus.offline] result.
|
||||
///
|
||||
/// Called once when the device first goes offline so that consuming
|
||||
/// [StreamProvider]s exit [AsyncLoading] and the UI shows cached content
|
||||
/// (or an empty list) instead of the skeleton shimmer.
|
||||
Future<void> _emitOfflineData() async {
|
||||
if (_disposed) return;
|
||||
List<T> cached = const [];
|
||||
if (_onOfflineData != null) {
|
||||
try {
|
||||
cached = await _onOfflineData();
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'StreamRecoveryWrapper[$channelName]: offline cache read failed: $e',
|
||||
);
|
||||
cached = const [];
|
||||
}
|
||||
}
|
||||
_emit(
|
||||
StreamRecoveryResult<T>(
|
||||
data: cached,
|
||||
connectionStatus: StreamConnectionStatus.offline,
|
||||
isStale: cached.isNotEmpty,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _emit(StreamRecoveryResult<T> result) {
|
||||
if (!_disposed && _controller != null && !_controller!.isClosed) {
|
||||
_controller!.add(result);
|
||||
@@ -358,6 +565,20 @@ class StreamRecoveryWrapper<T> {
|
||||
_startRealtimeSubscription();
|
||||
}
|
||||
|
||||
/// Called by [notifyAllOnlineRestored] when connectivity is restored.
|
||||
///
|
||||
/// Only restarts the subscription if the channel is not already connected.
|
||||
/// Wrappers that were mid-backoff-timer while offline resume from scratch
|
||||
/// rather than continuing an already-stale timer.
|
||||
void notifyOnlineRestored() {
|
||||
if (_disposed) return;
|
||||
if (_connectionStatus == StreamConnectionStatus.connected) return;
|
||||
debugPrint(
|
||||
'StreamRecoveryWrapper[$channelName]: back online — restarting stream',
|
||||
);
|
||||
retry();
|
||||
}
|
||||
|
||||
/// Clean up all resources and notify the status callback that this
|
||||
/// channel is no longer active, preventing ghost entries in the
|
||||
/// [RealtimeController]'s recovering-channels set.
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'announcements_provider.dart';
|
||||
import 'attendance_provider.dart';
|
||||
import 'chat_provider.dart';
|
||||
import 'it_service_request_provider.dart';
|
||||
import 'leave_provider.dart';
|
||||
import 'notifications_provider.dart';
|
||||
import 'pass_slip_provider.dart';
|
||||
import 'profile_provider.dart';
|
||||
import 'tasks_provider.dart';
|
||||
import 'tickets_provider.dart';
|
||||
import 'user_offices_provider.dart';
|
||||
|
||||
/// Total count of items waiting to sync across all modules.
|
||||
/// Used by the offline banner and reconnect overlay for dynamic messaging.
|
||||
final pendingSyncCountProvider = Provider<int>((ref) {
|
||||
// Tasks
|
||||
final tasks = ref.watch(offlinePendingTasksProvider).length;
|
||||
final taskUpdates = ref.watch(offlinePendingTaskUpdatesProvider).length;
|
||||
final messages = ref.watch(offlinePendingMessagesProvider)
|
||||
.values
|
||||
.fold<int>(0, (sum, list) => sum + list.length);
|
||||
final assignments = ref.watch(offlinePendingAssignmentsProvider).length;
|
||||
final activityLogs =
|
||||
ref.watch(offlinePendingActivityLogItemsProvider).length;
|
||||
|
||||
// Leave
|
||||
final leaves = ref.watch(offlinePendingLeavesProvider).length;
|
||||
final leaveUpdates = ref.watch(offlinePendingLeaveUpdatesProvider).length;
|
||||
|
||||
// Pass slips
|
||||
final passSlips = ref.watch(offlinePendingPassSlipsProvider).length;
|
||||
final passSlipUpdates =
|
||||
ref.watch(offlinePendingPassSlipUpdatesProvider).length;
|
||||
|
||||
// Announcements
|
||||
final announcements = ref.watch(offlinePendingAnnouncementsProvider).length;
|
||||
final announcementUpdates =
|
||||
ref.watch(offlinePendingAnnouncementUpdatesProvider).length;
|
||||
final announcementComments =
|
||||
ref.watch(offlinePendingAnnouncementCommentsProvider).length;
|
||||
|
||||
// IT Service Requests
|
||||
final itRequests =
|
||||
ref.watch(offlinePendingItServiceRequestsProvider).length;
|
||||
final itRequestUpdates =
|
||||
ref.watch(offlinePendingItServiceRequestUpdatesProvider).length;
|
||||
final isrActions = ref.watch(offlinePendingIsrActionsProvider).length;
|
||||
|
||||
// Attendance
|
||||
final checkIns = ref.watch(offlinePendingCheckInsProvider).length;
|
||||
final attendanceUpdates =
|
||||
ref.watch(offlinePendingAttendanceUpdatesProvider).length;
|
||||
|
||||
// Notifications
|
||||
final notifReads =
|
||||
ref.watch(offlinePendingNotificationReadsProvider).length;
|
||||
final notifBatchReads =
|
||||
ref.watch(offlinePendingBatchNotificationReadsProvider).length;
|
||||
|
||||
// Profile
|
||||
final profileUpdates =
|
||||
ref.watch(offlinePendingProfileUpdatesProvider).length;
|
||||
|
||||
// Tickets
|
||||
final tickets = ref.watch(offlinePendingTicketsProvider).length;
|
||||
final ticketUpdates =
|
||||
ref.watch(offlinePendingTicketUpdatesProvider).length;
|
||||
|
||||
// Offices
|
||||
final offices = ref.watch(offlinePendingOfficesProvider).length;
|
||||
|
||||
// User offices
|
||||
final officeOps = ref.watch(offlinePendingUserOfficeOpsProvider).length;
|
||||
|
||||
// Chat
|
||||
final chatMessages = ref.watch(offlinePendingChatMessagesProvider)
|
||||
.values
|
||||
.fold<int>(0, (sum, list) => sum + list.length);
|
||||
|
||||
return tasks +
|
||||
taskUpdates +
|
||||
messages +
|
||||
assignments +
|
||||
activityLogs +
|
||||
leaves +
|
||||
leaveUpdates +
|
||||
passSlips +
|
||||
passSlipUpdates +
|
||||
announcements +
|
||||
announcementUpdates +
|
||||
announcementComments +
|
||||
itRequests +
|
||||
itRequestUpdates +
|
||||
isrActions +
|
||||
checkIns +
|
||||
attendanceUpdates +
|
||||
notifReads +
|
||||
notifBatchReads +
|
||||
profileUpdates +
|
||||
tickets +
|
||||
ticketUpdates +
|
||||
offices +
|
||||
officeOps +
|
||||
chatMessages;
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,11 @@ import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../brick/cache_helpers.dart';
|
||||
import '../brick/repository.dart';
|
||||
import '../utils/snackbar.dart' show isOfflineSaveError;
|
||||
import '../models/office.dart';
|
||||
import '../models/ticket.dart';
|
||||
import '../models/ticket_message.dart';
|
||||
@@ -12,11 +17,27 @@ import 'profile_provider.dart';
|
||||
import 'supabase_provider.dart';
|
||||
import 'user_offices_provider.dart';
|
||||
import 'tasks_provider.dart';
|
||||
import 'connectivity_provider.dart';
|
||||
import 'stream_recovery.dart';
|
||||
import 'realtime_controller.dart';
|
||||
|
||||
/// Pending offices created while offline.
|
||||
final offlinePendingOfficesProvider =
|
||||
StateProvider<List<Office>>((ref) => const []);
|
||||
|
||||
final officesProvider = StreamProvider<List<Office>>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
final pendingOffices = ref.watch(offlinePendingOfficesProvider);
|
||||
|
||||
List<Office> applyPending(List<Office> rows) {
|
||||
if (pendingOffices.isEmpty) return rows;
|
||||
final byId = {for (final r in rows) r.id: r};
|
||||
for (final p in pendingOffices) {
|
||||
byId.putIfAbsent(p.id, () => p);
|
||||
}
|
||||
return byId.values.toList()
|
||||
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||
}
|
||||
|
||||
final wrapper = StreamRecoveryWrapper<Office>(
|
||||
stream: client.from('offices').stream(primaryKey: ['id']).order('name'),
|
||||
@@ -27,10 +48,49 @@ final officesProvider = StreamProvider<List<Office>>((ref) {
|
||||
fromMap: Office.fromMap,
|
||||
channelName: 'offices',
|
||||
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
|
||||
onOfflineData: () async {
|
||||
final all = await cachedListFromBrick<Office>();
|
||||
all.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||
return applyPending(all);
|
||||
},
|
||||
onCacheMirror: (rows) =>
|
||||
mirrorBatchToBrick<Office>(rows, tag: 'offices'),
|
||||
);
|
||||
|
||||
ref.onDispose(wrapper.dispose);
|
||||
return wrapper.stream.map((result) => result.data);
|
||||
|
||||
// Reconnect-replay for offline-created offices.
|
||||
ref.listen<bool>(isOnlineProvider, (wasOnline, isNowOnline) async {
|
||||
if (wasOnline == true || !isNowOnline) return;
|
||||
final pending = List<Office>.from(
|
||||
ref.read(offlinePendingOfficesProvider),
|
||||
);
|
||||
if (pending.isEmpty) return;
|
||||
final synced = <String>[];
|
||||
for (final office in pending) {
|
||||
try {
|
||||
final payload = <String, dynamic>{'id': office.id, 'name': office.name};
|
||||
if (office.serviceId != null) payload['service_id'] = office.serviceId;
|
||||
await client.from('offices').insert(payload);
|
||||
synced.add(office.id);
|
||||
} catch (e) {
|
||||
final s = e.toString();
|
||||
if (s.contains('23505') || s.contains('duplicate key')) {
|
||||
synced.add(office.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (synced.isNotEmpty) {
|
||||
final remaining = ref
|
||||
.read(offlinePendingOfficesProvider)
|
||||
.where((o) => !synced.contains(o.id))
|
||||
.toList();
|
||||
ref.read(offlinePendingOfficesProvider.notifier).state =
|
||||
List.unmodifiable(remaining);
|
||||
}
|
||||
});
|
||||
|
||||
return wrapper.stream.map((result) => applyPending(result.data));
|
||||
});
|
||||
|
||||
final officesOnceProvider = FutureProvider<List<Office>>((ref) async {
|
||||
@@ -70,7 +130,7 @@ final officesQueryProvider = StateProvider<OfficeQuery>(
|
||||
|
||||
final officesControllerProvider = Provider<OfficesController>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return OfficesController(client);
|
||||
return OfficesController(client, ref);
|
||||
});
|
||||
|
||||
/// Ticket query parameters for server-side pagination and filtering.
|
||||
@@ -210,10 +270,90 @@ final ticketsProvider = StreamProvider<List<Ticket>>((ref) {
|
||||
fromMap: Ticket.fromMap,
|
||||
channelName: 'tickets',
|
||||
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
|
||||
onOfflineData: () => cachedListFromBrick<Ticket>(),
|
||||
onCacheMirror: (rows) =>
|
||||
mirrorBatchToBrick<Ticket>(rows, tag: 'tickets'),
|
||||
);
|
||||
|
||||
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 tickets
|
||||
final pendingRaw = List<Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingTicketRawProvider),
|
||||
);
|
||||
if (pendingRaw.isNotEmpty) {
|
||||
debugPrint(
|
||||
'[ticketsProvider] reconnected — syncing ${pendingRaw.length} ticket(s)',
|
||||
);
|
||||
final syncedIds = <String>[];
|
||||
for (final row in pendingRaw) {
|
||||
final id = row['id'] as String;
|
||||
try {
|
||||
await client.from('tickets').insert(row);
|
||||
syncedIds.add(id);
|
||||
} catch (e) {
|
||||
final msg = e.toString();
|
||||
if (msg.contains('23505') || msg.contains('duplicate key')) {
|
||||
syncedIds.add(id);
|
||||
} else {
|
||||
debugPrint('[ticketsProvider] failed to sync ticket id=$id: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (syncedIds.isNotEmpty) {
|
||||
final remainingRaw = ref
|
||||
.read(offlinePendingTicketRawProvider)
|
||||
.where((r) => !syncedIds.contains(r['id'] as String))
|
||||
.toList();
|
||||
ref.read(offlinePendingTicketRawProvider.notifier).state =
|
||||
List.unmodifiable(remainingRaw);
|
||||
final remainingModels = ref
|
||||
.read(offlinePendingTicketsProvider)
|
||||
.where((t) => !syncedIds.contains(t.id))
|
||||
.toList();
|
||||
ref.read(offlinePendingTicketsProvider.notifier).state =
|
||||
List.unmodifiable(remainingModels);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Replay offline ticket updates
|
||||
final pendingUpdates = Map<String, Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingTicketUpdatesProvider),
|
||||
);
|
||||
if (pendingUpdates.isNotEmpty) {
|
||||
debugPrint(
|
||||
'[ticketsProvider] reconnected — syncing ${pendingUpdates.length} ticket update(s)',
|
||||
);
|
||||
final syncedIds = <String>[];
|
||||
for (final entry in pendingUpdates.entries) {
|
||||
try {
|
||||
await client
|
||||
.from('tickets')
|
||||
.update(entry.value)
|
||||
.eq('id', entry.key);
|
||||
syncedIds.add(entry.key);
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'[ticketsProvider] failed to sync update id=${entry.key}: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (syncedIds.isNotEmpty) {
|
||||
final remaining = Map<String, Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingTicketUpdatesProvider),
|
||||
);
|
||||
for (final id in syncedIds) {
|
||||
remaining.remove(id);
|
||||
}
|
||||
ref.read(offlinePendingTicketUpdatesProvider.notifier).state = remaining;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var lastResultHash = '';
|
||||
Timer? debounceTimer;
|
||||
// broadcast() so Riverpod and any other listener can both receive events.
|
||||
@@ -239,6 +379,35 @@ final ticketsProvider = StreamProvider<List<Ticket>>((ref) {
|
||||
// prevents a duplicate rebuild if both arrive with identical data.
|
||||
unawaited(
|
||||
Future(() async {
|
||||
// When offline, seed from Brick local SQLite cache instead of network.
|
||||
if (!ref.read(isOnlineProvider) && AppRepository.isConfigured) {
|
||||
try {
|
||||
final cached = await AppRepository.instance.get<Ticket>(
|
||||
policy: OfflineFirstGetPolicy.localOnly,
|
||||
);
|
||||
debugPrint('[ticketsProvider] offline seed: ${cached.length} tickets from local cache');
|
||||
final payload = _buildTicketPayload(
|
||||
tickets: cached,
|
||||
isGlobal: isGlobal,
|
||||
allowedOfficeIds: allowedOfficeIds,
|
||||
query: query,
|
||||
);
|
||||
final processed = await compute(_processTicketsInIsolate, payload);
|
||||
final tickets = (processed as List<dynamic>)
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map(Ticket.fromMap)
|
||||
.toList();
|
||||
final hash = tickets.fold('', (h, t) => '$h${t.id}');
|
||||
if (!controller.isClosed && hash != lastResultHash) {
|
||||
lastResultHash = hash;
|
||||
controller.add(tickets);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[ticketsProvider] offline seed error: $e');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Seed fetch: order newest-first and limit to 200 rows to reduce
|
||||
// initial payload. The realtime stream delivers subsequent changes.
|
||||
@@ -506,35 +675,91 @@ final taskMessagesProvider = StreamProvider.family<List<TicketMessage>, String>(
|
||||
},
|
||||
);
|
||||
|
||||
/// Tickets created offline that haven't synced to Supabase yet.
|
||||
/// Merged into the live stream in the UI — cleared entry-by-entry as the
|
||||
/// realtime stream delivers each synced UUID.
|
||||
final offlinePendingTicketsProvider =
|
||||
StateProvider<List<Ticket>>((ref) => const []);
|
||||
|
||||
/// Raw INSERT payloads for offline-created tickets — used for reconnect replay.
|
||||
final offlinePendingTicketRawProvider =
|
||||
StateProvider<List<Map<String, dynamic>>>((ref) => const []);
|
||||
|
||||
/// Field-level updates to existing tickets made offline, keyed by ticket id.
|
||||
final offlinePendingTicketUpdatesProvider =
|
||||
StateProvider<Map<String, Map<String, dynamic>>>((ref) => const {});
|
||||
|
||||
final ticketsControllerProvider = Provider<TicketsController>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return TicketsController(client);
|
||||
return TicketsController(client, ref);
|
||||
});
|
||||
|
||||
class TicketsController {
|
||||
TicketsController(this._client);
|
||||
TicketsController(this._client, [this._ref]);
|
||||
|
||||
final SupabaseClient _client;
|
||||
final Ref? _ref;
|
||||
|
||||
Future<void> createTicket({
|
||||
/// Creates a ticket. Returns the new [Ticket] so callers can add it to
|
||||
/// [offlinePendingTicketsProvider] when offline.
|
||||
Future<Ticket?> createTicket({
|
||||
required String subject,
|
||||
required String description,
|
||||
required String officeId,
|
||||
}) async {
|
||||
final actorId = _client.auth.currentUser?.id;
|
||||
final data = await _client
|
||||
.from('tickets')
|
||||
.insert({
|
||||
// Generate UUID before the network call so Brick's offline queue captures
|
||||
// the same UUID in the queued request — preventing a UUID mismatch on replay.
|
||||
final id = const Uuid().v4();
|
||||
try {
|
||||
final data = await _client
|
||||
.from('tickets')
|
||||
.insert({
|
||||
'id': id,
|
||||
'subject': subject,
|
||||
'description': description,
|
||||
'office_id': officeId,
|
||||
'creator_id': actorId,
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
final ticketId = data['id'] as String?;
|
||||
if (ticketId == null) return null;
|
||||
unawaited(_notifyCreated(ticketId: ticketId, actorId: actorId));
|
||||
return null; // online path — ticket will arrive via realtime stream
|
||||
} catch (e) {
|
||||
if (!isOfflineSaveError(e)) rethrow;
|
||||
final ticket = Ticket(
|
||||
id: id,
|
||||
subject: subject,
|
||||
description: description,
|
||||
officeId: officeId,
|
||||
creatorId: actorId ?? '',
|
||||
status: 'pending',
|
||||
createdAt: DateTime.now().toUtc(),
|
||||
respondedAt: null,
|
||||
promotedAt: null,
|
||||
closedAt: null,
|
||||
);
|
||||
// Queue for reconnect replay and persist locally for cold-launch.
|
||||
final ref = _ref;
|
||||
if (ref != null) {
|
||||
final currentRaw = List<Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingTicketRawProvider),
|
||||
);
|
||||
currentRaw.add({
|
||||
'id': id,
|
||||
'subject': subject,
|
||||
'description': description,
|
||||
'office_id': officeId,
|
||||
'creator_id': _client.auth.currentUser?.id,
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
final ticketId = data['id'] as String?;
|
||||
if (ticketId == null) return;
|
||||
unawaited(_notifyCreated(ticketId: ticketId, actorId: actorId));
|
||||
'creator_id': actorId,
|
||||
});
|
||||
ref.read(offlinePendingTicketRawProvider.notifier).state =
|
||||
List.unmodifiable(currentRaw);
|
||||
}
|
||||
mirrorBatchToBrick<Ticket>([ticket], tag: 'offline_ticket');
|
||||
return ticket;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _notifyCreated({
|
||||
@@ -661,27 +886,66 @@ class TicketsController {
|
||||
required String? ticketId,
|
||||
required String content,
|
||||
}) async {
|
||||
final senderId = _client.auth.currentUser?.id;
|
||||
final payload = <String, dynamic>{
|
||||
'task_id': taskId,
|
||||
'content': content,
|
||||
'sender_id': _client.auth.currentUser?.id,
|
||||
'sender_id': senderId,
|
||||
};
|
||||
if (ticketId != null) {
|
||||
payload['ticket_id'] = ticketId;
|
||||
}
|
||||
final data = await _client
|
||||
.from('ticket_messages')
|
||||
.insert(payload)
|
||||
.select()
|
||||
.single();
|
||||
return TicketMessage.fromMap(data);
|
||||
try {
|
||||
final data = await _client
|
||||
.from('ticket_messages')
|
||||
.insert(payload)
|
||||
.select()
|
||||
.single();
|
||||
return TicketMessage.fromMap(data);
|
||||
} catch (e) {
|
||||
if (isOfflineSaveError(e)) {
|
||||
// Store message locally so it appears in the UI immediately and is
|
||||
// replayed by tasksProvider's reconnect listener when back online.
|
||||
final ref = _ref;
|
||||
if (ref != null) {
|
||||
final createdAt = DateTime.now().toUtc().toIso8601String();
|
||||
final msgPayload = Map<String, dynamic>.from(payload)
|
||||
..['created_at'] = createdAt;
|
||||
final current = Map<String, List<Map<String, dynamic>>>.from(
|
||||
ref.read(offlinePendingMessagesProvider),
|
||||
);
|
||||
current[taskId] = [...(current[taskId] ?? []), msgPayload];
|
||||
ref.read(offlinePendingMessagesProvider.notifier).state = current;
|
||||
// Return a synthetic TicketMessage so the caller's mention logic
|
||||
// still has a message object to work with.
|
||||
return TicketMessage(
|
||||
id: -DateTime.now().millisecondsSinceEpoch,
|
||||
ticketId: ticketId,
|
||||
taskId: taskId,
|
||||
senderId: senderId,
|
||||
content: content,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateTicketStatus({
|
||||
required String ticketId,
|
||||
required String status,
|
||||
}) async {
|
||||
await _client.from('tickets').update({'status': status}).eq('id', ticketId);
|
||||
try {
|
||||
await _client
|
||||
.from('tickets')
|
||||
.update({'status': status})
|
||||
.eq('id', ticketId);
|
||||
} catch (e) {
|
||||
if (!isOfflineSaveError(e)) rethrow;
|
||||
_queueTicketUpdate(ticketId, {'status': status});
|
||||
return;
|
||||
}
|
||||
|
||||
// If ticket is promoted, create a linked Task (only once) — the
|
||||
// TasksController.createTask already runs auto-assignment on creation.
|
||||
@@ -718,6 +982,7 @@ class TicketsController {
|
||||
}
|
||||
|
||||
/// Update editable ticket fields such as subject, description, and office.
|
||||
/// Offline: queues the field patch.
|
||||
Future<void> updateTicket({
|
||||
required String ticketId,
|
||||
String? subject,
|
||||
@@ -730,29 +995,59 @@ class TicketsController {
|
||||
if (officeId != null) payload['office_id'] = officeId;
|
||||
if (payload.isEmpty) return;
|
||||
|
||||
await _client.from('tickets').update(payload).eq('id', ticketId);
|
||||
|
||||
// record an activity row for edit operations (best-effort)
|
||||
try {
|
||||
final actorId = _client.auth.currentUser?.id;
|
||||
await _client.from('ticket_messages').insert({
|
||||
'ticket_id': ticketId,
|
||||
'sender_id': actorId,
|
||||
'content': 'Ticket updated',
|
||||
});
|
||||
} catch (_) {}
|
||||
await _client.from('tickets').update(payload).eq('id', ticketId);
|
||||
// record an activity row for edit operations (best-effort)
|
||||
try {
|
||||
final actorId = _client.auth.currentUser?.id;
|
||||
await _client.from('ticket_messages').insert({
|
||||
'ticket_id': ticketId,
|
||||
'sender_id': actorId,
|
||||
'content': 'Ticket updated',
|
||||
});
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
if (!isOfflineSaveError(e)) rethrow;
|
||||
_queueTicketUpdate(ticketId, payload);
|
||||
}
|
||||
}
|
||||
|
||||
void _queueTicketUpdate(String ticketId, Map<String, dynamic> fields) {
|
||||
final ref = _ref;
|
||||
if (ref == null) return;
|
||||
final current = Map<String, Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingTicketUpdatesProvider),
|
||||
);
|
||||
current[ticketId] = {...?current[ticketId], ...fields};
|
||||
ref.read(offlinePendingTicketUpdatesProvider.notifier).state = current;
|
||||
}
|
||||
}
|
||||
|
||||
class OfficesController {
|
||||
OfficesController(this._client);
|
||||
OfficesController(this._client, [this._ref]);
|
||||
|
||||
final SupabaseClient _client;
|
||||
final Ref? _ref;
|
||||
|
||||
Future<void> createOffice({required String name, String? serviceId}) async {
|
||||
final payload = {'name': name};
|
||||
final id = const Uuid().v4();
|
||||
final payload = <String, dynamic>{'id': id, 'name': name};
|
||||
if (serviceId != null) payload['service_id'] = serviceId;
|
||||
await _client.from('offices').insert(payload);
|
||||
try {
|
||||
await _client.from('offices').insert(payload);
|
||||
} catch (e) {
|
||||
if (!isOfflineSaveError(e)) rethrow;
|
||||
final ref = _ref;
|
||||
if (ref == null) return;
|
||||
final office = Office(id: id, name: name, serviceId: serviceId);
|
||||
final current = List<Office>.from(
|
||||
ref.read(offlinePendingOfficesProvider),
|
||||
);
|
||||
current.add(office);
|
||||
ref.read(offlinePendingOfficesProvider.notifier).state =
|
||||
List.unmodifiable(current);
|
||||
mirrorBatchToBrick<Office>([office], tag: 'offline_offices');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateOffice({
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import 'connectivity_provider.dart';
|
||||
import 'supabase_provider.dart';
|
||||
|
||||
class TypingIndicatorState {
|
||||
@@ -37,24 +38,47 @@ final typingIndicatorProvider =
|
||||
String
|
||||
>((ref, ticketId) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
final controller = TypingIndicatorController(client, ticketId);
|
||||
final controller = TypingIndicatorController(
|
||||
client,
|
||||
ticketId,
|
||||
isOnline: () => ref.read(isOnlineProvider),
|
||||
);
|
||||
|
||||
// Pause the realtime channel when the device goes offline so Supabase's
|
||||
// internal reconnection loop stops firing WebSocket errors.
|
||||
// Resume (re-subscribe) when connectivity returns.
|
||||
ref.listen<bool>(isOnlineProvider, (previous, next) {
|
||||
if (next == false) {
|
||||
controller.pauseForOffline();
|
||||
} else if (previous != true && next == true) {
|
||||
// previous is null on first call after a provider rebuild — treat
|
||||
// null and false both as "was offline" to ensure the channel resumes.
|
||||
controller.resumeFromOffline();
|
||||
}
|
||||
});
|
||||
|
||||
return controller;
|
||||
});
|
||||
|
||||
class TypingIndicatorController extends StateNotifier<TypingIndicatorState> {
|
||||
TypingIndicatorController(this._client, this._ticketId)
|
||||
: super(
|
||||
const TypingIndicatorState(
|
||||
userIds: {},
|
||||
channelStatus: 'init',
|
||||
lastPayload: {},
|
||||
),
|
||||
) {
|
||||
TypingIndicatorController(
|
||||
this._client,
|
||||
this._ticketId, {
|
||||
bool Function()? isOnline,
|
||||
}) : _isOnline = isOnline ?? (() => true),
|
||||
super(
|
||||
const TypingIndicatorState(
|
||||
userIds: {},
|
||||
channelStatus: 'init',
|
||||
lastPayload: {},
|
||||
),
|
||||
) {
|
||||
_initChannel();
|
||||
}
|
||||
|
||||
final SupabaseClient _client;
|
||||
final String _ticketId;
|
||||
final bool Function() _isOnline;
|
||||
RealtimeChannel? _channel;
|
||||
Timer? _typingTimer;
|
||||
final Map<String, Timer> _remoteTimeouts = {};
|
||||
@@ -94,7 +118,9 @@ class TypingIndicatorController extends StateNotifier<TypingIndicatorState> {
|
||||
try {
|
||||
if (_disposed || !mounted) return;
|
||||
state = state.copyWith(channelStatus: status.name);
|
||||
if (error != null) {
|
||||
if (error != null && _isOnline()) {
|
||||
// Only log when online — offline WebSocket errors are expected and
|
||||
// handled by pauseForOffline() which unsubscribes the channel.
|
||||
debugPrint('TypingIndicatorController: subscribe error: $error');
|
||||
}
|
||||
} catch (e, st) {
|
||||
@@ -235,6 +261,31 @@ class TypingIndicatorController extends StateNotifier<TypingIndicatorState> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Called when the device goes offline.
|
||||
///
|
||||
/// Unsubscribes the Supabase channel so its internal WebSocket reconnection
|
||||
/// loop stops firing errors. The channel object is retained so
|
||||
/// [resumeFromOffline] can cleanly re-subscribe on the same ticket.
|
||||
void pauseForOffline() {
|
||||
if (_disposed) return;
|
||||
debugPrint('TypingIndicatorController[$_ticketId]: offline — unsubscribing');
|
||||
_channel?.unsubscribe();
|
||||
}
|
||||
|
||||
/// Called when the device comes back online.
|
||||
///
|
||||
/// Tears down the stale channel and opens a fresh subscription so broadcast
|
||||
/// events are received again without any missed-message gaps.
|
||||
void resumeFromOffline() {
|
||||
if (_disposed) return;
|
||||
debugPrint(
|
||||
'TypingIndicatorController[$_ticketId]: back online — resubscribing',
|
||||
);
|
||||
_channel?.unsubscribe();
|
||||
_channel = null;
|
||||
_initChannel();
|
||||
}
|
||||
|
||||
// Exposed for tests only: simulate a remote typing broadcast.
|
||||
@visibleForTesting
|
||||
void debugSimulateRemoteTyping(String userId, {bool stop = false}) {
|
||||
|
||||
@@ -1,63 +1,186 @@
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import '../brick/cache_warmer.dart';
|
||||
import '../models/user_office.dart';
|
||||
import '../utils/snackbar.dart' show isOfflineSaveError;
|
||||
import 'connectivity_provider.dart';
|
||||
import 'supabase_provider.dart';
|
||||
import 'stream_recovery.dart';
|
||||
import 'realtime_controller.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Offline-pending state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Queued assign/remove operations made offline.
|
||||
/// Each entry: {'op': 'assign'|'remove', 'user_id': ..., 'office_id': ...}
|
||||
final offlinePendingUserOfficeOpsProvider =
|
||||
StateProvider<List<Map<String, dynamic>>>((ref) => const []);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
final userOfficesProvider = StreamProvider<List<UserOffice>>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
|
||||
final pendingOps = ref.watch(offlinePendingUserOfficeOpsProvider);
|
||||
|
||||
List<UserOffice> applyPending(List<UserOffice> rows) {
|
||||
if (pendingOps.isEmpty) return rows;
|
||||
var result = List<UserOffice>.from(rows);
|
||||
for (final op in pendingOps) {
|
||||
final userId = op['user_id'] as String;
|
||||
final officeId = op['office_id'] as String;
|
||||
if (op['op'] == 'assign') {
|
||||
final exists = result.any(
|
||||
(r) => r.userId == userId && r.officeId == officeId,
|
||||
);
|
||||
if (!exists) result.add(UserOffice(userId: userId, officeId: officeId));
|
||||
} else if (op['op'] == 'remove') {
|
||||
result.removeWhere(
|
||||
(r) => r.userId == userId && r.officeId == officeId,
|
||||
);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
final wrapper = StreamRecoveryWrapper<UserOffice>(
|
||||
stream: client
|
||||
.from('user_offices')
|
||||
.stream(primaryKey: ['user_id', 'office_id'])
|
||||
.order('created_at'),
|
||||
onPollData: () async {
|
||||
final data = await client
|
||||
.from('user_offices')
|
||||
.select()
|
||||
.order('created_at');
|
||||
final data = await client.from('user_offices').select().order('created_at');
|
||||
return data.map(UserOffice.fromMap).toList();
|
||||
},
|
||||
fromMap: UserOffice.fromMap,
|
||||
channelName: 'user_offices',
|
||||
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
|
||||
onOfflineData: () async {
|
||||
final rows = await CacheWarmer.readCachedUserOffices();
|
||||
return applyPending(rows.map(UserOffice.fromMap).toList());
|
||||
},
|
||||
);
|
||||
|
||||
ref.onDispose(wrapper.dispose);
|
||||
return wrapper.stream.map((result) => result.data);
|
||||
|
||||
// Reconnect-replay
|
||||
ref.listen<bool>(isOnlineProvider, (wasOnline, isNowOnline) async {
|
||||
if (wasOnline == true || !isNowOnline) return;
|
||||
final pending = List<Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingUserOfficeOpsProvider),
|
||||
);
|
||||
if (pending.isEmpty) return;
|
||||
debugPrint(
|
||||
'[userOfficesProvider] reconnected — syncing ${pending.length} office op(s)',
|
||||
);
|
||||
final synced = <int>[];
|
||||
for (var i = 0; i < pending.length; i++) {
|
||||
final op = pending[i];
|
||||
final userId = op['user_id'] as String;
|
||||
final officeId = op['office_id'] as String;
|
||||
try {
|
||||
if (op['op'] == 'assign') {
|
||||
await client.from('user_offices').insert({
|
||||
'user_id': userId,
|
||||
'office_id': officeId,
|
||||
});
|
||||
} else {
|
||||
await client
|
||||
.from('user_offices')
|
||||
.delete()
|
||||
.eq('user_id', userId)
|
||||
.eq('office_id', officeId);
|
||||
}
|
||||
synced.add(i);
|
||||
} catch (e) {
|
||||
final msg = e.toString();
|
||||
if (msg.contains('23505') || msg.contains('duplicate key')) {
|
||||
synced.add(i);
|
||||
} else {
|
||||
debugPrint(
|
||||
'[userOfficesProvider] failed to sync op $i: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (synced.isNotEmpty) {
|
||||
final remaining = <Map<String, dynamic>>[];
|
||||
for (var i = 0; i < pending.length; i++) {
|
||||
if (!synced.contains(i)) remaining.add(pending[i]);
|
||||
}
|
||||
ref.read(offlinePendingUserOfficeOpsProvider.notifier).state =
|
||||
List.unmodifiable(remaining);
|
||||
}
|
||||
});
|
||||
|
||||
return wrapper.stream.map((result) => applyPending(result.data));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Controller
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
final userOfficesControllerProvider = Provider<UserOfficesController>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return UserOfficesController(client);
|
||||
return UserOfficesController(client, ref);
|
||||
});
|
||||
|
||||
class UserOfficesController {
|
||||
UserOfficesController(this._client);
|
||||
UserOfficesController(this._client, [this._ref]);
|
||||
|
||||
final SupabaseClient _client;
|
||||
final Ref? _ref;
|
||||
|
||||
/// Assign a user to an office. Offline: queues the operation.
|
||||
Future<void> assignUserOffice({
|
||||
required String userId,
|
||||
required String officeId,
|
||||
}) async {
|
||||
await _client.from('user_offices').insert({
|
||||
'user_id': userId,
|
||||
'office_id': officeId,
|
||||
});
|
||||
try {
|
||||
await _client.from('user_offices').insert({
|
||||
'user_id': userId,
|
||||
'office_id': officeId,
|
||||
});
|
||||
} catch (e) {
|
||||
if (!isOfflineSaveError(e)) rethrow;
|
||||
_queueOp('assign', userId, officeId);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a user from an office. Offline: queues the operation.
|
||||
Future<void> removeUserOffice({
|
||||
required String userId,
|
||||
required String officeId,
|
||||
}) async {
|
||||
await _client
|
||||
.from('user_offices')
|
||||
.delete()
|
||||
.eq('user_id', userId)
|
||||
.eq('office_id', officeId);
|
||||
try {
|
||||
await _client
|
||||
.from('user_offices')
|
||||
.delete()
|
||||
.eq('user_id', userId)
|
||||
.eq('office_id', officeId);
|
||||
} catch (e) {
|
||||
if (!isOfflineSaveError(e)) rethrow;
|
||||
_queueOp('remove', userId, officeId);
|
||||
}
|
||||
}
|
||||
|
||||
void _queueOp(String op, String userId, String officeId) {
|
||||
final ref = _ref;
|
||||
if (ref == null) return;
|
||||
final current = List<Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingUserOfficeOpsProvider),
|
||||
);
|
||||
// Dedupe: collapse redundant assign/remove pairs for the same user+office
|
||||
current.removeWhere(
|
||||
(e) => e['user_id'] == userId && e['office_id'] == officeId,
|
||||
);
|
||||
current.add({'op': op, 'user_id': userId, 'office_id': officeId});
|
||||
ref.read(offlinePendingUserOfficeOpsProvider.notifier).state =
|
||||
List.unmodifiable(current);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import '../brick/cache_helpers.dart';
|
||||
import '../models/app_settings.dart';
|
||||
import '../models/duty_schedule.dart';
|
||||
import '../models/swap_request.dart';
|
||||
@@ -52,6 +53,13 @@ final dutySchedulesProvider = StreamProvider<List<DutySchedule>>((ref) {
|
||||
fromMap: DutySchedule.fromMap,
|
||||
channelName: 'duty_schedules',
|
||||
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
|
||||
onOfflineData: () async {
|
||||
final all = await cachedListFromBrick<DutySchedule>();
|
||||
all.sort((a, b) => a.startTime.compareTo(b.startTime));
|
||||
return all;
|
||||
},
|
||||
onCacheMirror: (rows) =>
|
||||
mirrorBatchToBrick<DutySchedule>(rows, tag: 'duty_schedules'),
|
||||
);
|
||||
|
||||
ref.onDispose(wrapper.dispose);
|
||||
@@ -150,6 +158,20 @@ final swapRequestsProvider = StreamProvider<List<SwapRequest>>((ref) {
|
||||
fromMap: SwapRequest.fromMap,
|
||||
channelName: 'swap_requests',
|
||||
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
|
||||
onOfflineData: () async {
|
||||
final all = await cachedListFromBrick<SwapRequest>();
|
||||
final filtered = isAdmin
|
||||
? all
|
||||
: all
|
||||
.where(
|
||||
(r) => r.requesterId == profileId || r.recipientId == profileId,
|
||||
)
|
||||
.toList();
|
||||
filtered.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
return filtered;
|
||||
},
|
||||
onCacheMirror: (rows) =>
|
||||
mirrorBatchToBrick<SwapRequest>(rows, tag: 'swap_requests'),
|
||||
);
|
||||
|
||||
ref.onDispose(wrapper.dispose);
|
||||
|
||||
Reference in New Issue
Block a user