631 lines
22 KiB
Dart
631 lines
22 KiB
Dart
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:flutter/foundation.dart' show debugPrint, kIsWeb;
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:path_provider/path_provider.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 "Today".
|
|
final attendanceDateRangeProvider = StateProvider<ReportDateRange>((ref) {
|
|
final now = AppTime.now();
|
|
final today = DateTime(now.year, now.month, now.day);
|
|
return ReportDateRange(
|
|
start: today,
|
|
end: today.add(const Duration(days: 1)),
|
|
label: 'Today',
|
|
);
|
|
});
|
|
|
|
/// 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);
|
|
|
|
final profileId = ref.watch(
|
|
currentProfileProvider.select((p) => p.valueOrNull?.id),
|
|
);
|
|
final profileRole = ref.watch(
|
|
currentProfileProvider.select((p) => p.valueOrNull?.role),
|
|
);
|
|
if (profileId == null) return Stream.value(const <AttendanceLog>[]);
|
|
|
|
final hasFullAccess =
|
|
profileRole == 'admin' ||
|
|
profileRole == 'programmer' ||
|
|
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
|
|
.from('attendance_logs')
|
|
.stream(primaryKey: ['id'])
|
|
.order('check_in_at', ascending: false)
|
|
: client
|
|
.from('attendance_logs')
|
|
.stream(primaryKey: ['id'])
|
|
.eq('user_id', profileId)
|
|
.order('check_in_at', ascending: false),
|
|
onPollData: () async {
|
|
final query = client.from('attendance_logs').select();
|
|
final data = hasFullAccess
|
|
? await query.order('check_in_at', ascending: false)
|
|
: await query
|
|
.eq('user_id', profileId)
|
|
.order('check_in_at', ascending: false);
|
|
return data.map(AttendanceLog.fromMap).toList();
|
|
},
|
|
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);
|
|
|
|
// 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 {
|
|
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'],
|
|
'p_accuracy': params['p_accuracy'],
|
|
'p_is_mocked': params['p_is_mocked'],
|
|
},
|
|
) 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',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
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>[];
|
|
final filesToDelete = <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'],
|
|
'p_accuracy': fields['check_out_accuracy'],
|
|
'p_is_mocked': fields['check_out_is_mocked'],
|
|
},
|
|
);
|
|
syncedIds.add(attendanceId);
|
|
} catch (e) {
|
|
debugPrint(
|
|
'[attendanceLogsProvider] failed to sync checkout id=$attendanceId: $e',
|
|
);
|
|
}
|
|
} else {
|
|
// If a verification photo was queued to disk while offline, upload
|
|
// the bytes first and append the resulting public URL to `fields`.
|
|
final localPath = fields.remove('_local_photo_path') as String?;
|
|
final isCheckOut = fields.remove('_is_checkout') as bool? ?? false;
|
|
final fileName = fields.remove('_file_name') as String?;
|
|
String? pendingFileToDelete;
|
|
if (localPath != null && !kIsWeb) {
|
|
try {
|
|
final file = File(localPath);
|
|
if (await file.exists()) {
|
|
final bytes = await file.readAsBytes();
|
|
final userId = client.auth.currentUser?.id;
|
|
if (userId != null) {
|
|
final ext = (fileName ?? 'photo.jpg')
|
|
.split('.')
|
|
.last
|
|
.toLowerCase();
|
|
final prefix = isCheckOut ? 'checkout' : 'checkin';
|
|
final storagePath =
|
|
'$userId/${prefix}_$attendanceId.$ext';
|
|
await client.storage
|
|
.from('attendance-verification')
|
|
.uploadBinary(
|
|
storagePath,
|
|
bytes,
|
|
fileOptions: const FileOptions(upsert: true),
|
|
);
|
|
final url = client.storage
|
|
.from('attendance-verification')
|
|
.getPublicUrl(storagePath);
|
|
final column = isCheckOut
|
|
? 'check_out_verification_photo_url'
|
|
: 'check_in_verification_photo_url';
|
|
fields[column] = url;
|
|
pendingFileToDelete = localPath;
|
|
}
|
|
} else {
|
|
debugPrint(
|
|
'[attendanceLogsProvider] local photo missing for id=$attendanceId — replaying status only',
|
|
);
|
|
}
|
|
} catch (e) {
|
|
// Photo upload failed — leave entry in queue for next reconnect.
|
|
debugPrint(
|
|
'[attendanceLogsProvider] failed to upload local photo id=$attendanceId: $e',
|
|
);
|
|
continue;
|
|
}
|
|
}
|
|
try {
|
|
await client
|
|
.from('attendance_logs')
|
|
.update(fields)
|
|
.eq('id', attendanceId);
|
|
syncedIds.add(attendanceId);
|
|
if (pendingFileToDelete != null) {
|
|
filesToDelete.add(pendingFileToDelete);
|
|
}
|
|
} catch (e) {
|
|
debugPrint(
|
|
'[attendanceLogsProvider] failed to sync update id=$attendanceId: $e',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
// Best-effort cleanup of uploaded local photo files.
|
|
for (final path in filesToDelete) {
|
|
try {
|
|
await File(path).delete();
|
|
} catch (_) {}
|
|
}
|
|
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, ref);
|
|
});
|
|
|
|
class AttendanceController {
|
|
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,
|
|
double accuracy = 0,
|
|
bool isMocked = false,
|
|
String shiftType = 'normal',
|
|
}) async {
|
|
try {
|
|
final data = await _client.rpc(
|
|
'attendance_check_in',
|
|
params: {
|
|
'p_duty_id': dutyScheduleId,
|
|
'p_lat': lat,
|
|
'p_lng': lng,
|
|
'p_accuracy': accuracy,
|
|
'p_is_mocked': isMocked,
|
|
},
|
|
);
|
|
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,
|
|
'p_accuracy': accuracy,
|
|
'p_is_mocked': isMocked,
|
|
'localId': id,
|
|
},
|
|
);
|
|
debugPrint('[AttendanceController] checkIn queued offline: $id');
|
|
return id;
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
double accuracy = 0,
|
|
bool isMocked = false,
|
|
}) async {
|
|
try {
|
|
await _client.rpc(
|
|
'attendance_check_out',
|
|
params: {
|
|
'p_attendance_id': attendanceId,
|
|
'p_lat': lat,
|
|
'p_lng': lng,
|
|
'p_justification': justification,
|
|
'p_accuracy': accuracy,
|
|
'p_is_mocked': isMocked,
|
|
},
|
|
);
|
|
} 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,
|
|
'check_out_accuracy': accuracy,
|
|
'check_out_is_mocked': isMocked,
|
|
});
|
|
debugPrint(
|
|
'[AttendanceController] checkOut queued offline: $attendanceId',
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Overtime check-in (online-only — requires server-side schedule creation).
|
|
Future<String?> overtimeCheckIn({
|
|
required double lat,
|
|
required double lng,
|
|
String? justification,
|
|
double accuracy = 0,
|
|
bool isMocked = false,
|
|
}) async {
|
|
final data = await _client.rpc(
|
|
'overtime_check_in',
|
|
params: {
|
|
'p_lat': lat,
|
|
'p_lng': lng,
|
|
'p_justification': justification,
|
|
'p_accuracy': accuracy,
|
|
'p_is_mocked': isMocked,
|
|
},
|
|
);
|
|
return data as String?;
|
|
}
|
|
|
|
/// 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,
|
|
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';
|
|
|
|
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;
|
|
// Persist photo bytes to disk so the reconnect-replay path can upload
|
|
// them later. Disk write only on mobile/desktop — `path_provider` is
|
|
// unavailable on web, where we fall back to status-only queueing.
|
|
if (!kIsWeb) {
|
|
try {
|
|
final dir = await getApplicationDocumentsDirectory();
|
|
final pendingDir = Directory('${dir.path}/verification_pending');
|
|
if (!await pendingDir.exists()) {
|
|
await pendingDir.create(recursive: true);
|
|
}
|
|
final localPath = '${pendingDir.path}/${attendanceId}_$prefix.$ext';
|
|
await File(localPath).writeAsBytes(bytes);
|
|
_queueAttendanceUpdate(attendanceId, {
|
|
'verification_status': status,
|
|
'_local_photo_path': localPath,
|
|
'_is_checkout': isCheckOut,
|
|
'_file_name': fileName,
|
|
});
|
|
debugPrint(
|
|
'[AttendanceController] uploadVerification queued offline with photo: $attendanceId',
|
|
);
|
|
return;
|
|
} catch (writeErr) {
|
|
debugPrint(
|
|
'[AttendanceController] failed to persist offline photo for $attendanceId: $writeErr — falling back to status-only queue',
|
|
);
|
|
}
|
|
}
|
|
// Web or disk-write failure: queue status only.
|
|
_queueAttendanceUpdate(attendanceId, {'verification_status': status});
|
|
debugPrint(
|
|
'[AttendanceController] uploadVerification status queued offline (no photo): $attendanceId',
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Mark an attendance log as skipped verification. Offline: queues the patch.
|
|
Future<void> skipVerification(String attendanceId) async {
|
|
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;
|
|
}
|
|
}
|