Files
tasq/lib/providers/attendance_provider.dart
T
2026-04-27 06:20:39 +08:00

474 lines
16 KiB
Dart

import 'dart:typed_data';
import 'package:flutter/foundation.dart' show debugPrint;
import 'package:flutter_riverpod/flutter_riverpod.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 {
await client.rpc(
'attendance_check_in',
params: {
'p_duty_id': params['p_duty_id'],
'p_lat': params['p_lat'],
'p_lng': params['p_lng'],
},
);
syncedLocalIds.add(localId);
} catch (e) {
final msg = e.toString();
if (msg.contains('23505') || msg.contains('duplicate key')) {
syncedLocalIds.add(localId);
} 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>[];
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'],
},
);
syncedIds.add(attendanceId);
} catch (e) {
debugPrint(
'[attendanceLogsProvider] failed to sync checkout id=$attendanceId: $e',
);
}
} else {
try {
await client
.from('attendance_logs')
.update(fields)
.eq('id', attendanceId);
syncedIds.add(attendanceId);
} catch (e) {
debugPrint(
'[attendanceLogsProvider] failed to sync update id=$attendanceId: $e',
);
}
}
}
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,
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<void> 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<String?> 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<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;
// Queue status update without photo URL — photo bytes are not persisted
// across sessions; user will need to re-upload photo when online.
_queueAttendanceUpdate(attendanceId, {'verification_status': status});
debugPrint(
'[AttendanceController] uploadVerification status queued offline (photo not persisted): $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;
}
}