Offline Support

This commit is contained in:
2026-04-27 06:20:39 +08:00
parent 7d8851a94a
commit 48cd3bcd60
121 changed files with 14798 additions and 2214 deletions
+370 -62
View File
@@ -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');
}
}
+349 -46
View File
@@ -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;
}
}
+134 -8
View File
@@ -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');
}
}
}
+87
View File
@@ -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);
}
}
+493 -81
View File
@@ -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,
);
}
+256 -53
View File
@@ -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({
+214 -43
View File
@@ -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;
}
}
+269 -69
View File
@@ -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({
+81 -6
View File
@@ -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).
+11 -44
View File
@@ -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();
}
}
+8
View File
@@ -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);
+223 -2
View File
@@ -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.
+107
View File
@@ -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
+331 -36
View File
@@ -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({
+61 -10
View File
@@ -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}) {
+139 -16
View File
@@ -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);
}
}
+22
View File
@@ -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);