Offline rediness

This commit is contained in:
2026-05-03 14:50:14 +08:00
parent 783c5eb0be
commit 858520bd8d
13 changed files with 893 additions and 205 deletions
+38 -2
View File
@@ -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,
);
});
+70
View File
@@ -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;
});
+10 -3
View File
@@ -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);
-1
View File
@@ -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);
+1
View File
@@ -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);