diff --git a/apps/mobile/app.json b/apps/mobile/app.json
index 50311fd..454b8da 100644
--- a/apps/mobile/app.json
+++ b/apps/mobile/app.json
@@ -16,7 +16,8 @@
"supportsTablet": true,
"infoPlist": {
"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."
+ "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."
}
},
"android": {
@@ -27,7 +28,8 @@
"permissions": [
"CAMERA",
"POST_NOTIFICATIONS",
- "android.permission.CAMERA"
+ "android.permission.CAMERA",
+ "android.permission.ACTIVITY_RECOGNITION"
],
"package": "com.anonymous.fitai"
},
diff --git a/apps/mobile/package-lock.json b/apps/mobile/package-lock.json
index 5c087ac..7d952db 100644
--- a/apps/mobile/package-lock.json
+++ b/apps/mobile/package-lock.json
@@ -32,6 +32,7 @@
"expo-notifications": "~0.32.0",
"expo-router": "~6.0.14",
"expo-secure-store": "~15.0.7",
+ "expo-sensors": "~14.1.4",
"expo-status-bar": "^3.0.8",
"expo-web-browser": "^15.0.10",
"react": "19.1.0",
@@ -7604,6 +7605,19 @@
"expo": "*"
}
},
+ "node_modules/expo-sensors": {
+ "version": "14.1.4",
+ "resolved": "https://registry.npmjs.org/expo-sensors/-/expo-sensors-14.1.4.tgz",
+ "integrity": "sha512-KHROi5C8dhXedMwx7fZ5eyv9p382F5XOIex4a+GpdOTL3OY4xyk08kt7x64FtMeeoT87gYD3mb9LrBpHyNubkg==",
+ "license": "MIT",
+ "dependencies": {
+ "invariant": "^2.2.4"
+ },
+ "peerDependencies": {
+ "expo": "*",
+ "react-native": "*"
+ }
+ },
"node_modules/expo-server": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.4.tgz",
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index 08f0824..de9ebc1 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -38,6 +38,7 @@
"expo-notifications": "~0.32.0",
"expo-router": "~6.0.14",
"expo-secure-store": "~15.0.7",
+ "expo-sensors": "~14.1.4",
"expo-status-bar": "^3.0.8",
"expo-web-browser": "^15.0.10",
"react": "19.1.0",
diff --git a/apps/mobile/src/app/(tabs)/index.tsx b/apps/mobile/src/app/(tabs)/index.tsx
index 553790c..91aca56 100644
--- a/apps/mobile/src/app/(tabs)/index.tsx
+++ b/apps/mobile/src/app/(tabs)/index.tsx
@@ -28,6 +28,7 @@ import { ScanFoodModal } from "../../components/ScanFoodModal";
import { ActivityRing } from "../../components/ActivityRing";
import { useMembership } from "../../hooks/useMembership";
import { attendanceApi, type Attendance } from "../../api/attendance";
+import { useDailySteps } from "../../hooks/useDailySteps";
import {
checkInsToActivities,
completedGoalsToActivities,
@@ -77,6 +78,13 @@ export default function HomeScreen() {
const { user } = useUser();
const { getToken } = useAuth();
const { colors, typography } = useTheme();
+ const {
+ steps,
+ goal: stepsGoal,
+ loading: stepsLoading,
+ supported: stepsSupported,
+ permissionGranted: stepsPermissionGranted,
+ } = useDailySteps();
const { features, membershipType } = useMembership();
const { refetchStatistics, forceRefresh, statistics, loading } =
useStatistics();
@@ -825,6 +833,51 @@ export default function HomeScreen() {
style={{ marginTop: 16 }}
/>
+
+
+
+
+
+ 👣
+
+
+
+ Steps
+
+
+ {stepsLoading
+ ? "Loading steps..."
+ : !stepsSupported
+ ? "Step tracking not supported on this device"
+ : !stepsPermissionGranted
+ ? "Enable motion access in settings"
+ : steps >= stepsGoal
+ ? "Daily step goal reached!"
+ : `${Math.max(0, stepsGoal - steps)} steps remaining`}
+
+
+
+
+ {stepsLoading ? "--" : `${steps}/${stepsGoal}`}
+
+
+
+
{/* Recent Activity */}
diff --git a/apps/mobile/src/hooks/useDailySteps.ts b/apps/mobile/src/hooks/useDailySteps.ts
new file mode 100644
index 0000000..31ecd27
--- /dev/null
+++ b/apps/mobile/src/hooks/useDailySteps.ts
@@ -0,0 +1,136 @@
+import { useAuth } from "@clerk/clerk-expo";
+import { Pedometer } from "expo-sensors";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { AppState } from "react-native";
+
+interface DailyStepsState {
+ steps: number;
+ goal: number;
+ loading: boolean;
+ supported: boolean;
+ permissionGranted: boolean;
+ refresh: () => Promise;
+}
+
+const DEFAULT_DAILY_STEPS_GOAL = 8000;
+
+function startOfToday(): Date {
+ const now = new Date();
+ return new Date(now.getFullYear(), now.getMonth(), now.getDate());
+}
+
+function millisecondsUntilNextMidnight(): number {
+ const now = new Date();
+ const nextMidnight = new Date(now);
+ nextMidnight.setHours(24, 0, 0, 0);
+ return Math.max(1000, nextMidnight.getTime() - now.getTime());
+}
+
+export function useDailySteps(): DailyStepsState {
+ const { isSignedIn } = useAuth();
+ const [steps, setSteps] = useState(0);
+ const [loading, setLoading] = useState(true);
+ const [supported, setSupported] = useState(true);
+ const [permissionGranted, setPermissionGranted] = useState(true);
+ const pedometerSubscriptionRef = useRef | null>(null);
+ const midnightTimerRef = useRef | null>(null);
+
+ const fetchDailySteps = useCallback(async () => {
+ if (!isSignedIn) {
+ setSteps(0);
+ setLoading(false);
+ return;
+ }
+
+ try {
+ setLoading(true);
+ const isAvailable = await Pedometer.isAvailableAsync();
+ setSupported(Boolean(isAvailable));
+
+ if (!isAvailable) {
+ setSteps(0);
+ setPermissionGranted(false);
+ return;
+ }
+
+ const start = startOfToday();
+ const end = new Date();
+ const result = await Pedometer.getStepCountAsync(start, end);
+
+ setSteps(Math.max(0, result.steps ?? 0));
+ setPermissionGranted(true);
+ } catch {
+ setPermissionGranted(false);
+ setSteps(0);
+ } finally {
+ setLoading(false);
+ }
+ }, [isSignedIn]);
+
+ const resetAtMidnight = useCallback(() => {
+ if (midnightTimerRef.current) {
+ clearTimeout(midnightTimerRef.current);
+ }
+
+ midnightTimerRef.current = setTimeout(() => {
+ void fetchDailySteps();
+ resetAtMidnight();
+ }, millisecondsUntilNextMidnight() + 100);
+ }, [fetchDailySteps]);
+
+ useEffect(() => {
+ void fetchDailySteps();
+ }, [fetchDailySteps]);
+
+ useEffect(() => {
+ if (pedometerSubscriptionRef.current) {
+ pedometerSubscriptionRef.current.remove();
+ pedometerSubscriptionRef.current = null;
+ }
+
+ if (!isSignedIn || !supported || !permissionGranted) {
+ return;
+ }
+
+ pedometerSubscriptionRef.current = Pedometer.watchStepCount(
+ (result: { steps: number }) => {
+ setSteps((prev) => Math.max(0, prev + Math.max(0, result.steps ?? 0)));
+ },
+ );
+
+ return () => {
+ if (pedometerSubscriptionRef.current) {
+ pedometerSubscriptionRef.current.remove();
+ pedometerSubscriptionRef.current = null;
+ }
+ };
+ }, [isSignedIn, permissionGranted, supported]);
+
+ useEffect(() => {
+ const subscription = AppState.addEventListener("change", (state) => {
+ if (state === "active") {
+ void fetchDailySteps();
+ }
+ });
+
+ resetAtMidnight();
+
+ return () => {
+ subscription.remove();
+ if (midnightTimerRef.current) {
+ clearTimeout(midnightTimerRef.current);
+ }
+ };
+ }, [fetchDailySteps, resetAtMidnight]);
+
+ return {
+ steps,
+ goal: DEFAULT_DAILY_STEPS_GOAL,
+ loading,
+ supported,
+ permissionGranted,
+ refresh: fetchDailySteps,
+ };
+}