geofence refinement
and manual failsafe
This commit is contained in:
parent
71ccea85d2
commit
c90f8cb1fa
Binary file not shown.
@ -38,6 +38,8 @@ jest.mock("@/lib/geofence", () => ({
|
||||
accuracy: 10,
|
||||
})),
|
||||
validateGeofence: jest.fn(() => ({ ok: true })),
|
||||
validateGeofenceWithFallback: jest.fn(() => ({ ok: true })),
|
||||
validateCheckInGeofence: jest.fn(() => ({ ok: true })),
|
||||
}));
|
||||
|
||||
const mockDb = {
|
||||
|
||||
@ -5,7 +5,7 @@ import { ensureUserSynced } from "@/lib/sync-user";
|
||||
import {
|
||||
getUserGymGeofence,
|
||||
parseUserLocation,
|
||||
validateGeofence,
|
||||
validateCheckInGeofence,
|
||||
} from "@/lib/geofence";
|
||||
import log from "@/lib/logger";
|
||||
|
||||
@ -27,6 +27,7 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const { type = "gym", notes } = body;
|
||||
const fallbackRequested = Boolean(body.fallbackRequested);
|
||||
|
||||
const gym = await getUserGymGeofence(userId);
|
||||
if (!gym) {
|
||||
@ -37,7 +38,7 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
const location = parseUserLocation(body.location);
|
||||
const geofence = validateGeofence(gym, location);
|
||||
const geofence = validateCheckInGeofence(gym, location, fallbackRequested);
|
||||
if (!geofence.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: geofence.error },
|
||||
|
||||
@ -4,7 +4,7 @@ import { getDatabase } from "@/lib/database";
|
||||
import {
|
||||
getUserGymGeofence,
|
||||
parseUserLocation,
|
||||
validateGeofence,
|
||||
validateGeofenceWithFallback,
|
||||
} from "@/lib/geofence";
|
||||
import log from "@/lib/logger";
|
||||
|
||||
@ -21,6 +21,7 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const fallbackRequested = Boolean(body.fallbackRequested);
|
||||
|
||||
const gym = await getUserGymGeofence(userId);
|
||||
if (!gym) {
|
||||
@ -31,7 +32,11 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
const location = parseUserLocation(body.location);
|
||||
const geofence = validateGeofence(gym, location);
|
||||
const geofence = validateGeofenceWithFallback(
|
||||
gym,
|
||||
location,
|
||||
fallbackRequested,
|
||||
);
|
||||
if (!geofence.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: geofence.error },
|
||||
|
||||
@ -1,8 +1,78 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { db, users as usersTable, eq, sql } from "@fitai/database";
|
||||
import { ensureGymsGeofenceColumns } from "@/lib/geofence";
|
||||
import log from "@/lib/logger";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) return new NextResponse("Unauthorized", { status: 401 });
|
||||
|
||||
const user = await db
|
||||
.select()
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.id, userId))
|
||||
.get();
|
||||
|
||||
if (!user) {
|
||||
return new NextResponse("User not found", { status: 404 });
|
||||
}
|
||||
|
||||
if (!user.gymId) {
|
||||
return NextResponse.json({ gymId: null, gym: null });
|
||||
}
|
||||
|
||||
await ensureGymsGeofenceColumns();
|
||||
|
||||
const rows = await db.all(sql`
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
location,
|
||||
latitude,
|
||||
longitude,
|
||||
geofence_radius_meters as geofenceRadiusMeters,
|
||||
geofence_enabled as geofenceEnabled,
|
||||
status
|
||||
FROM gyms
|
||||
WHERE id = ${user.gymId}
|
||||
LIMIT 1
|
||||
`);
|
||||
|
||||
const gym = rows?.[0] as
|
||||
| {
|
||||
id: string;
|
||||
name: string;
|
||||
location: string | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
geofenceRadiusMeters: number | null;
|
||||
geofenceEnabled: number | boolean | null;
|
||||
status: "active" | "inactive";
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (!gym || gym.status !== "active") {
|
||||
return NextResponse.json({ gymId: user.gymId, gym: null });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
gymId: user.gymId,
|
||||
gym: {
|
||||
...gym,
|
||||
geofenceEnabled:
|
||||
typeof gym.geofenceEnabled === "boolean"
|
||||
? gym.geofenceEnabled
|
||||
: Boolean(gym.geofenceEnabled),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
log.error("Failed to fetch current user gym", error);
|
||||
return new NextResponse("Internal Server Error", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/users/gym
|
||||
* Body: { gymId: string | null }
|
||||
|
||||
@ -2,6 +2,7 @@ import { db, eq, sql, users } from "@fitai/database";
|
||||
|
||||
export const DEFAULT_GEOFENCE_RADIUS_METERS = 30;
|
||||
export const MAX_LOCATION_ACCURACY_METERS = 50;
|
||||
export const MAX_FALLBACK_ACCURACY_MARGIN_METERS = 120;
|
||||
|
||||
export interface UserLocation {
|
||||
latitude: number;
|
||||
@ -169,6 +170,85 @@ export function validateGeofence(
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function validateGeofenceWithFallback(
|
||||
gym: GymGeofenceConfig,
|
||||
location: UserLocation | null,
|
||||
fallbackRequested: boolean,
|
||||
): { ok: true } | { ok: false; status: number; error: string } {
|
||||
const geofenceEnabled = gym.geofenceEnabled ?? true;
|
||||
if (!geofenceEnabled) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
if (!location) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
error: "Location is required for gym check-in/check-out",
|
||||
};
|
||||
}
|
||||
|
||||
if (gym.latitude === null || gym.longitude === null) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
error: "Gym geofence is enabled but gym coordinates are not configured",
|
||||
};
|
||||
}
|
||||
|
||||
const radius = gym.geofenceRadiusMeters ?? DEFAULT_GEOFENCE_RADIUS_METERS;
|
||||
const distanceMeters = haversineDistanceMeters(
|
||||
gym.latitude,
|
||||
gym.longitude,
|
||||
location.latitude,
|
||||
location.longitude,
|
||||
);
|
||||
|
||||
if (location.accuracy <= MAX_LOCATION_ACCURACY_METERS) {
|
||||
if (distanceMeters > radius) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 403,
|
||||
error: `You are outside the gym geofence (${Math.round(distanceMeters)}m away, allowed ${Math.round(radius)}m).`,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
if (!fallbackRequested) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
error: `Location accuracy too low (${Math.round(location.accuracy)}m). Move to an open area and try again.`,
|
||||
};
|
||||
}
|
||||
|
||||
const fallbackMargin = Math.min(
|
||||
location.accuracy,
|
||||
MAX_FALLBACK_ACCURACY_MARGIN_METERS,
|
||||
);
|
||||
const fallbackAllowedDistance = radius + fallbackMargin;
|
||||
|
||||
if (distanceMeters > fallbackAllowedDistance) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 403,
|
||||
error: `You are outside the gym geofence (${Math.round(distanceMeters)}m away, fallback allowed ${Math.round(fallbackAllowedDistance)}m).`,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function validateCheckInGeofence(
|
||||
gym: GymGeofenceConfig,
|
||||
location: UserLocation | null,
|
||||
fallbackRequested: boolean,
|
||||
): { ok: true } | { ok: false; status: number; error: string } {
|
||||
return validateGeofenceWithFallback(gym, location, fallbackRequested);
|
||||
}
|
||||
|
||||
function haversineDistanceMeters(
|
||||
latitude1: number,
|
||||
longitude1: number,
|
||||
|
||||
@ -18,7 +18,12 @@
|
||||
"NSCameraUsageDescription": "This app uses the camera to scan food barcodes and identify nutritional information.",
|
||||
"NSUserNotificationsUsageDescription": "This app uses notifications to keep you updated on your fitness progress, recommendation approvals, and important reminders.",
|
||||
"NSMotionUsageDescription": "This app uses motion data to track your daily steps and activity progress.",
|
||||
"NSLocationWhenInUseUsageDescription": "This app uses your location to verify you are at your gym when checking in and checking out."
|
||||
"NSLocationWhenInUseUsageDescription": "This app uses your location to verify you are at your gym when checking in and checking out.",
|
||||
"NSLocationAlwaysAndWhenInUseUsageDescription": "This app uses your location in the background to automatically start and end workouts when you enter or leave your gym geofence."
|
||||
},
|
||||
"bundleIdentifier": "com.anonymous.fitai",
|
||||
"config": {
|
||||
"usesNonExemptEncryption": false
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
@ -32,7 +37,8 @@
|
||||
"android.permission.CAMERA",
|
||||
"android.permission.ACTIVITY_RECOGNITION",
|
||||
"android.permission.ACCESS_FINE_LOCATION",
|
||||
"android.permission.ACCESS_COARSE_LOCATION"
|
||||
"android.permission.ACCESS_COARSE_LOCATION",
|
||||
"android.permission.ACCESS_BACKGROUND_LOCATION"
|
||||
],
|
||||
"package": "com.anonymous.fitai"
|
||||
},
|
||||
@ -43,7 +49,15 @@
|
||||
"expo-router",
|
||||
"expo-font",
|
||||
"expo-barcode-scanner",
|
||||
"expo-location",
|
||||
[
|
||||
"expo-location",
|
||||
{
|
||||
"locationWhenInUsePermission": "Allow FitAI to use your location to verify gym check-ins and check-outs.",
|
||||
"locationAlwaysAndWhenInUsePermission": "Allow FitAI to use your location in the background to automatically start and end workouts at your gym.",
|
||||
"isIosBackgroundLocationEnabled": true,
|
||||
"isAndroidBackgroundLocationEnabled": true
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-notifications",
|
||||
{
|
||||
|
||||
20
apps/mobile/package-lock.json
generated
20
apps/mobile/package-lock.json
generated
@ -35,6 +35,7 @@
|
||||
"expo-secure-store": "~15.0.7",
|
||||
"expo-sensors": "~14.1.4",
|
||||
"expo-status-bar": "^3.0.8",
|
||||
"expo-task-manager": "~14.0.8",
|
||||
"expo-web-browser": "^15.0.10",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
@ -7650,6 +7651,19 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-task-manager": {
|
||||
"version": "14.0.9",
|
||||
"resolved": "https://registry.npmjs.org/expo-task-manager/-/expo-task-manager-14.0.9.tgz",
|
||||
"integrity": "sha512-GKWtXrkedr4XChHfTm5IyTcSfMtCPxzx89y4CMVqKfyfROATibrE/8UI5j7UC/pUOfFoYlQvulQEvECMreYuUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"unimodules-app-loader": "~6.0.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-web-browser": {
|
||||
"version": "15.0.10",
|
||||
"resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.10.tgz",
|
||||
@ -13443,6 +13457,12 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/unimodules-app-loader": {
|
||||
"version": "6.0.8",
|
||||
"resolved": "https://registry.npmjs.org/unimodules-app-loader/-/unimodules-app-loader-6.0.8.tgz",
|
||||
"integrity": "sha512-fqS8QwT/MC/HAmw1NKCHdzsPA6WaLm0dNmoC5Pz6lL+cDGYeYCNdHMO9fy08aL2ZD7cVkNM0pSR/AoNRe+rslA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unique-string": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz",
|
||||
|
||||
@ -38,6 +38,7 @@
|
||||
"expo-location": "~19.0.7",
|
||||
"expo-notifications": "~0.32.0",
|
||||
"expo-router": "~6.0.14",
|
||||
"expo-task-manager": "~14.0.8",
|
||||
"expo-secure-store": "~15.0.7",
|
||||
"expo-sensors": "~14.1.4",
|
||||
"expo-status-bar": "^3.0.8",
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { apiClient } from "./client";
|
||||
import { API_ENDPOINTS } from "../config/api";
|
||||
import { isAxiosError } from "axios";
|
||||
|
||||
export interface Attendance {
|
||||
id: string;
|
||||
@ -23,7 +24,9 @@ export const attendanceApi = {
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
throw new Error(
|
||||
getAttendanceErrorMessage(error, "Failed to load attendance history."),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@ -31,30 +34,55 @@ export const attendanceApi = {
|
||||
type: string,
|
||||
token: string,
|
||||
location: AttendanceLocationPayload,
|
||||
fallbackRequested = false,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await apiClient.post(
|
||||
API_ENDPOINTS.ATTENDANCE.CHECK_IN,
|
||||
{ type, location },
|
||||
{ type, location, fallbackRequested },
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
throw new Error(
|
||||
getAttendanceErrorMessage(error, "Failed to start workout."),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
checkOut: async (
|
||||
token: string,
|
||||
location: AttendanceLocationPayload,
|
||||
fallbackRequested = false,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await apiClient.post(
|
||||
API_ENDPOINTS.ATTENDANCE.CHECK_OUT,
|
||||
{ location },
|
||||
{ location, fallbackRequested },
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
throw new Error(
|
||||
getAttendanceErrorMessage(error, "Failed to end workout."),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
function getAttendanceErrorMessage(error: unknown, fallback: string): string {
|
||||
if (isAxiosError(error)) {
|
||||
const payload = error.response?.data;
|
||||
|
||||
if (typeof payload === "string" && payload.trim()) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
if (payload && typeof payload === "object") {
|
||||
const message = (payload as { error?: unknown }).error;
|
||||
if (typeof message === "string" && message.trim()) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
28
apps/mobile/src/api/userGym.ts
Normal file
28
apps/mobile/src/api/userGym.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { API_ENDPOINTS } from "../config/api";
|
||||
import { apiClient, withAuth } from "./client";
|
||||
|
||||
export interface UserGymGeofence {
|
||||
id: string;
|
||||
name: string;
|
||||
location: string | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
geofenceRadiusMeters: number | null;
|
||||
geofenceEnabled: boolean;
|
||||
status: "active" | "inactive";
|
||||
}
|
||||
|
||||
export interface UserGymResponse {
|
||||
gymId: string | null;
|
||||
gym: UserGymGeofence | null;
|
||||
}
|
||||
|
||||
export const userGymApi = {
|
||||
getCurrentGym: async (token: string | null): Promise<UserGymResponse> => {
|
||||
const response = await apiClient.get<UserGymResponse>(
|
||||
API_ENDPOINTS.USERS.GYM,
|
||||
withAuth(token),
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@ -17,6 +17,7 @@ import { useTheme } from "../../contexts/ThemeContext";
|
||||
import { Input } from "../../components/Input";
|
||||
import { MinimalButton } from "../../components/MinimalButton";
|
||||
import { MinimalCard } from "../../components/MinimalCard";
|
||||
import { syncAutoWorkoutGeofenceWithToken } from "../../services/autoWorkoutGeofence";
|
||||
import log from "../../utils/logger";
|
||||
|
||||
export default function OnboardingScreen() {
|
||||
@ -81,6 +82,9 @@ export default function OnboardingScreen() {
|
||||
// selectedGymId: string gym id, or null to proceed without gym
|
||||
try {
|
||||
await gymsApi.updateUserGym(selectedGymId, token);
|
||||
await syncAutoWorkoutGeofenceWithToken(token, {
|
||||
requestPermissions: true,
|
||||
});
|
||||
} catch (e) {
|
||||
log.warn("Failed to update gym selection", { gymId: selectedGymId });
|
||||
}
|
||||
|
||||
@ -30,6 +30,7 @@ import { ActivityRing } from "../../components/ActivityRing";
|
||||
import { useMembership } from "../../hooks/useMembership";
|
||||
import { attendanceApi, type Attendance } from "../../api/attendance";
|
||||
import { useDailySteps } from "../../hooks/useDailySteps";
|
||||
import { syncAutoWorkoutGeofenceWithToken } from "../../services/autoWorkoutGeofence";
|
||||
import {
|
||||
checkInsToActivities,
|
||||
completedGoalsToActivities,
|
||||
@ -237,6 +238,10 @@ export default function HomeScreen() {
|
||||
return;
|
||||
}
|
||||
|
||||
await syncAutoWorkoutGeofenceWithToken(token, {
|
||||
requestPermissions: true,
|
||||
});
|
||||
|
||||
const permission = await Location.requestForegroundPermissionsAsync();
|
||||
if (permission.status !== "granted") {
|
||||
Alert.alert(
|
||||
@ -256,10 +261,17 @@ export default function HomeScreen() {
|
||||
};
|
||||
|
||||
if (activeWorkoutSession) {
|
||||
await attendanceApi.checkOut(token, locationPayload);
|
||||
const fallbackRequested = locationPayload.accuracy > 50;
|
||||
await attendanceApi.checkOut(token, locationPayload, fallbackRequested);
|
||||
Alert.alert("Workout logged", "Session ended successfully.");
|
||||
} else {
|
||||
await attendanceApi.checkIn("gym", token, locationPayload);
|
||||
const fallbackRequested = locationPayload.accuracy > 50;
|
||||
await attendanceApi.checkIn(
|
||||
"gym",
|
||||
token,
|
||||
locationPayload,
|
||||
fallbackRequested,
|
||||
);
|
||||
Alert.alert("Workout started", "Session started successfully.");
|
||||
}
|
||||
|
||||
|
||||
@ -21,6 +21,7 @@ import { IconContainer } from "../../components/IconContainer";
|
||||
import { fitnessProfileApi, FitnessProfile } from "../../api/fitnessProfile";
|
||||
import { gymsApi, type Gym } from "../../api/gyms";
|
||||
import { useMembership } from "../../hooks/useMembership";
|
||||
import { syncAutoWorkoutGeofenceWithToken } from "../../services/autoWorkoutGeofence";
|
||||
import log from "../../utils/logger";
|
||||
|
||||
export default function ProfileScreen() {
|
||||
@ -115,6 +116,12 @@ export default function ProfileScreen() {
|
||||
"Success",
|
||||
selectedGymId ? "Gym selected successfully" : "Proceeding without gym",
|
||||
);
|
||||
|
||||
if (token) {
|
||||
await syncAutoWorkoutGeofenceWithToken(token, {
|
||||
requestPermissions: true,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
log.error("Failed to update gym selection", err);
|
||||
Alert.alert("Error", "Failed to update gym selection");
|
||||
|
||||
@ -13,6 +13,7 @@ import { RecommendationsProvider } from "../contexts/RecommendationsContext";
|
||||
import { NotificationsProvider } from "../contexts/NotificationsContext";
|
||||
import { MembershipProvider } from "../contexts/MembershipContext";
|
||||
import { queryClient } from "../lib/query-client";
|
||||
import { useAutoWorkoutGeofence } from "../hooks/useAutoWorkoutGeofence";
|
||||
import log from "../utils/logger";
|
||||
|
||||
// Wrapper to use notification permissions hook after ClerkLoaded
|
||||
@ -22,6 +23,7 @@ function AppContent() {
|
||||
useNotificationPermissions,
|
||||
} = require("../hooks/useNotificationPermissions");
|
||||
useNotificationPermissions();
|
||||
useAutoWorkoutGeofence();
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
|
||||
68
apps/mobile/src/hooks/useAutoWorkoutGeofence.ts
Normal file
68
apps/mobile/src/hooks/useAutoWorkoutGeofence.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { useAuth } from "@clerk/clerk-expo";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { AppState } from "react-native";
|
||||
import {
|
||||
disableAutoWorkoutGeofence,
|
||||
syncAutoWorkoutGeofenceWithToken,
|
||||
} from "../services/autoWorkoutGeofence";
|
||||
import log from "../utils/logger";
|
||||
|
||||
export function useAutoWorkoutGeofence() {
|
||||
const { isSignedIn, getToken } = useAuth();
|
||||
|
||||
const syncGeofence = useCallback(async () => {
|
||||
try {
|
||||
if (!isSignedIn) {
|
||||
await disableAutoWorkoutGeofence();
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
await disableAutoWorkoutGeofence();
|
||||
return;
|
||||
}
|
||||
|
||||
await syncAutoWorkoutGeofenceWithToken(token, {
|
||||
requestPermissions: true,
|
||||
});
|
||||
} catch (error) {
|
||||
log.warn("Failed to sync auto workout geofence", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}, [getToken, isSignedIn]);
|
||||
|
||||
useEffect(() => {
|
||||
void syncGeofence();
|
||||
}, [syncGeofence]);
|
||||
|
||||
useEffect(() => {
|
||||
const appStateSubscription = AppState.addEventListener(
|
||||
"change",
|
||||
(state) => {
|
||||
if (state === "active") {
|
||||
void syncGeofence();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const interval = setInterval(
|
||||
() => {
|
||||
void syncGeofence();
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
);
|
||||
|
||||
return () => {
|
||||
appStateSubscription.remove();
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [syncGeofence]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSignedIn) {
|
||||
void disableAutoWorkoutGeofence();
|
||||
}
|
||||
}, [isSignedIn]);
|
||||
}
|
||||
19
apps/mobile/src/lib/background-auth-token.ts
Normal file
19
apps/mobile/src/lib/background-auth-token.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
|
||||
const BACKGROUND_AUTH_TOKEN_KEY = "fitai_background_auth_token";
|
||||
|
||||
export async function saveBackgroundAuthToken(token: string): Promise<void> {
|
||||
if (!looksLikeJwt(token)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await SecureStore.setItemAsync(BACKGROUND_AUTH_TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
export async function getBackgroundAuthToken(): Promise<string | null> {
|
||||
return SecureStore.getItemAsync(BACKGROUND_AUTH_TOKEN_KEY);
|
||||
}
|
||||
|
||||
function looksLikeJwt(token: string): boolean {
|
||||
return token.split(".").length === 3;
|
||||
}
|
||||
296
apps/mobile/src/services/autoWorkoutGeofence.ts
Normal file
296
apps/mobile/src/services/autoWorkoutGeofence.ts
Normal file
@ -0,0 +1,296 @@
|
||||
import * as Location from "expo-location";
|
||||
import * as TaskManager from "expo-task-manager";
|
||||
import { apiClient } from "../api/client";
|
||||
import { userGymApi } from "../api/userGym";
|
||||
import { API_ENDPOINTS } from "../config/api";
|
||||
import { saveBackgroundAuthToken } from "../lib/background-auth-token";
|
||||
import { getBackgroundAuthToken } from "../lib/background-auth-token";
|
||||
import log from "../utils/logger";
|
||||
|
||||
const AUTO_WORKOUT_GEOFENCE_TASK = "fitai-auto-workout-geofence";
|
||||
const DEFAULT_RADIUS_METERS = 30;
|
||||
const MAX_ACCEPTABLE_ACCURACY_METERS = 50;
|
||||
const ACTION_COOLDOWN_MS = 90_000;
|
||||
|
||||
let lastActionAt = 0;
|
||||
|
||||
interface GeofenceRegion {
|
||||
identifier: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
radius: number;
|
||||
notifyOnEnter: boolean;
|
||||
notifyOnExit: boolean;
|
||||
}
|
||||
|
||||
if (!TaskManager.isTaskDefined(AUTO_WORKOUT_GEOFENCE_TASK)) {
|
||||
TaskManager.defineTask(
|
||||
AUTO_WORKOUT_GEOFENCE_TASK,
|
||||
async ({ data, error }: TaskManager.TaskManagerTaskBody) => {
|
||||
if (error) {
|
||||
log.error("Auto workout geofence task failed", error);
|
||||
return;
|
||||
}
|
||||
|
||||
const event = data as
|
||||
| {
|
||||
eventType?: Location.GeofencingEventType;
|
||||
region?: GeofenceRegion;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const eventType = event?.eventType;
|
||||
if (
|
||||
eventType !== Location.GeofencingEventType.Enter &&
|
||||
eventType !== Location.GeofencingEventType.Exit
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastActionAt < ACTION_COOLDOWN_MS) {
|
||||
return;
|
||||
}
|
||||
lastActionAt = now;
|
||||
|
||||
try {
|
||||
const token = await getBackgroundAuthToken();
|
||||
if (!token) {
|
||||
log.warn("Skipping geofence auto-workout due to missing auth token");
|
||||
return;
|
||||
}
|
||||
|
||||
const location = await Location.getCurrentPositionAsync({
|
||||
accuracy: Location.Accuracy.Balanced,
|
||||
});
|
||||
|
||||
const accuracy = location.coords.accuracy ?? 999;
|
||||
if (accuracy > MAX_ACCEPTABLE_ACCURACY_METERS) {
|
||||
log.warn("Skipping geofence auto-workout due to poor GPS accuracy", {
|
||||
accuracy,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = { Authorization: `Bearer ${token}` };
|
||||
const locationPayload = {
|
||||
latitude: location.coords.latitude,
|
||||
longitude: location.coords.longitude,
|
||||
accuracy,
|
||||
};
|
||||
|
||||
const historyRes = await apiClient.get(
|
||||
API_ENDPOINTS.ATTENDANCE.HISTORY,
|
||||
{
|
||||
headers,
|
||||
},
|
||||
);
|
||||
const history = Array.isArray(historyRes.data)
|
||||
? (historyRes.data as Array<{ id: string; checkOutTime?: string }>)
|
||||
: [];
|
||||
const activeSession = history.find((item) => !item.checkOutTime);
|
||||
|
||||
if (eventType === Location.GeofencingEventType.Enter) {
|
||||
if (activeSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
await apiClient.post(
|
||||
API_ENDPOINTS.ATTENDANCE.CHECK_IN,
|
||||
{ type: "gym", location: locationPayload },
|
||||
{ headers },
|
||||
);
|
||||
log.info("Auto-started workout after geofence enter");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!activeSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
await apiClient.post(
|
||||
API_ENDPOINTS.ATTENDANCE.CHECK_OUT,
|
||||
{
|
||||
location: locationPayload,
|
||||
fallbackRequested: accuracy > MAX_ACCEPTABLE_ACCURACY_METERS,
|
||||
},
|
||||
{ headers },
|
||||
);
|
||||
log.info("Auto-ended workout after geofence exit");
|
||||
} catch (taskError: unknown) {
|
||||
if (
|
||||
isApiError(taskError, 400, "Already checked in") ||
|
||||
isApiError(taskError, 404, "No active check-in found")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.error("Auto workout geofence action failed", taskError);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function configureAutoWorkoutGeofence(params: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
radiusMeters?: number | null;
|
||||
}): Promise<void> {
|
||||
const started = await Location.hasStartedGeofencingAsync(
|
||||
AUTO_WORKOUT_GEOFENCE_TASK,
|
||||
);
|
||||
|
||||
const region: GeofenceRegion = {
|
||||
identifier: "user-gym",
|
||||
latitude: params.latitude,
|
||||
longitude: params.longitude,
|
||||
radius: params.radiusMeters ?? DEFAULT_RADIUS_METERS,
|
||||
notifyOnEnter: true,
|
||||
notifyOnExit: true,
|
||||
};
|
||||
|
||||
if (started) {
|
||||
try {
|
||||
await Location.stopGeofencingAsync(AUTO_WORKOUT_GEOFENCE_TASK);
|
||||
} catch (error) {
|
||||
if (!isTaskNotFoundError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
log.warn("Geofence task was not found while stopping before restart", {
|
||||
task: AUTO_WORKOUT_GEOFENCE_TASK,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await Location.startGeofencingAsync(AUTO_WORKOUT_GEOFENCE_TASK, [region]);
|
||||
}
|
||||
|
||||
export async function disableAutoWorkoutGeofence(): Promise<void> {
|
||||
try {
|
||||
const started = await Location.hasStartedGeofencingAsync(
|
||||
AUTO_WORKOUT_GEOFENCE_TASK,
|
||||
);
|
||||
|
||||
if (!started) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Location.stopGeofencingAsync(AUTO_WORKOUT_GEOFENCE_TASK);
|
||||
} catch (error) {
|
||||
if (!isTaskNotFoundError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
log.warn("Geofence task was not found while disabling", {
|
||||
task: AUTO_WORKOUT_GEOFENCE_TASK,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncAutoWorkoutGeofenceWithToken(
|
||||
token: string,
|
||||
options?: { requestPermissions?: boolean },
|
||||
): Promise<void> {
|
||||
const shouldRequestPermissions = options?.requestPermissions ?? false;
|
||||
|
||||
await saveBackgroundAuthToken(token);
|
||||
|
||||
const foregroundPermission = await Location.getForegroundPermissionsAsync();
|
||||
const foregroundStatus =
|
||||
foregroundPermission.status === "granted"
|
||||
? foregroundPermission.status
|
||||
: shouldRequestPermissions
|
||||
? (await Location.requestForegroundPermissionsAsync()).status
|
||||
: foregroundPermission.status;
|
||||
|
||||
if (foregroundStatus !== "granted") {
|
||||
log.info("Auto workout geofence disabled: foreground location not granted");
|
||||
await disableAutoWorkoutGeofence();
|
||||
return;
|
||||
}
|
||||
|
||||
const backgroundPermission = await Location.getBackgroundPermissionsAsync();
|
||||
const backgroundStatus =
|
||||
backgroundPermission.status === "granted"
|
||||
? backgroundPermission.status
|
||||
: shouldRequestPermissions
|
||||
? (await Location.requestBackgroundPermissionsAsync()).status
|
||||
: backgroundPermission.status;
|
||||
|
||||
if (backgroundStatus !== "granted") {
|
||||
log.info("Auto workout geofence disabled: background location not granted");
|
||||
await disableAutoWorkoutGeofence();
|
||||
return;
|
||||
}
|
||||
|
||||
const currentGym = await userGymApi.getCurrentGym(token);
|
||||
const gym = currentGym.gym;
|
||||
|
||||
if (
|
||||
!gym ||
|
||||
gym.geofenceEnabled === false ||
|
||||
gym.latitude === null ||
|
||||
gym.longitude === null
|
||||
) {
|
||||
log.info("Auto workout geofence disabled: missing/disabled gym geofence");
|
||||
await disableAutoWorkoutGeofence();
|
||||
return;
|
||||
}
|
||||
|
||||
await configureAutoWorkoutGeofence({
|
||||
latitude: gym.latitude,
|
||||
longitude: gym.longitude,
|
||||
radiusMeters: gym.geofenceRadiusMeters ?? DEFAULT_RADIUS_METERS,
|
||||
});
|
||||
}
|
||||
|
||||
function isApiError(
|
||||
error: unknown,
|
||||
status: number,
|
||||
expectedMessage: string,
|
||||
): boolean {
|
||||
if (!error || typeof error !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const maybe = error as {
|
||||
response?: {
|
||||
status?: number;
|
||||
data?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
if (maybe.response?.status !== status) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = maybe.response.data;
|
||||
if (typeof data === "string") {
|
||||
return data === expectedMessage;
|
||||
}
|
||||
|
||||
if (data && typeof data === "object") {
|
||||
const errorField = (data as { error?: unknown }).error;
|
||||
if (typeof errorField === "string") {
|
||||
return errorField.includes(expectedMessage);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isTaskNotFoundError(error: unknown): boolean {
|
||||
if (!error || typeof error !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const message =
|
||||
"message" in error && typeof error.message === "string"
|
||||
? error.message
|
||||
: String(error);
|
||||
|
||||
return (
|
||||
message.includes("TaskNotFoundException") || message.includes("not found")
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user