diff --git a/__tests__/AttendanceAction.break-rules.test.jsx b/__tests__/AttendanceAction.break-rules.test.jsx
index 87d25ba..f3f7471 100644
--- a/__tests__/AttendanceAction.break-rules.test.jsx
+++ b/__tests__/AttendanceAction.break-rules.test.jsx
@@ -59,13 +59,15 @@ jest.mock("@expo/vector-icons", () => {
};
});
+let focusListener = null;
+
const mockNavigation = {
setOptions: jest.fn(),
goBack: jest.fn(),
navigate: jest.fn(),
addListener: jest.fn((event, cb) => {
if (event === "focus" && typeof cb === "function") {
- // Keep tests deterministic: do not auto-trigger focus callback.
+ focusListener = cb;
}
return jest.fn();
@@ -109,17 +111,23 @@ const createStore = (attendanceOverrides = {}) =>
const renderScreen = (attendanceOverrides = {}) => {
const store = createStore(attendanceOverrides);
- return render(
+ const utils = render(
,
);
+
+ return {
+ ...utils,
+ store,
+ };
};
describe("AttendanceAction break rules", () => {
beforeEach(async () => {
jest.clearAllMocks();
jest.useRealTimers();
+ focusListener = null;
await AsyncStorage.clear();
await AsyncStorage.setItem("restrict_location", "0");
@@ -145,6 +153,113 @@ describe("AttendanceAction break rules", () => {
});
});
+ it("keeps checkin start time stable after focus refresh", async () => {
+ const stableCheckinTime = new Date("2026-04-25T08:30:00.000Z").getTime();
+
+ await AsyncStorage.setItem("checkinStartTime", String(stableCheckinTime));
+
+ attendanceService.getAttendanceStatus.mockResolvedValue({
+ custom_in: 1,
+ checkin_time: String(stableCheckinTime),
+ });
+
+ const screen = renderScreen({
+ checkin: true,
+ checkinTime: stableCheckinTime,
+ });
+
+ await waitFor(() => {
+ expect(focusListener).toEqual(expect.any(Function));
+ });
+
+ await act(async () => {
+ await focusListener();
+ });
+
+ await waitFor(() => {
+ expect(screen.store.getState().attendance.checkinTime).toBe(
+ stableCheckinTime,
+ );
+ });
+ });
+
+ it("keeps newer local checkin start when backend timestamp is stale", async () => {
+ const staleBackendCheckinTime = new Date(
+ "2026-04-25T08:30:00.000Z",
+ ).getTime();
+ const freshLocalCheckinTime = new Date(
+ "2026-04-25T10:00:00.000Z",
+ ).getTime();
+
+ await AsyncStorage.setItem(
+ "checkinStartTime",
+ String(freshLocalCheckinTime),
+ );
+
+ attendanceService.getAttendanceStatus.mockResolvedValue({
+ custom_in: 1,
+ checkin_time: String(staleBackendCheckinTime),
+ });
+
+ const screen = renderScreen({
+ checkin: true,
+ checkinTime: freshLocalCheckinTime,
+ });
+
+ await waitFor(() => {
+ expect(focusListener).toEqual(expect.any(Function));
+ });
+
+ await act(async () => {
+ await focusListener();
+ });
+
+ await waitFor(() => {
+ expect(screen.store.getState().attendance.checkinTime).toBe(
+ freshLocalCheckinTime,
+ );
+ });
+ });
+
+ it("ignores status checkin time older than last local checkout", async () => {
+ jest.useFakeTimers();
+
+ const now = new Date("2026-04-25T10:00:00.000Z");
+ jest.setSystemTime(now);
+
+ const staleBackendCheckinTime = new Date(
+ "2026-04-25T08:30:00.000Z",
+ ).getTime();
+ const lastCheckoutTime = new Date("2026-04-25T09:45:00.000Z").getTime();
+
+ await AsyncStorage.removeItem("checkinStartTime");
+ await AsyncStorage.setItem("lastCheckoutTime", String(lastCheckoutTime));
+
+ attendanceService.getAttendanceStatus.mockResolvedValue({
+ custom_in: 1,
+ checkin_time: String(staleBackendCheckinTime),
+ });
+
+ const screen = renderScreen({
+ checkin: false,
+ checkinTime: null,
+ });
+
+ await waitFor(() => {
+ expect(focusListener).toEqual(expect.any(Function));
+ });
+
+ await act(async () => {
+ await focusListener();
+ });
+
+ await waitFor(() => {
+ expect(screen.store.getState().attendance.checkinTime).toBe(
+ now.getTime(),
+ );
+ });
+ });
+
it("disables break after a completed break for the day", async () => {
attendanceService.getTodayBreaks.mockResolvedValue({
total_break_minutes: 25,
diff --git a/components/AttendanceAction/AttendanceActionForm.jsx b/components/AttendanceAction/AttendanceActionForm.jsx
new file mode 100644
index 0000000..6c6cf09
--- /dev/null
+++ b/components/AttendanceAction/AttendanceActionForm.jsx
@@ -0,0 +1,97 @@
+import React from "react";
+import { View, Text, TouchableOpacity } from "react-native";
+import { MaterialCommunityIcons } from "@expo/vector-icons";
+import { COLORS } from "../../constants";
+
+function AttendanceActionForm({
+ dateTime,
+ locationStatusText,
+ distanceInfo,
+ restrictLocation,
+ checkin,
+ actionLoading,
+ isLocationBlocked,
+ onCheckInOutPress,
+ onBreakPress,
+ breakDisabled,
+ breakButtonLabel,
+ breakButtonToneClass,
+ monthlyCapMessage,
+}) {
+ return (
+
+
+
+ DATE AND TIME *
+
+
+ {dateTime}
+
+
+
+
+ LOCATION *
+
+
+
+ {locationStatusText}
+
+
+
+
+ {restrictLocation === "1" && distanceInfo && (
+
+
+ Distance: {distanceInfo.distance} m | Allowed:{" "}
+ {distanceInfo.radius} m
+
+
+ )}
+
+
+
+ {checkin ? "CHECK-OUT" : "CHECK-IN"}
+
+
+
+ {checkin && (
+
+
+
+ {breakButtonLabel}
+
+
+
+ {!!monthlyCapMessage && (
+
+
+ {monthlyCapMessage}
+
+
+ )}
+
+ )}
+
+
+ );
+}
+
+export default React.memo(AttendanceActionForm);
diff --git a/components/AttendanceAction/BreakInProgressBanner.jsx b/components/AttendanceAction/BreakInProgressBanner.jsx
new file mode 100644
index 0000000..c57f39f
--- /dev/null
+++ b/components/AttendanceAction/BreakInProgressBanner.jsx
@@ -0,0 +1,23 @@
+import React from "react";
+import { View, Text } from "react-native";
+
+function BreakInProgressBanner({ liveBreakTime }) {
+ return (
+
+
+ BREAK IN PROGRESS
+
+
+ {liveBreakTime || "00:00:00"}
+
+
+ Auto-ends at 02:00:00
+
+
+ );
+}
+
+export default React.memo(BreakInProgressBanner);
diff --git a/components/AttendanceAction/DevBreakTools.jsx b/components/AttendanceAction/DevBreakTools.jsx
new file mode 100644
index 0000000..dcde699
--- /dev/null
+++ b/components/AttendanceAction/DevBreakTools.jsx
@@ -0,0 +1,62 @@
+import React from "react";
+import { View, Text, TouchableOpacity } from "react-native";
+
+const PRESETS = [
+ { key: "idle-0", label: "Idle 00:00", tone: "bg-slate-700" },
+ { key: "idle-45", label: "Idle 00:45", tone: "bg-slate-700" },
+ { key: "running-30", label: "Running +30m", tone: "bg-amber-700" },
+ { key: "cap-120", label: "Cap 02:00", tone: "bg-gray-700" },
+ { key: "completed", label: "Completed 1/day", tone: "bg-indigo-700" },
+ { key: "monthly-cap", label: "Monthly Cap 8h", tone: "bg-rose-700" },
+];
+
+function DevBreakTools({
+ devBreakMockMode,
+ onToggleDevBreakMockMode,
+ onInvalidateAccessToken,
+ onApplyPreset,
+}) {
+ return (
+
+
+
+ DEV: Invalidate access token
+
+
+
+
+ DEV: Break UI presets
+
+
+
+
+ DEV local break flow: {devBreakMockMode ? "ON" : "OFF"}
+
+
+
+
+ {PRESETS.map((preset) => (
+ onApplyPreset(preset.key)}
+ >
+
+ {preset.label}
+
+
+ ))}
+
+
+ );
+}
+
+export default React.memo(DevBreakTools);
diff --git a/hooks/attendanceAction/helpers.js b/hooks/attendanceAction/helpers.js
new file mode 100644
index 0000000..363d9f8
--- /dev/null
+++ b/hooks/attendanceAction/helpers.js
@@ -0,0 +1,30 @@
+export const BREAK_LIMIT_MS = 2 * 60 * 60 * 1000; // 2 hours
+
+export const getTodayString = () =>
+ new Date().toLocaleDateString("en-GB").replace(/\//g, "-");
+
+export const isBreakCompleted = (breakData) => {
+ if (!breakData?.breaks?.length) return false;
+
+ const hasIn = breakData.breaks.some((b) => b.start);
+ const hasOut = breakData.breaks.some((b) => b.end);
+
+ return hasIn && hasOut;
+};
+
+export const findOpenBreak = (breakData) => {
+ const breaks = breakData?.breaks ?? [];
+ for (let i = breaks.length - 1; i >= 0; i -= 1) {
+ const currentBreak = breaks[i];
+ if (!currentBreak?.end) return currentBreak;
+ }
+ return null;
+};
+
+export const formatSecondsToHms = (seconds) => {
+ const hrs = String(Math.floor(seconds / 3600)).padStart(2, "0");
+ const mins = String(Math.floor((seconds % 3600) / 60)).padStart(2, "0");
+ const secs = String(seconds % 60).padStart(2, "0");
+
+ return `${hrs}:${mins}:${secs}`;
+};
diff --git a/hooks/attendanceAction/useAttendanceBreakFlow.js b/hooks/attendanceAction/useAttendanceBreakFlow.js
new file mode 100644
index 0000000..4efaa75
--- /dev/null
+++ b/hooks/attendanceAction/useAttendanceBreakFlow.js
@@ -0,0 +1,402 @@
+import { useEffect, useState, useRef, useCallback } from "react";
+import { Alert } from "react-native";
+import { useDispatch } from "react-redux";
+import Toast from "react-native-toast-message";
+import AsyncStorage from "@react-native-async-storage/async-storage";
+import {
+ setCheckin,
+ setBreakMinutes,
+ setBreakStatus,
+} from "../../redux/Slices/AttendanceSlice";
+import {
+ employeeBreak,
+ getAttendanceStatus,
+ getTodayBreaks,
+} from "../../services/api/attendance.service";
+import {
+ BREAK_LIMIT_MS,
+ findOpenBreak,
+ formatSecondsToHms,
+ getTodayString,
+ isBreakCompleted,
+} from "./helpers";
+
+function useAttendanceBreakFlow({
+ navigation,
+ employeeCode,
+ checkin,
+ breakMinutes,
+ isLocationBlocked,
+ isMountedRef,
+ refreshAttendanceData,
+ syncCheckinFromStatus,
+ setActionLoading,
+}) {
+ const dispatch = useDispatch();
+
+ const [onBreak, setOnBreak] = useState(false);
+ const [liveBreakTime, setLiveBreakTime] = useState("00:00:00");
+ const [breakStartTime, setBreakStartTime] = useState(null);
+ const [breakCompleted, setBreakCompleted] = useState(false);
+ const [monthlyCapMessage, setMonthlyCapMessage] = useState("");
+ const [devBreakMockMode, setDevBreakMockMode] = useState(false);
+
+ const breakTriggeredRef = useRef(false);
+
+ const syncBreakState = useCallback(async (breakData) => {
+ const openBreak = findOpenBreak(breakData);
+
+ if (!openBreak) {
+ setOnBreak(false);
+ setBreakStartTime(null);
+ await AsyncStorage.removeItem("breakStartTime");
+ return;
+ }
+
+ const isOpen = !openBreak.end || openBreak.end === "";
+
+ setOnBreak(isOpen);
+
+ if (isOpen) breakTriggeredRef.current = false;
+
+ const savedTime = await AsyncStorage.getItem("breakStartTime");
+
+ if (savedTime) {
+ setBreakStartTime(parseInt(savedTime, 10));
+ } else {
+ const backendTime = new Date(openBreak.start).getTime();
+ setBreakStartTime(backendTime);
+ await AsyncStorage.setItem("breakStartTime", backendTime.toString());
+ }
+ }, []);
+
+ useEffect(() => {
+ const unsubscribe = navigation.addListener("focus", async () => {
+ try {
+ if (!employeeCode) return;
+
+ const status = await getAttendanceStatus();
+
+ if (!isMountedRef.current) return;
+ await syncCheckinFromStatus(status);
+
+ const breakData = await refreshAttendanceData();
+
+ if (!isMountedRef.current) return;
+ setBreakCompleted(isBreakCompleted(breakData));
+
+ await syncBreakState(breakData);
+ } catch (error) {
+ console.log("Focus sync error:", error);
+ }
+ });
+
+ return unsubscribe;
+ }, [
+ navigation,
+ employeeCode,
+ isMountedRef,
+ refreshAttendanceData,
+ syncBreakState,
+ syncCheckinFromStatus,
+ ]);
+
+ useEffect(() => {
+ const loadBreak = async () => {
+ if (!employeeCode) return;
+
+ const breakData = await getTodayBreaks(employeeCode, getTodayString());
+
+ if (!isMountedRef.current) return;
+ setBreakCompleted(isBreakCompleted(breakData));
+
+ await syncBreakState(breakData);
+ };
+
+ loadBreak();
+ }, [employeeCode, isMountedRef, syncBreakState]);
+
+ useEffect(() => {
+ if (!onBreak || !breakStartTime) {
+ setLiveBreakTime("00:00:00");
+ return;
+ }
+
+ const interval = setInterval(async () => {
+ const diff = Date.now() - breakStartTime;
+ const currentBreakSeconds = Math.floor(diff / 1000);
+
+ setLiveBreakTime(formatSecondsToHms(currentBreakSeconds));
+
+ if (diff >= BREAK_LIMIT_MS && !breakTriggeredRef.current) {
+ breakTriggeredRef.current = true;
+
+ try {
+ await employeeBreak({ employeeCode, type: "OUT" });
+ await AsyncStorage.removeItem("breakStartTime");
+
+ if (!isMountedRef.current) return;
+ setOnBreak(false);
+ setBreakStartTime(null);
+ setBreakCompleted(true);
+
+ const breakData = await getTodayBreaks(
+ employeeCode,
+ getTodayString(),
+ );
+
+ if (!isMountedRef.current) return;
+ dispatch(setBreakMinutes(breakData?.total_break_minutes ?? 0));
+
+ Alert.alert(
+ "Break Ended",
+ "2-hour break limit reached. Break automatically stopped.",
+ );
+ } catch {
+ Alert.alert("Error", "Auto break end failed");
+ }
+ }
+ }, 1000);
+
+ return () => clearInterval(interval);
+ }, [breakStartTime, dispatch, employeeCode, isMountedRef, onBreak]);
+
+ const applyDevBreakPreset = useCallback(
+ async (preset) => {
+ const now = Date.now();
+
+ const setIdleState = async (minutes, completed = false) => {
+ setOnBreak(false);
+ setBreakStartTime(null);
+ setBreakCompleted(completed);
+ setMonthlyCapMessage("");
+ breakTriggeredRef.current = false;
+ dispatch(setBreakMinutes(minutes));
+ dispatch(
+ setBreakStatus({
+ onBreak: false,
+ breakStartTime: null,
+ }),
+ );
+ await AsyncStorage.removeItem("breakStartTime");
+ };
+
+ dispatch(
+ setCheckin({
+ checkinTime: now,
+ location: null,
+ }),
+ );
+
+ if (preset === "idle-0") {
+ await setIdleState(0, false);
+ }
+
+ if (preset === "idle-45") {
+ await setIdleState(45, false);
+ }
+
+ if (preset === "running-30") {
+ const startTime = now - 30 * 60 * 1000;
+ setOnBreak(true);
+ setBreakStartTime(startTime);
+ setBreakCompleted(false);
+ breakTriggeredRef.current = false;
+ dispatch(setBreakMinutes(45));
+ dispatch(
+ setBreakStatus({
+ onBreak: true,
+ breakStartTime: startTime,
+ }),
+ );
+ await AsyncStorage.setItem("breakStartTime", String(startTime));
+ }
+
+ if (preset === "cap-120") {
+ await setIdleState(120, false);
+ }
+
+ if (preset === "completed") {
+ await setIdleState(60, true);
+ }
+
+ if (preset === "monthly-cap") {
+ await setIdleState(30, true);
+ setMonthlyCapMessage("Monthly break limit reached (8h)");
+ Toast.show({
+ type: "error",
+ text1: "Monthly break limit reached (8h)",
+ });
+ }
+
+ if (__DEV__) {
+ setDevBreakMockMode(true);
+ }
+
+ Toast.show({
+ type: "success",
+ text1: `DEV preset applied: ${preset}`,
+ });
+ },
+ [dispatch],
+ );
+
+ const toggleDevBreakMockMode = useCallback(() => {
+ setDevBreakMockMode((prev) => !prev);
+ }, []);
+
+ const handleBreak = useCallback(async () => {
+ if (!checkin) {
+ Toast.show({ type: "error", text1: "Please check-in first" });
+ return;
+ }
+
+ if (isLocationBlocked) {
+ Toast.show({
+ type: "error",
+ text1: "You are out of allowed location",
+ });
+ return;
+ }
+
+ if (__DEV__ && devBreakMockMode) {
+ try {
+ setActionLoading(true);
+
+ if (!onBreak) {
+ const startTime = Date.now();
+ setOnBreak(true);
+ setBreakStartTime(startTime);
+ setBreakCompleted(false);
+ setMonthlyCapMessage("");
+ breakTriggeredRef.current = false;
+
+ dispatch(
+ setBreakStatus({
+ onBreak: true,
+ breakStartTime: startTime,
+ }),
+ );
+ await AsyncStorage.setItem("breakStartTime", String(startTime));
+
+ Toast.show({ type: "success", text1: "DEV break started (local)" });
+ return;
+ }
+
+ const elapsedMinutes = Math.max(
+ 0,
+ Math.floor((Date.now() - breakStartTime) / 60000),
+ );
+ const nextTotal = Math.min(120, (breakMinutes ?? 0) + elapsedMinutes);
+
+ setOnBreak(false);
+ setBreakStartTime(null);
+ setBreakCompleted(true);
+ setMonthlyCapMessage("");
+ dispatch(setBreakMinutes(nextTotal));
+ dispatch(
+ setBreakStatus({
+ onBreak: false,
+ breakStartTime: null,
+ }),
+ );
+ await AsyncStorage.removeItem("breakStartTime");
+
+ Toast.show({
+ type: "success",
+ text1: `DEV break ended (total: ${nextTotal}m)`,
+ });
+ return;
+ } finally {
+ setActionLoading(false);
+ }
+ }
+
+ const breakDataCheck = await getTodayBreaks(employeeCode, getTodayString());
+
+ if (isBreakCompleted(breakDataCheck)) {
+ Toast.show({
+ type: "error",
+ text1: "Break already completed for today",
+ });
+ return;
+ }
+
+ const type = onBreak ? "OUT" : "IN";
+
+ try {
+ setActionLoading(true);
+ const response = await employeeBreak({ employeeCode, type });
+
+ if (!response.allowed) {
+ if (response.message?.includes("Monthly break limit")) {
+ setBreakCompleted(true);
+ setMonthlyCapMessage(response.message);
+ } else {
+ setMonthlyCapMessage("");
+ }
+
+ Toast.show({ type: "error", text1: response.message });
+ return;
+ }
+
+ if (type === "IN") {
+ const startTime = Date.now();
+
+ setOnBreak(true);
+ setBreakStartTime(startTime);
+ setMonthlyCapMessage("");
+
+ await AsyncStorage.setItem("breakStartTime", startTime.toString());
+ } else {
+ setOnBreak(false);
+ setBreakStartTime(null);
+ setMonthlyCapMessage("");
+
+ await AsyncStorage.removeItem("breakStartTime");
+ }
+
+ const breakData = await getTodayBreaks(employeeCode, getTodayString());
+
+ setBreakCompleted(isBreakCompleted(breakData));
+ dispatch(setBreakMinutes(breakData?.total_break_minutes ?? 0));
+
+ await syncBreakState(breakData);
+
+ Toast.show({ type: "success", text1: response.message });
+ } catch (error) {
+ Toast.show({
+ type: "error",
+ text1: "Break failed",
+ text2: error.message,
+ });
+ } finally {
+ setActionLoading(false);
+ }
+ }, [
+ breakMinutes,
+ breakStartTime,
+ checkin,
+ devBreakMockMode,
+ dispatch,
+ employeeCode,
+ isLocationBlocked,
+ onBreak,
+ setActionLoading,
+ syncBreakState,
+ ]);
+
+ return {
+ applyDevBreakPreset,
+ breakCompleted,
+ devBreakMockMode,
+ handleBreak,
+ liveBreakTime,
+ monthlyCapMessage,
+ onBreak,
+ syncBreakState,
+ toggleDevBreakMockMode,
+ };
+}
+
+export default useAttendanceBreakFlow;
diff --git a/hooks/attendanceAction/useAttendanceCheckInOut.js b/hooks/attendanceAction/useAttendanceCheckInOut.js
new file mode 100644
index 0000000..0e67a64
--- /dev/null
+++ b/hooks/attendanceAction/useAttendanceCheckInOut.js
@@ -0,0 +1,148 @@
+import { useCallback } from "react";
+import { useDispatch } from "react-redux";
+import Toast from "react-native-toast-message";
+import AsyncStorage from "@react-native-async-storage/async-storage";
+import { setCheckin, setCheckout } from "../../redux/Slices/AttendanceSlice";
+import {
+ userCheckIn,
+ employeeBreak,
+} from "../../services/api/attendance.service";
+
+const CHECKIN_START_STORAGE_KEY = "checkinStartTime";
+const CHECKOUT_TIME_STORAGE_KEY = "lastCheckoutTime";
+
+function useAttendanceCheckInOut({
+ navigation,
+ checkin,
+ employeeCode,
+ distanceInfo,
+ restrictLocation,
+ onBreak,
+ refreshAttendanceData,
+ syncBreakState,
+ setActionLoading,
+}) {
+ const dispatch = useDispatch();
+
+ const handleDirectCheckInOut = useCallback(
+ async (type) => {
+ try {
+ setActionLoading(true);
+ const response = await userCheckIn({
+ employeeCode,
+ type,
+ locationData: distanceInfo,
+ });
+
+ if (!response.allowed) {
+ Toast.show({
+ type: "error",
+ text1: ":warning: Action blocked",
+ text2: response.message,
+ });
+ return;
+ }
+
+ if (type === "IN") {
+ const checkinStartTime = Date.now();
+ await AsyncStorage.removeItem(CHECKOUT_TIME_STORAGE_KEY);
+ await AsyncStorage.setItem(
+ CHECKIN_START_STORAGE_KEY,
+ String(checkinStartTime),
+ );
+
+ dispatch({ type: "attendance/setSelectedLocation", payload: null });
+ dispatch(
+ setCheckin({
+ checkinTime: checkinStartTime,
+ location: restrictLocation === "1" ? response.location : null,
+ }),
+ );
+ } else {
+ const checkoutTime = Date.now();
+
+ if (onBreak) {
+ const breakRes = await employeeBreak({
+ employeeCode,
+ type: "OUT",
+ });
+ if (!breakRes?.allowed) {
+ console.log("Break already ended from backend");
+ }
+ }
+
+ await AsyncStorage.removeItem(CHECKIN_START_STORAGE_KEY);
+ await AsyncStorage.setItem(
+ CHECKOUT_TIME_STORAGE_KEY,
+ String(checkoutTime),
+ );
+ dispatch(setCheckout({ checkoutTime }));
+ dispatch({ type: "attendance/setSelectedLocation", payload: null });
+ }
+
+ const breakData = await refreshAttendanceData();
+ await syncBreakState(breakData);
+
+ Toast.show({
+ type: "success",
+ text1: type === "IN" ? "Checked in!" : "Checked out!",
+ });
+ } catch (error) {
+ console.log("AttendanceAction.handleDirectCheckInOut error:", {
+ errorMessage: error?.message,
+ status: error?.response?.status,
+ responseData: error?.response?.data,
+ });
+
+ Toast.show({
+ type: "error",
+ text1: ":warning: Failed",
+ text2:
+ error?.response?.data?.message ||
+ error?.response?.data ||
+ error.message ||
+ "Request failed",
+ });
+ } finally {
+ setActionLoading(false);
+ }
+ },
+ [
+ dispatch,
+ distanceInfo,
+ employeeCode,
+ onBreak,
+ refreshAttendanceData,
+ restrictLocation,
+ setActionLoading,
+ syncBreakState,
+ ],
+ );
+
+ const handleCheckInOutPress = useCallback(async () => {
+ try {
+ const photoValue = await AsyncStorage.getItem("photo");
+ const actionType = checkin ? "OUT" : "IN";
+
+ if (photoValue !== "1") {
+ await handleDirectCheckInOut(actionType);
+ } else {
+ navigation.navigate("Attendance camera", {
+ type: actionType,
+ });
+ }
+ } catch (error) {
+ Toast.show({
+ type: "error",
+ text1: ":warning: Action failed",
+ text2: error.message,
+ });
+ }
+ }, [checkin, handleDirectCheckInOut, navigation]);
+
+ return {
+ handleCheckInOutPress,
+ };
+}
+
+export default useAttendanceCheckInOut;
diff --git a/hooks/attendanceAction/useAttendanceDevActions.js b/hooks/attendanceAction/useAttendanceDevActions.js
new file mode 100644
index 0000000..bb26bf1
--- /dev/null
+++ b/hooks/attendanceAction/useAttendanceDevActions.js
@@ -0,0 +1,41 @@
+import { useCallback } from "react";
+import Toast from "react-native-toast-message";
+import AsyncStorage from "@react-native-async-storage/async-storage";
+import { saveTokens } from "../../services/api/apiClient";
+
+function useAttendanceDevActions() {
+ const handleInvalidateAccessToken = useCallback(async () => {
+ try {
+ const refreshToken = await AsyncStorage.getItem("refresh_token");
+
+ if (!refreshToken) {
+ Toast.show({
+ type: "error",
+ text1: "Refresh token missing",
+ text2: "Cannot invalidate access token without a refresh token.",
+ });
+ return;
+ }
+
+ await saveTokens("invalid-access-token-123", refreshToken);
+ const maskedRefresh = `${refreshToken.slice(0, 6)}...${refreshToken.slice(-4)}`;
+ Toast.show({
+ type: "success",
+ text1: "Dev token invalidated",
+ text2: `Refresh token preserved: ${maskedRefresh}`,
+ });
+ } catch (error) {
+ Toast.show({
+ type: "error",
+ text1: "Dev token reset failed",
+ text2: error.message || "Unable to invalidate access token.",
+ });
+ }
+ }, []);
+
+ return {
+ handleInvalidateAccessToken,
+ };
+}
+
+export default useAttendanceDevActions;
diff --git a/hooks/attendanceAction/useAttendanceMeta.js b/hooks/attendanceAction/useAttendanceMeta.js
new file mode 100644
index 0000000..c5aefa2
--- /dev/null
+++ b/hooks/attendanceAction/useAttendanceMeta.js
@@ -0,0 +1,288 @@
+import { useEffect, useState, useCallback, useMemo } from "react";
+import { useDispatch, useSelector } from "react-redux";
+import Toast from "react-native-toast-message";
+import AsyncStorage from "@react-native-async-storage/async-storage";
+import {
+ setCheckin,
+ resetCheckin,
+ setBreakMinutes,
+ setTodayHours,
+ setMonthlyHours,
+ selectCheckinTime,
+} from "../../redux/Slices/AttendanceSlice";
+import { updateDateTime } from "../../utils/TimeServices";
+import {
+ getOfficeLocation,
+ getAttendanceStatus,
+ getDailyWorkedHours,
+ getMonthlyWorkedHours,
+ getServerTime,
+ getTodayBreaks,
+} from "../../services/api/attendance.service";
+import { getTodayString } from "./helpers";
+
+const CHECKIN_START_STORAGE_KEY = "checkinStartTime";
+const CHECKOUT_TIME_STORAGE_KEY = "lastCheckoutTime";
+
+const parseTimestampToMs = (value) => {
+ if (value === null || value === undefined || value === "") return null;
+
+ const normalizeEpochMs = (epoch) =>
+ epoch < 1_000_000_000_000 ? epoch * 1000 : epoch;
+
+ if (typeof value === "number" && Number.isFinite(value)) {
+ return normalizeEpochMs(value);
+ }
+
+ if (typeof value === "string") {
+ const trimmed = value.trim();
+ if (/^\d+$/.test(trimmed)) {
+ const asNumber = Number(trimmed);
+ return Number.isFinite(asNumber) ? normalizeEpochMs(asNumber) : null;
+ }
+ }
+
+ const parsed = new Date(value).getTime();
+ return Number.isFinite(parsed) ? parsed : null;
+};
+
+const pickBestCheckinStartMs = (...candidates) => {
+ const now = Date.now();
+ const MAX_FUTURE_SKEW_MS = 5 * 60 * 1000;
+
+ const normalized = candidates.filter(
+ (value) => Number.isFinite(value) && value > 0,
+ );
+
+ if (normalized.length === 0) return now;
+
+ const plausible = normalized.filter(
+ (value) => value <= now + MAX_FUTURE_SKEW_MS,
+ );
+
+ const pool = plausible.length ? plausible : normalized;
+ return Math.max(...pool);
+};
+
+const dropIfNotAfterCheckout = (candidate, lastCheckoutMs) => {
+ if (!Number.isFinite(candidate)) return null;
+ if (!Number.isFinite(lastCheckoutMs)) return candidate;
+ return candidate > lastCheckoutMs ? candidate : null;
+};
+
+function useAttendanceMeta({ employeeCode, isMountedRef }) {
+ const dispatch = useDispatch();
+ const existingCheckinTime = useSelector(selectCheckinTime);
+
+ const [refresh, setRefresh] = useState(false);
+ const [dateTime, setDateTime] = useState(null);
+ const [inTarget, setInTarget] = useState(true);
+ const [ready, setReady] = useState(false);
+ const [distanceInfo, setDistanceInfo] = useState(null);
+ const [restrictLocation, setRestrictLocation] = useState("0");
+ const [restrictionLoaded, setRestrictionLoaded] = useState(false);
+
+ const isLocationBlocked = restrictLocation === "1" && !inTarget;
+
+ const locationStatusText = useMemo(() => {
+ if (restrictLocation === "0") return "Not Required";
+ if (!ready) return "Getting Location...";
+ return inTarget ? "In bound" : "Out of bound";
+ }, [restrictLocation, ready, inTarget]);
+
+ useEffect(() => {
+ const loadRestriction = async () => {
+ const stored = await AsyncStorage.getItem("restrict_location");
+ if (!isMountedRef.current) return;
+ setRestrictLocation(stored === "1" ? "1" : "0");
+ setRestrictionLoaded(true);
+ };
+
+ loadRestriction();
+ }, [isMountedRef]);
+
+ const syncCheckinFromStatus = useCallback(
+ async (status) => {
+ const isCheckedIn =
+ status?.custom_in === 1 ||
+ status?.custom_in === "1" ||
+ status?.custom_in === true ||
+ `${status?.log_type || ""}`.toUpperCase() === "IN";
+
+ if (isCheckedIn) {
+ const statusStartMs = parseTimestampToMs(
+ status?.checkin_time ??
+ status?.timestamp ??
+ status?.creation ??
+ status?.time ??
+ status?.checkinTime,
+ );
+ const storedStartRaw = await AsyncStorage.getItem(
+ CHECKIN_START_STORAGE_KEY,
+ );
+ const lastCheckoutRaw = await AsyncStorage.getItem(
+ CHECKOUT_TIME_STORAGE_KEY,
+ );
+
+ const storedStartMs = parseTimestampToMs(storedStartRaw);
+ const existingStartMs = parseTimestampToMs(existingCheckinTime);
+ const lastCheckoutMs = parseTimestampToMs(lastCheckoutRaw);
+
+ const sanitizedStatusStartMs = dropIfNotAfterCheckout(
+ statusStartMs,
+ lastCheckoutMs,
+ );
+ const sanitizedStoredStartMs = dropIfNotAfterCheckout(
+ storedStartMs,
+ lastCheckoutMs,
+ );
+ const sanitizedExistingStartMs = dropIfNotAfterCheckout(
+ existingStartMs,
+ lastCheckoutMs,
+ );
+
+ const checkinStartTime = pickBestCheckinStartMs(
+ sanitizedStatusStartMs,
+ sanitizedStoredStartMs,
+ sanitizedExistingStartMs,
+ );
+
+ await AsyncStorage.setItem(
+ CHECKIN_START_STORAGE_KEY,
+ String(checkinStartTime),
+ );
+
+ if (
+ Number.isFinite(lastCheckoutMs) &&
+ Number.isFinite(checkinStartTime) &&
+ checkinStartTime > lastCheckoutMs
+ ) {
+ await AsyncStorage.removeItem(CHECKOUT_TIME_STORAGE_KEY);
+ }
+
+ dispatch(
+ setCheckin({
+ checkinTime: checkinStartTime,
+ location: null,
+ }),
+ );
+ return;
+ }
+
+ await AsyncStorage.removeItem(CHECKIN_START_STORAGE_KEY);
+ dispatch(resetCheckin());
+ },
+ [dispatch, existingCheckinTime],
+ );
+
+ useEffect(() => {
+ const loadCheckinStatus = async () => {
+ try {
+ const status = await getAttendanceStatus();
+
+ if (!isMountedRef.current) return;
+ await syncCheckinFromStatus(status);
+ } catch (error) {
+ console.log("Status sync error:", error);
+ }
+ };
+
+ if (employeeCode) {
+ loadCheckinStatus();
+ }
+ }, [employeeCode, isMountedRef, syncCheckinFromStatus]);
+
+ const fetchStatusAndLocation = useCallback(async () => {
+ setReady(false);
+
+ try {
+ if (restrictLocation === "0") {
+ setInTarget(true);
+ setDistanceInfo(null);
+ return;
+ }
+
+ const nearest = await getOfficeLocation(employeeCode);
+ if (!isMountedRef.current) return;
+ setInTarget(nearest.withinRadius);
+ setDistanceInfo(nearest);
+ } catch (error) {
+ if (!isMountedRef.current) return;
+ Toast.show({
+ type: "error",
+ text1: ":warning: Location error",
+ text2: error.message,
+ });
+ setInTarget(false);
+ } finally {
+ if (!isMountedRef.current) return;
+ setReady(true);
+ }
+ }, [employeeCode, isMountedRef, restrictLocation]);
+
+ const onRefresh = useCallback(() => {
+ setRefresh(true);
+ fetchStatusAndLocation().finally(() => {
+ if (!isMountedRef.current) return;
+ setRefresh(false);
+ });
+ }, [fetchStatusAndLocation, isMountedRef]);
+
+ useEffect(() => {
+ if (restrictionLoaded && employeeCode) {
+ fetchStatusAndLocation();
+ }
+ }, [employeeCode, fetchStatusAndLocation, restrictionLoaded]);
+
+ useEffect(() => {
+ const loadServerTime = async () => {
+ const server = await getServerTime();
+ if (!isMountedRef.current) return;
+ if (server) setDateTime(updateDateTime(server));
+ };
+
+ loadServerTime();
+ const intervalId = setInterval(loadServerTime, 10000);
+
+ return () => clearInterval(intervalId);
+ }, [isMountedRef]);
+
+ const refreshAttendanceData = useCallback(async () => {
+ const todayStr = getTodayString();
+ const now = new Date();
+
+ const [todayWorked, monthlyWorked, breakData] = await Promise.all([
+ getDailyWorkedHours(employeeCode, todayStr),
+ getMonthlyWorkedHours(
+ employeeCode,
+ now.getMonth() + 1,
+ now.getFullYear(),
+ ),
+ getTodayBreaks(employeeCode, todayStr),
+ ]);
+
+ dispatch(setTodayHours(todayWorked ?? "00:00"));
+ dispatch(setMonthlyHours(monthlyWorked ?? "00:00"));
+ dispatch(setBreakMinutes(breakData?.total_break_minutes ?? 0));
+
+ return breakData;
+ }, [dispatch, employeeCode]);
+
+ return {
+ dateTime,
+ distanceInfo,
+ fetchStatusAndLocation,
+ inTarget,
+ isLocationBlocked,
+ locationStatusText,
+ onRefresh,
+ ready,
+ refresh,
+ refreshAttendanceData,
+ restrictLocation,
+ restrictionLoaded,
+ syncCheckinFromStatus,
+ };
+}
+
+export default useAttendanceMeta;
diff --git a/hooks/useAttendanceActionController.js b/hooks/useAttendanceActionController.js
new file mode 100644
index 0000000..84c2403
--- /dev/null
+++ b/hooks/useAttendanceActionController.js
@@ -0,0 +1,114 @@
+import { useEffect, useState, useRef } from "react";
+import { useSelector } from "react-redux";
+import useAttendanceMeta from "./attendanceAction/useAttendanceMeta";
+import useAttendanceBreakFlow from "./attendanceAction/useAttendanceBreakFlow";
+import useAttendanceCheckInOut from "./attendanceAction/useAttendanceCheckInOut";
+import useAttendanceDevActions from "./attendanceAction/useAttendanceDevActions";
+
+function useAttendanceActionController({ navigation }) {
+ const checkin = useSelector((state) => state.attendance.checkin);
+ const userDetails = useSelector((state) => state.user.userDetails);
+ const breakMinutes = useSelector((state) => state.attendance.breakMinutes);
+ const employeeCode = userDetails?.employeeCode;
+
+ const [actionLoading, setActionLoading] = useState(false);
+ const isMountedRef = useRef(true);
+
+ useEffect(() => {
+ isMountedRef.current = true;
+ return () => {
+ isMountedRef.current = false;
+ };
+ }, []);
+
+ const {
+ dateTime,
+ distanceInfo,
+ isLocationBlocked,
+ locationStatusText,
+ onRefresh,
+ refresh,
+ refreshAttendanceData,
+ restrictLocation,
+ restrictionLoaded,
+ syncCheckinFromStatus,
+ } = useAttendanceMeta({ employeeCode, isMountedRef });
+
+ const {
+ applyDevBreakPreset,
+ breakCompleted,
+ devBreakMockMode,
+ handleBreak,
+ liveBreakTime,
+ monthlyCapMessage,
+ onBreak,
+ syncBreakState,
+ toggleDevBreakMockMode,
+ } = useAttendanceBreakFlow({
+ navigation,
+ employeeCode,
+ checkin,
+ breakMinutes,
+ isLocationBlocked,
+ isMountedRef,
+ refreshAttendanceData,
+ syncCheckinFromStatus,
+ setActionLoading,
+ });
+
+ const { handleCheckInOutPress } = useAttendanceCheckInOut({
+ navigation,
+ checkin,
+ employeeCode,
+ distanceInfo,
+ restrictLocation,
+ onBreak,
+ refreshAttendanceData,
+ syncBreakState,
+ setActionLoading,
+ });
+
+ const { handleInvalidateAccessToken } = useAttendanceDevActions();
+
+ const breakDisabled =
+ actionLoading || isLocationBlocked || breakCompleted || breakMinutes >= 120;
+
+ const breakButtonLabel = breakDisabled
+ ? "BREAK NOT ALLOWED"
+ : onBreak
+ ? "END BREAK"
+ : "TAKE BREAK";
+
+ const breakButtonToneClass = breakDisabled
+ ? "bg-gray-400"
+ : onBreak
+ ? "bg-slate-500"
+ : "bg-blue-400";
+
+ return {
+ actionLoading,
+ applyDevBreakPreset,
+ breakButtonLabel,
+ breakButtonToneClass,
+ breakDisabled,
+ checkin,
+ dateTime,
+ devBreakMockMode,
+ distanceInfo,
+ handleBreak,
+ handleCheckInOutPress,
+ handleInvalidateAccessToken,
+ isLocationBlocked,
+ liveBreakTime,
+ locationStatusText,
+ monthlyCapMessage,
+ onBreak,
+ onRefresh,
+ refresh,
+ restrictLocation,
+ restrictionLoaded,
+ toggleDevBreakMockMode,
+ };
+}
+
+export default useAttendanceActionController;
diff --git a/redux/Slices/AttendanceSlice.js b/redux/Slices/AttendanceSlice.js
index 076c4e4..0b6fd6d 100644
--- a/redux/Slices/AttendanceSlice.js
+++ b/redux/Slices/AttendanceSlice.js
@@ -12,7 +12,6 @@ const initialState = {
breakTakenToday: false,
onBreak: false,
breakStartTime: null,
-
};
export const AttendanceSlice = createSlice({
@@ -27,6 +26,7 @@ export const AttendanceSlice = createSlice({
},
setCheckout: (state, action) => {
state.checkin = false;
+ state.checkinTime = null;
state.checkoutTime = action.payload.checkoutTime;
state.location = null; // clear location on checkout
},
diff --git a/screens/AttendanceAction.jsx b/screens/AttendanceAction.jsx
index cbac067..3e57edc 100644
--- a/screens/AttendanceAction.jsx
+++ b/screens/AttendanceAction.jsx
@@ -1,10 +1,4 @@
-import React, {
- useEffect,
- useLayoutEffect,
- useState,
- useRef,
- useCallback,
-} from "react";
+import React, { useLayoutEffect } from "react";
import {
View,
Text,
@@ -12,86 +6,48 @@ import {
ActivityIndicator,
ScrollView,
RefreshControl,
- Alert,
StyleSheet,
} from "react-native";
-import { Entypo, MaterialCommunityIcons } from "@expo/vector-icons";
-import { useDispatch, useSelector } from "react-redux";
-import Toast from "react-native-toast-message";
+import { Entypo } from "@expo/vector-icons";
import { useNavigation } from "@react-navigation/native";
-import AsyncStorage from "@react-native-async-storage/async-storage";
-import {
- setCheckin,
- setCheckout,
- resetCheckin,
- setBreakMinutes,
- setBreakStatus,
- setTodayHours,
- setMonthlyHours,
-} from "../redux/Slices/AttendanceSlice";
import { COLORS, SIZES } from "../constants";
import WelcomeCard from "../components/AttendanceAction/WelcomeCard";
-import { updateDateTime } from "../utils/TimeServices";
-import { saveTokens } from "../services/api/apiClient";
-import {
- getOfficeLocation,
- userCheckIn,
- getAttendanceStatus,
- getDailyWorkedHours,
- getMonthlyWorkedHours,
- getServerTime,
- employeeBreak,
- getTodayBreaks,
-} from "../services/api/attendance.service";
+import BreakInProgressBanner from "../components/AttendanceAction/BreakInProgressBanner";
+import DevBreakTools from "../components/AttendanceAction/DevBreakTools";
+import AttendanceActionForm from "../components/AttendanceAction/AttendanceActionForm";
+import useAttendanceActionController from "../hooks/useAttendanceActionController";
import {
SafeAreaView,
useSafeAreaInsets,
} from "react-native-safe-area-context";
-const BREAK_LIMIT_MS = 2 * 60 * 60 * 1000; // 2 hours
-
-/** Returns today's date formatted as DD-MM-YYYY */
-const getTodayString = () =>
- new Date().toLocaleDateString("en-GB").replace(/\//g, "-");
-
function AttendanceAction() {
const insets = useSafeAreaInsets();
const navigation = useNavigation();
- const dispatch = useDispatch();
- const checkin = useSelector((state) => state.attendance.checkin);
- const userDetails = useSelector((state) => state.user.userDetails);
- const breakMinutes = useSelector((state) => state.attendance.breakMinutes);
- const employeeCode = userDetails?.employeeCode;
- const [refresh, setRefresh] = useState(false);
- const [dateTime, setDateTime] = useState(null);
- const [inTarget, setInTarget] = useState(true);
- const [ready, setReady] = useState(false);
- const [distanceInfo, setDistanceInfo] = useState(null);
- const [actionLoading, setActionLoading] = useState(false);
- const [restrictLocation, setRestrictLocation] = useState("0");
- const [restrictionLoaded, setRestrictionLoaded] = useState(false);
- const [onBreak, setOnBreak] = useState(false);
- const [liveBreakTime, setLiveBreakTime] = useState("00:00:00");
- const [breakStartTime, setBreakStartTime] = useState(null);
- const breakTriggeredRef = useRef(false);
- const isMountedRef = useRef(true);
- const [breakCompleted, setBreakCompleted] = useState(false);
- const [monthlyCapMessage, setMonthlyCapMessage] = useState("");
- const [devBreakMockMode, setDevBreakMockMode] = useState(false);
- const isBreakCompleted = (breakData) => {
- if (!breakData?.breaks?.length) return false;
-
- const hasIn = breakData.breaks.some((b) => b.start);
- const hasOut = breakData.breaks.some((b) => b.end);
-
- return hasIn && hasOut;
- };
- useEffect(() => {
- isMountedRef.current = true;
- return () => {
- isMountedRef.current = false;
- };
- }, []);
+ const {
+ actionLoading,
+ applyDevBreakPreset,
+ breakButtonLabel,
+ breakButtonToneClass,
+ breakDisabled,
+ checkin,
+ dateTime,
+ devBreakMockMode,
+ distanceInfo,
+ handleBreak,
+ handleCheckInOutPress,
+ handleInvalidateAccessToken,
+ isLocationBlocked,
+ liveBreakTime,
+ locationStatusText,
+ monthlyCapMessage,
+ onBreak,
+ onRefresh,
+ refresh,
+ restrictLocation,
+ restrictionLoaded,
+ toggleDevBreakMockMode,
+ } = useAttendanceActionController({ navigation });
useLayoutEffect(() => {
navigation.setOptions({
@@ -112,593 +68,6 @@ function AttendanceAction() {
});
}, [navigation]);
- // Load location restriction
- useEffect(() => {
- const loadRestriction = async () => {
- const r = await AsyncStorage.getItem("restrict_location");
- if (!isMountedRef.current) return;
- setRestrictLocation(r === "1" ? "1" : "0");
- setRestrictionLoaded(true);
- };
- loadRestriction();
- }, []);
- useEffect(() => {
- const loadCheckinStatus = async () => {
- try {
- const res = await getAttendanceStatus();
-
- if (!isMountedRef.current) return;
-
- if (res.custom_in === 1) {
- dispatch(
- setCheckin({
- checkinTime: Date.now(),
- location: null,
- }),
- );
- } else {
- // dispatch(setCheckout({ checkoutTime: null }));
- dispatch(resetCheckin());
- }
- } catch (e) {
- console.log("Status sync error:", e);
- }
- };
-
- if (employeeCode) {
- loadCheckinStatus();
- }
- }, [employeeCode]);
-
- const fetchStatusAndLocation = useCallback(async () => {
- try {
- setReady(false);
-
- if (restrictLocation === "0") {
- setInTarget(true);
- setDistanceInfo(null);
- setReady(true);
- return;
- }
-
- const nearest = await getOfficeLocation(employeeCode);
- if (!isMountedRef.current) return;
- setInTarget(nearest.withinRadius);
- setDistanceInfo(nearest);
- setReady(true);
- } catch (error) {
- if (!isMountedRef.current) return;
- Toast.show({
- type: "error",
- text1: ":warning: Location error",
- text2: error.message,
- });
- setInTarget(false);
- setReady(true);
- }
- }, [restrictLocation, employeeCode]);
-
- // Fetch GPS on mount
- useEffect(() => {
- if (restrictionLoaded && employeeCode) fetchStatusAndLocation();
- }, [restrictionLoaded, employeeCode, fetchStatusAndLocation]);
-
- // Update date & time every 10 seconds
- useEffect(() => {
- const loadServerTime = async () => {
- const server = await getServerTime();
- if (!isMountedRef.current) return;
- if (server) setDateTime(updateDateTime(server));
- };
-
- loadServerTime();
- const intervalId = setInterval(loadServerTime, 10000);
- return () => clearInterval(intervalId);
- }, []);
-
- /**
- * Dispatches today + monthly worked hours and break minutes to Redux.
- * Avoids duplicating three identical Promise.all blocks across handlers.
- */
- const refreshAttendanceData = useCallback(async () => {
- const todayStr = getTodayString();
- const now = new Date();
- const [todayWorked, monthlyWorked, breakData] = await Promise.all([
- getDailyWorkedHours(employeeCode, todayStr),
- getMonthlyWorkedHours(
- employeeCode,
- now.getMonth() + 1,
- now.getFullYear(),
- ),
- getTodayBreaks(employeeCode, todayStr),
- ]);
-
- dispatch(setTodayHours(todayWorked ?? "00:00"));
- dispatch(setMonthlyHours(monthlyWorked ?? "00:00"));
- dispatch(setBreakMinutes(breakData?.total_break_minutes ?? 0));
-
- return breakData;
- }, [dispatch, employeeCode]);
-
- /**
- * Syncs onBreak / breakStartTime state from a breakData response object.
- */
-
- const syncBreakState = useCallback(async (breakData) => {
- const lastBreak = breakData?.breaks?.find((b) => !b.end || b.end === null);
-
- if (!lastBreak) {
- setOnBreak(false);
- setBreakStartTime(null);
- await AsyncStorage.removeItem("breakStartTime"); // ✅ keep storage clean
- return;
- }
-
- const isOpen =
- !lastBreak.end || lastBreak.end === "" || lastBreak.end === null;
-
- setOnBreak(isOpen);
-
- if (isOpen) breakTriggeredRef.current = false;
-
- const savedTime = await AsyncStorage.getItem("breakStartTime");
-
- if (savedTime) {
- setBreakStartTime(parseInt(savedTime));
- } else {
- const backendTime = new Date(lastBreak.start).getTime();
- setBreakStartTime(backendTime);
-
- await AsyncStorage.setItem("breakStartTime", backendTime.toString());
- }
- }, []);
-
- useEffect(() => {
- const unsubscribe = navigation.addListener("focus", async () => {
- try {
- if (!employeeCode) return;
-
- // ✅ 1. FIRST: sync attendance status
- const res = await getAttendanceStatus();
-
- console.log("FOCUS STATUS:", res.custom_in);
-
- if (!isMountedRef.current) return;
-
- if (res.custom_in === 1) {
- dispatch(
- setCheckin({
- checkinTime: Date.now(),
- location: null,
- }),
- );
- } else {
- dispatch(resetCheckin());
- }
-
- // ✅ 2. THEN: fetch totals
- const breakData = await refreshAttendanceData();
-
- if (!isMountedRef.current) return;
- setBreakCompleted(isBreakCompleted(breakData));
-
- syncBreakState(breakData);
- } catch (e) {
- console.log("Focus sync error:", e);
- }
- });
-
- return unsubscribe;
- }, [navigation, employeeCode]);
-
- useEffect(() => {
- const loadBreak = async () => {
- const saved = await AsyncStorage.getItem("breakStartTime");
- const breakData = await getTodayBreaks(employeeCode, getTodayString());
- setBreakCompleted(isBreakCompleted(breakData));
-
- if (saved) {
- const parsedTime = parseInt(saved);
-
- setBreakStartTime(parsedTime);
- setOnBreak(true);
- }
- };
-
- loadBreak();
- }, []);
-
- useEffect(() => {
- if (!onBreak || !breakStartTime) {
- setLiveBreakTime("00:00:00");
- return;
- }
-
- const interval = setInterval(async () => {
- const diff = Date.now() - breakStartTime;
- const currentBreakSeconds = Math.floor(diff / 1000);
-
- const hrs = String(Math.floor(currentBreakSeconds / 3600)).padStart(
- 2,
- "0",
- );
- const mins = String(
- Math.floor((currentBreakSeconds % 3600) / 60),
- ).padStart(2, "0");
- const secs = String(currentBreakSeconds % 60).padStart(2, "0");
- setLiveBreakTime(`${hrs}:${mins}:${secs}`);
-
- if (diff >= BREAK_LIMIT_MS && !breakTriggeredRef.current) {
- breakTriggeredRef.current = true;
-
- try {
- await employeeBreak({ employeeCode, type: "OUT" });
- await AsyncStorage.removeItem("breakStartTime");
-
- if (!isMountedRef.current) return;
- setOnBreak(false);
- setBreakStartTime(null);
- setBreakCompleted(true);
-
- const breakData = await getTodayBreaks(
- employeeCode,
- getTodayString(),
- );
- if (!isMountedRef.current) return;
- dispatch(setBreakMinutes(breakData?.total_break_minutes ?? 0));
-
- Alert.alert(
- "Break Ended",
- "2-hour break limit reached. Break automatically stopped.",
- );
- } catch {
- Alert.alert("Error", "Auto break end failed");
- }
- }
- }, 1000);
- console.log("BREAK START TIME:", breakStartTime);
- console.log("NOW:", Date.now());
- console.log("DIFF MIN:", (Date.now() - breakStartTime) / 60000);
-
- return () => clearInterval(interval);
- }, [onBreak, breakStartTime, dispatch, employeeCode]);
-
- const handleDirectCheckInOut = useCallback(
- async (type) => {
- try {
- setActionLoading(true);
- const response = await userCheckIn({
- employeeCode,
- type,
- locationData: distanceInfo,
- });
-
- if (!response.allowed) {
- Toast.show({
- type: "error",
- text1: ":warning: Action blocked",
- text2: response.message,
- });
- return;
- }
-
- if (type === "IN") {
- dispatch({ type: "attendance/setSelectedLocation", payload: null });
- dispatch(
- setCheckin({
- checkinTime: Date.now(),
- location: restrictLocation === "1" ? response.location : null,
- }),
- );
- } else {
- if (onBreak) {
- const breakRes = await employeeBreak({
- employeeCode,
- type: "OUT",
- });
- if (!breakRes?.allowed) {
- console.log("Break already ended from backend");
- }
- }
- dispatch(setCheckout({ checkoutTime: Date.now() }));
- dispatch({ type: "attendance/setSelectedLocation", payload: null });
- }
-
- const breakData = await refreshAttendanceData();
- syncBreakState(breakData);
-
- Toast.show({
- type: "success",
- text1: type === "IN" ? "Checked in!" : "Checked out!",
- });
- } catch (error) {
- console.log("AttendanceAction.handleDirectCheckInOut error:", {
- errorMessage: error?.message,
- status: error?.response?.status,
- responseData: error?.response?.data,
- });
-
- Toast.show({
- type: "error",
- text1: ":warning: Failed",
- text2:
- error?.response?.data?.message ||
- error?.response?.data ||
- error.message ||
- "Request failed",
- });
- } finally {
- setActionLoading(false);
- }
- },
- [
- employeeCode,
- distanceInfo,
- restrictLocation,
- onBreak,
- dispatch,
- refreshAttendanceData,
- syncBreakState,
- ],
- );
-
- const handleInvalidateAccessToken = useCallback(async () => {
- try {
- const refreshToken = await AsyncStorage.getItem("refresh_token");
-
- if (!refreshToken) {
- Toast.show({
- type: "error",
- text1: "Refresh token missing",
- text2: "Cannot invalidate access token without a refresh token.",
- });
- return;
- }
-
- await saveTokens("invalid-access-token-123", refreshToken);
- const maskedRefresh = `${refreshToken.slice(0, 6)}...${refreshToken.slice(-4)}`;
- Toast.show({
- type: "success",
- text1: "Dev token invalidated",
- text2: `Refresh token preserved: ${maskedRefresh}`,
- });
- } catch (error) {
- Toast.show({
- type: "error",
- text1: "Dev token reset failed",
- text2: error.message || "Unable to invalidate access token.",
- });
- }
- }, []);
-
- const applyDevBreakPreset = useCallback(
- async (preset) => {
- const now = Date.now();
- const setIdleState = async (minutes, completed = false) => {
- setOnBreak(false);
- setBreakStartTime(null);
- setBreakCompleted(completed);
- setMonthlyCapMessage("");
- breakTriggeredRef.current = false;
- dispatch(setBreakMinutes(minutes));
- dispatch(
- setBreakStatus({
- onBreak: false,
- breakStartTime: null,
- }),
- );
- await AsyncStorage.removeItem("breakStartTime");
- };
-
- dispatch(
- setCheckin({
- checkinTime: now,
- location: null,
- }),
- );
-
- if (preset === "idle-0") {
- await setIdleState(0, false);
- }
-
- if (preset === "idle-45") {
- await setIdleState(45, false);
- }
-
- if (preset === "running-30") {
- const startTime = now - 30 * 60 * 1000;
- setOnBreak(true);
- setBreakStartTime(startTime);
- setBreakCompleted(false);
- breakTriggeredRef.current = false;
- dispatch(setBreakMinutes(45));
- dispatch(
- setBreakStatus({
- onBreak: true,
- breakStartTime: startTime,
- }),
- );
- await AsyncStorage.setItem("breakStartTime", String(startTime));
- }
-
- if (preset === "cap-120") {
- await setIdleState(120, false);
- }
-
- if (preset === "completed") {
- await setIdleState(60, true);
- }
-
- if (preset === "monthly-cap") {
- await setIdleState(30, true);
- setMonthlyCapMessage("Monthly break limit reached (8h)");
- Toast.show({
- type: "error",
- text1: "Monthly break limit reached (8h)",
- });
- }
-
- if (__DEV__) {
- setDevBreakMockMode(true);
- }
-
- Toast.show({
- type: "success",
- text1: `DEV preset applied: ${preset}`,
- });
- },
- [dispatch],
- );
-
- const handleBreak = useCallback(async () => {
- if (!checkin) {
- Toast.show({ type: "error", text1: "Please check-in first" });
- return;
- }
-
- if (restrictLocation === "1" && !inTarget) {
- Toast.show({
- type: "error",
- text1: "You are out of allowed location",
- });
- return;
- }
-
- if (__DEV__ && devBreakMockMode) {
- try {
- setActionLoading(true);
-
- if (!onBreak) {
- const startTime = Date.now();
- setOnBreak(true);
- setBreakStartTime(startTime);
- setBreakCompleted(false);
- setMonthlyCapMessage("");
- breakTriggeredRef.current = false;
-
- dispatch(
- setBreakStatus({
- onBreak: true,
- breakStartTime: startTime,
- }),
- );
- await AsyncStorage.setItem("breakStartTime", String(startTime));
-
- Toast.show({ type: "success", text1: "DEV break started (local)" });
- return;
- }
-
- const elapsedMinutes = Math.max(
- 0,
- Math.floor((Date.now() - breakStartTime) / 60000),
- );
- const nextTotal = Math.min(120, (breakMinutes ?? 0) + elapsedMinutes);
-
- setOnBreak(false);
- setBreakStartTime(null);
- setBreakCompleted(true);
- setMonthlyCapMessage("");
- dispatch(setBreakMinutes(nextTotal));
- dispatch(
- setBreakStatus({
- onBreak: false,
- breakStartTime: null,
- }),
- );
- await AsyncStorage.removeItem("breakStartTime");
-
- Toast.show({
- type: "success",
- text1: `DEV break ended (total: ${nextTotal}m)`,
- });
- return;
- } finally {
- setActionLoading(false);
- }
- }
-
- // ✅ FIRST check from backend (important)
- const breakDataCheck = await getTodayBreaks(employeeCode, getTodayString());
-
- if (isBreakCompleted(breakDataCheck)) {
- Toast.show({
- type: "error",
- text1: "Break already completed for today",
- });
- return;
- }
-
- const type = onBreak ? "OUT" : "IN";
-
- try {
- setActionLoading(true);
-
- // const response = await employeeBreak({ employeeCode, type });
-
- // if (!response.allowed) {
- // Toast.show({ type: "error", text1: response.message });
- // return;
- // }
- const response = await employeeBreak({ employeeCode, type });
-
- if (!response.allowed) {
- // ✅ Handle monthly limit from backend
- if (response.message?.includes("Monthly break limit")) {
- setBreakCompleted(true); // disable button
- setMonthlyCapMessage(response.message);
- } else {
- setMonthlyCapMessage("");
- }
-
- Toast.show({ type: "error", text1: response.message });
- return;
- }
-
- if (type === "IN") {
- const startTime = Date.now();
-
- setOnBreak(true);
- setBreakStartTime(startTime);
- setMonthlyCapMessage("");
-
- await AsyncStorage.setItem("breakStartTime", startTime.toString());
- } else {
- setOnBreak(false);
- setBreakStartTime(null);
- setMonthlyCapMessage("");
-
- await AsyncStorage.removeItem("breakStartTime");
- }
-
- // ✅ Fetch latest break data
- const breakData = await getTodayBreaks(employeeCode, getTodayString());
-
- setBreakCompleted(isBreakCompleted(breakData));
- dispatch(setBreakMinutes(breakData?.total_break_minutes ?? 0));
-
- syncBreakState(breakData);
-
- Toast.show({ type: "success", text1: response.message });
- } catch (error) {
- Toast.show({
- type: "error",
- text1: "Break failed",
- text2: error.message,
- });
- } finally {
- setActionLoading(false);
- }
- }, [
- checkin,
- restrictLocation,
- inTarget,
- onBreak,
- devBreakMockMode,
- breakMinutes,
- breakStartTime,
- dispatch,
- ]);
-
// Temporary loading screen
if (!restrictionLoaded) {
return (
@@ -747,242 +116,38 @@ function AttendanceAction() {
paddingBottom: Math.max(insets.bottom, 16),
}}
refreshControl={
- {
- setRefresh(true);
- fetchStatusAndLocation().finally(() => setRefresh(false));
- }}
- />
+
}
>
- {onBreak && (
-
-
- BREAK IN PROGRESS
-
-
- {liveBreakTime || "00:00:00"}
-
-
- Auto-ends at 02:00:00
-
-
- )}
-
- {__DEV__ && (
-
-
-
- DEV: Invalidate access token
-
-
-
-
- DEV: Break UI presets
-
-
- setDevBreakMockMode((prev) => !prev)}
- >
-
- DEV local break flow: {devBreakMockMode ? "ON" : "OFF"}
-
-
-
-
- applyDevBreakPreset("idle-0")}
- >
-
- Idle 00:00
-
-
-
- applyDevBreakPreset("idle-45")}
- >
-
- Idle 00:45
-
-
+ {onBreak && }
- applyDevBreakPreset("running-30")}
- >
-
- Running +30m
-
-
-
- applyDevBreakPreset("cap-120")}
- >
-
- Cap 02:00
-
-
-
- applyDevBreakPreset("completed")}
- >
-
- Completed 1/day
-
-
+
- applyDevBreakPreset("monthly-cap")}
- >
-
- Monthly Cap 8h
-
-
-
-
+ {__DEV__ && (
+
)}
-
-
- {/* DATE & TIME */}
-
- DATE AND TIME *
-
-
-
- {dateTime}
-
-
-
- {/* LOCATION */}
-
- LOCATION *
-
-
-
- {restrictLocation === "0"
- ? "Not Required"
- : !ready
- ? "Getting Location..."
- : // : distanceInfo?.locationName
- // ? distanceInfo.locationName
- inTarget
- ? "In bound"
- : "Out of bound"}
-
-
-
- {restrictLocation === "1" && distanceInfo && (
-
-
- Distance: {distanceInfo.distance} m | Allowed:{" "}
- {distanceInfo.radius} m
-
-
- )}
-
- {/* CHECK-IN / CHECK-OUT BUTTON */}
- {
- try {
- const photoValue = await AsyncStorage.getItem("photo");
- const actionType = checkin ? "OUT" : "IN";
-
- if (photoValue !== "1") {
- await handleDirectCheckInOut(actionType);
- } else {
- navigation.navigate("Attendance camera", {
- type: actionType,
- });
- }
- } catch (error) {
- Toast.show({
- type: "error",
- text1: ":warning: Action failed",
- text2: error.message,
- });
- }
- }}
- >
-
- {checkin ? "CHECK-OUT" : "CHECK-IN"}
-
-
- {/* BREAK BUTTON */}
- {checkin && (
-
- = 120
- ? "bg-gray-400" // ✅ disabled color
- : onBreak
- ? "bg-slate-500" // break running
- : "bg-blue-400" // normal
- }`}
- disabled={
- actionLoading ||
- (restrictLocation === "1" && !inTarget) ||
- breakCompleted ||
- breakMinutes >= 120
- }
- onPress={handleBreak}
- >
-
- {actionLoading ||
- (restrictLocation === "1" && !inTarget) ||
- breakCompleted ||
- breakMinutes >= 120
- ? "BREAK NOT ALLOWED"
- : onBreak
- ? "END BREAK"
- : "TAKE BREAK"}
-
-
-
- {!!monthlyCapMessage && (
-
-
- {monthlyCapMessage}
-
-
- )}
-
- )}
-
-
+
diff --git a/screens/AttendanceCamera.jsx b/screens/AttendanceCamera.jsx
index 07e7571..20792e1 100644
--- a/screens/AttendanceCamera.jsx
+++ b/screens/AttendanceCamera.jsx
@@ -32,6 +32,9 @@ import {
getOfficeLocation,
} from "../services/api";
+const CHECKIN_START_STORAGE_KEY = "checkinStartTime";
+const CHECKOUT_TIME_STORAGE_KEY = "lastCheckoutTime";
+
function AttendanceCamera() {
const navigation = useNavigation();
const dispatch = useDispatch();
@@ -137,18 +140,31 @@ function AttendanceCamera() {
await uploadPicture(docname);
// Redux update
if (custom_in === 1) {
+ const checkinStartTime = Date.now();
+ await AsyncStorage.removeItem(CHECKOUT_TIME_STORAGE_KEY);
+ await AsyncStorage.setItem(
+ CHECKIN_START_STORAGE_KEY,
+ String(checkinStartTime),
+ );
+
dispatch(
setCheckin({
- checkinTime: new Date().toISOString(),
+ checkinTime: checkinStartTime,
location: {
locationName: locationData?.locationName || "Office",
latitude: locationData?.latitude,
longitude: locationData?.longitude,
radius: locationData?.radius,
},
- })
+ }),
);
} else {
+ const checkoutTime = Date.now();
+ await AsyncStorage.removeItem(CHECKIN_START_STORAGE_KEY);
+ await AsyncStorage.setItem(
+ CHECKOUT_TIME_STORAGE_KEY,
+ String(checkoutTime),
+ );
dispatch(setCheckout({ checkoutTime: new Date().toISOString() }));
}
diff --git a/screens/Profile.jsx b/screens/Profile.jsx
index 95b8c9c..a0c18a4 100644
--- a/screens/Profile.jsx
+++ b/screens/Profile.jsx
@@ -296,7 +296,7 @@ function Profile() {
Employee account
- Version {appVersion}-APR-28
+ Version {appVersion}-APR-29-exp
{deviceName}
diff --git a/services/api/attendance.service.js b/services/api/attendance.service.js
index 4192a58..6b88157 100644
--- a/services/api/attendance.service.js
+++ b/services/api/attendance.service.js
@@ -295,8 +295,22 @@ export const getAttendanceStatus = async () => {
console.log("LATEST RECORD:", latest);
+ const isCustomIn =
+ latest?.custom_in === 1 ||
+ latest?.custom_in === "1" ||
+ latest?.custom_in === true;
+ const isLogIn = `${latest?.log_type || ""}`.toUpperCase() === "IN";
+ const isCheckedIn = isCustomIn || isLogIn;
+
return {
- custom_in: latest?.custom_in === 1 || latest?.log_type === "IN" ? 1 : 0,
+ custom_in: isCheckedIn ? 1 : 0,
+ checkin_time: isCheckedIn
+ ? (latest?.checkin_time ??
+ latest?.timestamp ??
+ latest?.time ??
+ latest?.creation ??
+ null)
+ : null,
};
} catch (e) {
console.log("STATUS ERROR:", e);