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((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>((ref) => []); // --------------------------------------------------------------------------- // Offline-pending state // --------------------------------------------------------------------------- /// Check-ins created offline (typed model — merged into live stream). final offlinePendingCheckInsProvider = StateProvider>((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>>((ref) => const []); /// Attendance field updates made offline, keyed by attendance log id. /// Covers: checkouts, verification status updates, skipVerification. final offlinePendingAttendanceUpdatesProvider = StateProvider>>((ref) => const {}); // --------------------------------------------------------------------------- // Read provider // --------------------------------------------------------------------------- /// All visible attendance logs (own for standard, all for admin/dispatcher/it_staff). final attendanceLogsProvider = StreamProvider>((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 []); final hasFullAccess = profileRole == 'admin' || profileRole == 'programmer' || profileRole == 'dispatcher' || profileRole == 'it_staff'; final pendingCheckIns = ref.watch(offlinePendingCheckInsProvider); final pendingUpdates = ref.watch(offlinePendingAttendanceUpdatesProvider); List applyPending(List 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( 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(); 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(rows, tag: 'attendance_logs'), ); ref.onDispose(wrapper.dispose); // Reconnect-replay: POST/PATCH queued items directly, bypassing Brick's queue. ref.listen(isOnlineProvider, (wasOnline, isNowOnline) async { if (wasOnline == true || !isNowOnline) return; // 1. Replay offline check-ins final pendingRaw = List>.from( ref.read(offlinePendingCheckInRawProvider), ); if (pendingRaw.isNotEmpty) { debugPrint( '[attendanceLogsProvider] reconnected — syncing ${pendingRaw.length} check-in(s)', ); final syncedLocalIds = []; 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'], }, ) 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>.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>.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>.from( ref.read(offlinePendingAttendanceUpdatesProvider), ); if (pendingUpdatesMap.isNotEmpty) { debugPrint( '[attendanceLogsProvider] reconnected — syncing ${pendingUpdatesMap.length} attendance update(s)', ); final syncedIds = []; final filesToDelete = []; for (final entry in pendingUpdatesMap.entries) { final attendanceId = entry.key; final fields = Map.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 { // 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>.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((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 checkIn({ required String dutyScheduleId, required double lat, required double lng, String shiftType = 'normal', }) async { 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. Offline: queues the checkout params. Future checkOut({ required String attendanceId, required double lat, required double lng, String? justification, }) async { 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 (online-only — requires server-side schedule creation). Future overtimeCheckIn({ required double lat, required double lng, String? justification, }) async { final data = await _client.rpc( 'overtime_check_in', params: {'p_lat': lat, 'p_lng': lng, 'p_justification': justification}, ); 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 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 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 rpcParams, ) { final ref = _ref; if (ref == null) return; final currentModels = List.from( ref.read(offlinePendingCheckInsProvider), ); currentModels.add(log); ref.read(offlinePendingCheckInsProvider.notifier).state = List.unmodifiable(currentModels); final currentRaw = List>.from( ref.read(offlinePendingCheckInRawProvider), ); currentRaw.add(rpcParams); ref.read(offlinePendingCheckInRawProvider.notifier).state = List.unmodifiable(currentRaw); mirrorBatchToBrick([log], tag: 'offline_checkin'); } void _queueAttendanceUpdate( String attendanceId, Map fields, ) { final ref = _ref; if (ref == null) return; final current = Map>.from( ref.read(offlinePendingAttendanceUpdatesProvider), ); current[attendanceId] = {...?current[attendanceId], ...fields}; ref.read(offlinePendingAttendanceUpdatesProvider.notifier).state = current; } }