Worklog, anti geo spoofing and UI Polish

This commit is contained in:
2026-05-05 05:51:19 +08:00
parent d068887354
commit ccedf6e5f0
15 changed files with 2589 additions and 45 deletions
+36 -3
View File
@@ -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?;
}
+19 -4
View File
@@ -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');
+1 -1
View File
@@ -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 (_) {
+64 -4
View File
@@ -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 [];
}
}
},
);