Worklog, anti geo spoofing and UI Polish
This commit is contained in:
@@ -181,6 +181,8 @@ final attendanceLogsProvider = StreamProvider<List<AttendanceLog>>((ref) {
|
||||
'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);
|
||||
@@ -273,6 +275,8 @@ final attendanceLogsProvider = StreamProvider<List<AttendanceLog>>((ref) {
|
||||
'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);
|
||||
@@ -390,12 +394,20 @@ class AttendanceController {
|
||||
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},
|
||||
params: {
|
||||
'p_duty_id': dutyScheduleId,
|
||||
'p_lat': lat,
|
||||
'p_lng': lng,
|
||||
'p_accuracy': accuracy,
|
||||
'p_is_mocked': isMocked,
|
||||
},
|
||||
);
|
||||
return data as String?;
|
||||
} catch (e) {
|
||||
@@ -418,7 +430,14 @@ class AttendanceController {
|
||||
|
||||
_queuePendingCheckIn(
|
||||
log,
|
||||
{'p_duty_id': dutyScheduleId, 'p_lat': lat, 'p_lng': lng, 'localId': id},
|
||||
{
|
||||
'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;
|
||||
@@ -431,6 +450,8 @@ class AttendanceController {
|
||||
required double lat,
|
||||
required double lng,
|
||||
String? justification,
|
||||
double accuracy = 0,
|
||||
bool isMocked = false,
|
||||
}) async {
|
||||
try {
|
||||
await _client.rpc(
|
||||
@@ -440,6 +461,8 @@ class AttendanceController {
|
||||
'p_lat': lat,
|
||||
'p_lng': lng,
|
||||
'p_justification': justification,
|
||||
'p_accuracy': accuracy,
|
||||
'p_is_mocked': isMocked,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
@@ -451,6 +474,8 @@ class AttendanceController {
|
||||
'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',
|
||||
@@ -463,10 +488,18 @@ class AttendanceController {
|
||||
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},
|
||||
params: {
|
||||
'p_lat': lat,
|
||||
'p_lng': lng,
|
||||
'p_justification': justification,
|
||||
'p_accuracy': accuracy,
|
||||
'p_is_mocked': isMocked,
|
||||
},
|
||||
);
|
||||
return data as String?;
|
||||
}
|
||||
|
||||
@@ -163,6 +163,21 @@ class ChatController {
|
||||
} catch (e) {
|
||||
if (!isOfflineSaveError(e)) rethrow;
|
||||
|
||||
final ref = _ref;
|
||||
if (ref != null) {
|
||||
final existing =
|
||||
ref.read(offlinePendingChatMessagesProvider)[threadId] ?? [];
|
||||
// Idempotency guard: suppress if the same body was queued within the
|
||||
// last 10 s — prevents duplicate messages from rapid taps while offline.
|
||||
final cutoff = AppTime.now().subtract(const Duration(seconds: 10));
|
||||
if (existing.any((m) => m.body == body && m.createdAt.isAfter(cutoff))) {
|
||||
debugPrint(
|
||||
'[ChatController] duplicate offline message suppressed for thread=$threadId',
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
final message = ChatMessage(
|
||||
id: id,
|
||||
threadId: threadId,
|
||||
@@ -171,13 +186,13 @@ class ChatController {
|
||||
createdAt: AppTime.now(),
|
||||
);
|
||||
|
||||
final ref = _ref;
|
||||
if (ref != null) {
|
||||
final ref2 = _ref;
|
||||
if (ref2 != null) {
|
||||
final current = Map<String, List<ChatMessage>>.from(
|
||||
ref.read(offlinePendingChatMessagesProvider),
|
||||
ref2.read(offlinePendingChatMessagesProvider),
|
||||
);
|
||||
current[threadId] = [...(current[threadId] ?? []), message];
|
||||
ref.read(offlinePendingChatMessagesProvider.notifier).state =
|
||||
ref2.read(offlinePendingChatMessagesProvider.notifier).state =
|
||||
Map.unmodifiable(current);
|
||||
}
|
||||
mirrorBatchToBrick<ChatMessage>([message], tag: 'offline_chat');
|
||||
|
||||
@@ -64,7 +64,7 @@ class ConnectivityMonitor {
|
||||
}
|
||||
try {
|
||||
final res = await http
|
||||
.head(Uri.parse('https://tasq.crmc.ph'))
|
||||
.head(Uri.parse('https://google.com'))
|
||||
.timeout(const Duration(seconds: 5));
|
||||
return res.statusCode < 500;
|
||||
} catch (_) {
|
||||
|
||||
@@ -328,9 +328,10 @@ final tasksProvider = StreamProvider<List<Task>>((ref) {
|
||||
onOfflineData: () async {
|
||||
if (!AppRepository.isConfigured) return [];
|
||||
try {
|
||||
return await AppRepository.instance.get<Task>(
|
||||
final all = await AppRepository.instance.get<Task>(
|
||||
policy: OfflineFirstGetPolicy.localOnly,
|
||||
);
|
||||
return {for (final t in all) t.id: t}.values.toList();
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
@@ -363,6 +364,7 @@ final tasksProvider = StreamProvider<List<Task>>((ref) {
|
||||
'[tasksProvider] reconnected — syncing ${pending.length} pending task(s)',
|
||||
);
|
||||
}
|
||||
final failedTaskIds = <String>{};
|
||||
for (final task in pending) {
|
||||
try {
|
||||
final syncPayload = <String, dynamic>{
|
||||
@@ -393,18 +395,26 @@ final tasksProvider = StreamProvider<List<Task>>((ref) {
|
||||
} catch (e) {
|
||||
final msg = e.toString();
|
||||
if (msg.contains('23505') || msg.contains('duplicate key')) {
|
||||
// Task already in DB (Brick may have synced it first) — safe to
|
||||
// leave cleanup to the realtime stream listener in tasks_list_screen.
|
||||
// Task already in DB — treat as success so it's cleared from pending.
|
||||
debugPrint(
|
||||
'[tasksProvider] pending task already in DB id=${task.id}',
|
||||
);
|
||||
} else {
|
||||
failedTaskIds.add(task.id);
|
||||
debugPrint(
|
||||
'[tasksProvider] failed to sync pending task id=${task.id}: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Clear successfully synced pending tasks. Don't rely on the
|
||||
// tasks_list_screen listener alone — it only fires when that screen is
|
||||
// active, leaving the banner stuck on other screens indefinitely.
|
||||
if (pending.isNotEmpty) {
|
||||
final remaining =
|
||||
pending.where((t) => failedTaskIds.contains(t.id)).toList();
|
||||
ref.read(offlinePendingTasksProvider.notifier).state = remaining;
|
||||
}
|
||||
|
||||
// ── 2. Sync offline field edits for EXISTING tasks (PATCH) ──────────
|
||||
// pendingUpdates already had pending-task entries removed in step 1.
|
||||
@@ -573,9 +583,10 @@ final tasksProvider = StreamProvider<List<Task>>((ref) {
|
||||
// When offline, seed from Brick local SQLite cache instead of network.
|
||||
if (!ref.read(isOnlineProvider) && AppRepository.isConfigured) {
|
||||
try {
|
||||
final cached = await AppRepository.instance.get<Task>(
|
||||
final all = await AppRepository.instance.get<Task>(
|
||||
policy: OfflineFirstGetPolicy.localOnly,
|
||||
);
|
||||
final cached = {for (final t in all) t.id: t}.values.toList();
|
||||
debugPrint('[tasksProvider] offline seed: ${cached.length} tasks from local cache');
|
||||
final payload = _buildTaskPayload(
|
||||
tasks: cached,
|
||||
@@ -2788,3 +2799,52 @@ class TaskAssignmentsController {
|
||||
ref.read(offlinePendingAssignmentsProvider.notifier).state = current;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Work Log helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/// All task_assignments rows where user_id = [userId].
|
||||
/// Used by the Work Log tab to identify which tasks the user is the assignee of.
|
||||
final taskAssignmentsForUserProvider =
|
||||
FutureProvider.autoDispose.family<List<TaskAssignment>, String>(
|
||||
(ref, userId) async {
|
||||
try {
|
||||
final data = await Supabase.instance.client
|
||||
.from('task_assignments')
|
||||
.select()
|
||||
.eq('user_id', userId);
|
||||
return data.map(TaskAssignment.fromMap).toList();
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/// All task_activity_logs rows for the given task IDs — single batch query.
|
||||
/// Key is a sorted, comma-joined string of task IDs so Riverpod caches the
|
||||
/// provider correctly across rebuilds (List equality is reference-based in Dart).
|
||||
/// Returns logs in DESCENDING order (newest first).
|
||||
final taskActivityLogsForTasksProvider =
|
||||
FutureProvider.autoDispose.family<List<TaskActivityLog>, String>(
|
||||
(ref, taskIdsKey) async {
|
||||
if (taskIdsKey.isEmpty) return [];
|
||||
final taskIds = taskIdsKey.split(',');
|
||||
try {
|
||||
final data = await Supabase.instance.client
|
||||
.from('task_activity_logs')
|
||||
.select()
|
||||
.inFilter('task_id', taskIds)
|
||||
.order('created_at', ascending: false);
|
||||
return data.map(TaskActivityLog.fromMap).toList();
|
||||
} catch (_) {
|
||||
try {
|
||||
final all = await cachedListFromBrick<TaskActivityLog>();
|
||||
final idSet = taskIds.toSet();
|
||||
final filtered = all.where((l) => idSet.contains(l.taskId)).toList();
|
||||
filtered.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
return filtered;
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user