From 5010a579d60892202284ad76e0891306fa9de1ff Mon Sep 17 00:00:00 2001 From: echo Date: Tue, 31 Mar 2026 18:00:35 +0200 Subject: [PATCH] add daily pedometer steps metric on home screen --- apps/mobile/app.json | 6 +- apps/mobile/package-lock.json | 14 +++ apps/mobile/package.json | 1 + apps/mobile/src/app/(tabs)/index.tsx | 53 ++++++++++ apps/mobile/src/hooks/useDailySteps.ts | 136 +++++++++++++++++++++++++ 5 files changed, 208 insertions(+), 2 deletions(-) create mode 100644 apps/mobile/src/hooks/useDailySteps.ts 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, + }; +}