Offline rediness
This commit is contained in:
@@ -175,19 +175,55 @@ final attendanceLogsProvider = StreamProvider<List<AttendanceLog>>((ref) {
|
||||
for (final params in pendingRaw) {
|
||||
final localId = params['localId'] as String;
|
||||
try {
|
||||
await client.rpc(
|
||||
final serverId = await client.rpc(
|
||||
'attendance_check_in',
|
||||
params: {
|
||||
'p_duty_id': params['p_duty_id'],
|
||||
'p_lat': params['p_lat'],
|
||||
'p_lng': params['p_lng'],
|
||||
},
|
||||
);
|
||||
) as String?;
|
||||
syncedLocalIds.add(localId);
|
||||
// Re-key any pending verification update from local UUID → server UUID
|
||||
// so the PATCH in the updates replay hits the real attendance_logs row.
|
||||
if (serverId != null) {
|
||||
final updates = ref.read(offlinePendingAttendanceUpdatesProvider);
|
||||
if (updates.containsKey(localId)) {
|
||||
final updated = Map<String, Map<String, dynamic>>.from(updates);
|
||||
updated[serverId] = updated.remove(localId)!;
|
||||
ref
|
||||
.read(offlinePendingAttendanceUpdatesProvider.notifier)
|
||||
.state = updated;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
final msg = e.toString();
|
||||
if (msg.contains('23505') || msg.contains('duplicate key')) {
|
||||
syncedLocalIds.add(localId);
|
||||
// Duplicate key means the log already exists on the server.
|
||||
// Look up its real ID so any pending verification photo can be re-keyed.
|
||||
try {
|
||||
final existing = await client
|
||||
.from('attendance_logs')
|
||||
.select('id')
|
||||
.eq('duty_schedule_id', params['p_duty_id'] as String)
|
||||
.order('check_in_at', ascending: false)
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
final serverId = existing?['id'] as String?;
|
||||
if (serverId != null) {
|
||||
final updates =
|
||||
ref.read(offlinePendingAttendanceUpdatesProvider);
|
||||
if (updates.containsKey(localId)) {
|
||||
final updated =
|
||||
Map<String, Map<String, dynamic>>.from(updates);
|
||||
updated[serverId] = updated.remove(localId)!;
|
||||
ref
|
||||
.read(offlinePendingAttendanceUpdatesProvider.notifier)
|
||||
.state = updated;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
} else {
|
||||
debugPrint(
|
||||
'[attendanceLogsProvider] failed to sync check-in localId=$localId: $e',
|
||||
|
||||
@@ -419,6 +419,12 @@ class NotificationsController {
|
||||
final current = List<Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingBatchNotificationReadsProvider),
|
||||
);
|
||||
// Idempotency: skip if same (filter_type, filter_id) is already queued.
|
||||
// Mark-read is idempotent — queuing duplicates only inflates the banner count.
|
||||
if (current.any((e) =>
|
||||
e['filter_type'] == filterType && e['filter_id'] == filterId)) {
|
||||
return;
|
||||
}
|
||||
current.add({
|
||||
'filter_type': filterType,
|
||||
'filter_id': filterId,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../brick/cache_warmer.dart';
|
||||
import 'attendance_provider.dart';
|
||||
|
||||
class OfflineReadinessState {
|
||||
final bool hasWarmedOnce;
|
||||
final Map<String, int> cacheCounts;
|
||||
final int pendingCheckIns;
|
||||
final int pendingUpdates;
|
||||
final int pendingPhotos;
|
||||
|
||||
const OfflineReadinessState({
|
||||
required this.hasWarmedOnce,
|
||||
required this.cacheCounts,
|
||||
required this.pendingCheckIns,
|
||||
required this.pendingUpdates,
|
||||
required this.pendingPhotos,
|
||||
});
|
||||
|
||||
bool get isReady {
|
||||
if (!hasWarmedOnce) return false;
|
||||
const critical = ['tasks', 'profiles', 'duty_schedules', 'attendance_logs'];
|
||||
return critical.every((t) => (cacheCounts[t] ?? -1) >= 0);
|
||||
}
|
||||
|
||||
int get totalPending => pendingCheckIns + pendingUpdates;
|
||||
}
|
||||
|
||||
final offlineReadinessProvider =
|
||||
FutureProvider.autoDispose<OfflineReadinessState>((ref) async {
|
||||
final counts = await CacheWarmer.readCacheCounts();
|
||||
final pendingCheckIns = ref.watch(offlinePendingCheckInsProvider).length;
|
||||
final pendingUpdatesMap = ref.watch(offlinePendingAttendanceUpdatesProvider);
|
||||
final pendingUpdates = pendingUpdatesMap.length;
|
||||
final pendingPhotos = pendingUpdatesMap.values
|
||||
.where((f) => f.containsKey('_local_photo_path'))
|
||||
.length;
|
||||
|
||||
return OfflineReadinessState(
|
||||
hasWarmedOnce: CacheWarmer.hasWarmedOnce,
|
||||
cacheCounts: counts,
|
||||
pendingCheckIns: pendingCheckIns,
|
||||
pendingUpdates: pendingUpdates,
|
||||
pendingPhotos: pendingPhotos,
|
||||
);
|
||||
});
|
||||
@@ -105,3 +105,73 @@ final pendingSyncCountProvider = Provider<int>((ref) {
|
||||
officeOps +
|
||||
chatMessages;
|
||||
});
|
||||
|
||||
/// Per-category breakdown of pending-sync items (only non-zero entries).
|
||||
/// Keys are stable category identifiers used for icon/label lookup in the UI.
|
||||
final pendingSyncBreakdownProvider = Provider<Map<String, int>>((ref) {
|
||||
final result = <String, int>{};
|
||||
|
||||
final tasks = ref.watch(offlinePendingTasksProvider).length +
|
||||
ref.watch(offlinePendingTaskUpdatesProvider).length;
|
||||
if (tasks > 0) result['tasks'] = tasks;
|
||||
|
||||
final tickets = ref.watch(offlinePendingTicketsProvider).length +
|
||||
ref.watch(offlinePendingTicketUpdatesProvider).length;
|
||||
if (tickets > 0) result['tickets'] = tickets;
|
||||
|
||||
final messages = ref
|
||||
.watch(offlinePendingMessagesProvider)
|
||||
.values
|
||||
.fold<int>(0, (s, l) => s + l.length) +
|
||||
ref
|
||||
.watch(offlinePendingChatMessagesProvider)
|
||||
.values
|
||||
.fold<int>(0, (s, l) => s + l.length);
|
||||
if (messages > 0) result['messages'] = messages;
|
||||
|
||||
final assignments = ref.watch(offlinePendingAssignmentsProvider).length;
|
||||
if (assignments > 0) result['assignments'] = assignments;
|
||||
|
||||
final activityLogs =
|
||||
ref.watch(offlinePendingActivityLogItemsProvider).length;
|
||||
if (activityLogs > 0) result['activity_logs'] = activityLogs;
|
||||
|
||||
final checkIns = ref.watch(offlinePendingCheckInsProvider).length;
|
||||
if (checkIns > 0) result['check_ins'] = checkIns;
|
||||
|
||||
final attendanceUpdates =
|
||||
ref.watch(offlinePendingAttendanceUpdatesProvider).length;
|
||||
if (attendanceUpdates > 0) result['attendance'] = attendanceUpdates;
|
||||
|
||||
final leaves = ref.watch(offlinePendingLeavesProvider).length +
|
||||
ref.watch(offlinePendingLeaveUpdatesProvider).length;
|
||||
if (leaves > 0) result['leaves'] = leaves;
|
||||
|
||||
final passSlips = ref.watch(offlinePendingPassSlipsProvider).length +
|
||||
ref.watch(offlinePendingPassSlipUpdatesProvider).length;
|
||||
if (passSlips > 0) result['pass_slips'] = passSlips;
|
||||
|
||||
final announcements = ref.watch(offlinePendingAnnouncementsProvider).length +
|
||||
ref.watch(offlinePendingAnnouncementUpdatesProvider).length +
|
||||
ref.watch(offlinePendingAnnouncementCommentsProvider).length;
|
||||
if (announcements > 0) result['announcements'] = announcements;
|
||||
|
||||
final itRequests = ref.watch(offlinePendingItServiceRequestsProvider).length +
|
||||
ref.watch(offlinePendingItServiceRequestUpdatesProvider).length +
|
||||
ref.watch(offlinePendingIsrActionsProvider).length;
|
||||
if (itRequests > 0) result['it_requests'] = itRequests;
|
||||
|
||||
final notifReads = ref.watch(offlinePendingNotificationReadsProvider).length +
|
||||
ref.watch(offlinePendingBatchNotificationReadsProvider).length;
|
||||
if (notifReads > 0) result['notification_reads'] = notifReads;
|
||||
|
||||
final profileUpdates =
|
||||
ref.watch(offlinePendingProfileUpdatesProvider).length;
|
||||
if (profileUpdates > 0) result['profile_updates'] = profileUpdates;
|
||||
|
||||
final officeChanges = ref.watch(offlinePendingOfficesProvider).length +
|
||||
ref.watch(offlinePendingUserOfficeOpsProvider).length;
|
||||
if (officeChanges > 0) result['office_changes'] = officeChanges;
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'supabase_provider.dart';
|
||||
import 'stream_recovery.dart';
|
||||
import 'realtime_controller.dart';
|
||||
import '../brick/cache_helpers.dart';
|
||||
import '../brick/cache_warmer.dart';
|
||||
import '../models/team.dart';
|
||||
import '../models/team_member.dart';
|
||||
import 'realtime_controller.dart';
|
||||
import 'stream_recovery.dart';
|
||||
import 'supabase_provider.dart';
|
||||
|
||||
/// Real-time stream of teams with automatic recovery and graceful degradation.
|
||||
final teamsProvider = StreamProvider<List<Team>>((ref) {
|
||||
@@ -19,6 +21,7 @@ final teamsProvider = StreamProvider<List<Team>>((ref) {
|
||||
fromMap: Team.fromMap,
|
||||
channelName: 'teams',
|
||||
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
|
||||
onOfflineData: () async => cachedListFromBrick<Team>(),
|
||||
);
|
||||
|
||||
ref.onDispose(wrapper.dispose);
|
||||
@@ -40,6 +43,10 @@ final teamMembersProvider = StreamProvider<List<TeamMember>>((ref) {
|
||||
fromMap: TeamMember.fromMap,
|
||||
channelName: 'team_members',
|
||||
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
|
||||
onOfflineData: () async {
|
||||
final rows = await CacheWarmer.readCachedTeamMembers();
|
||||
return rows.map(TeamMember.fromMap).toList();
|
||||
},
|
||||
);
|
||||
|
||||
ref.onDispose(wrapper.dispose);
|
||||
|
||||
@@ -30,7 +30,6 @@ final officesProvider = StreamProvider<List<Office>>((ref) {
|
||||
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);
|
||||
|
||||
@@ -25,6 +25,7 @@ final livePositionsProvider = StreamProvider<List<LivePosition>>((ref) {
|
||||
fromMap: LivePosition.fromMap,
|
||||
channelName: 'live_positions',
|
||||
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
|
||||
onOfflineData: () async => const [],
|
||||
);
|
||||
|
||||
ref.onDispose(wrapper.dispose);
|
||||
|
||||
Reference in New Issue
Block a user