Files
tasq/lib/providers/attendance_provider.dart
T

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;
}
}