add daily pedometer steps metric on home screen

This commit is contained in:
echo 2026-03-31 18:00:35 +02:00
parent 2cff8eafbd
commit 5010a579d6
5 changed files with 208 additions and 2 deletions

View File

@ -16,7 +16,8 @@
"supportsTablet": true, "supportsTablet": true,
"infoPlist": { "infoPlist": {
"NSCameraUsageDescription": "This app uses the camera to scan food barcodes and identify nutritional information.", "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": { "android": {
@ -27,7 +28,8 @@
"permissions": [ "permissions": [
"CAMERA", "CAMERA",
"POST_NOTIFICATIONS", "POST_NOTIFICATIONS",
"android.permission.CAMERA" "android.permission.CAMERA",
"android.permission.ACTIVITY_RECOGNITION"
], ],
"package": "com.anonymous.fitai" "package": "com.anonymous.fitai"
}, },

View File

@ -32,6 +32,7 @@
"expo-notifications": "~0.32.0", "expo-notifications": "~0.32.0",
"expo-router": "~6.0.14", "expo-router": "~6.0.14",
"expo-secure-store": "~15.0.7", "expo-secure-store": "~15.0.7",
"expo-sensors": "~14.1.4",
"expo-status-bar": "^3.0.8", "expo-status-bar": "^3.0.8",
"expo-web-browser": "^15.0.10", "expo-web-browser": "^15.0.10",
"react": "19.1.0", "react": "19.1.0",
@ -7604,6 +7605,19 @@
"expo": "*" "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": { "node_modules/expo-server": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.4.tgz", "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.4.tgz",

View File

@ -38,6 +38,7 @@
"expo-notifications": "~0.32.0", "expo-notifications": "~0.32.0",
"expo-router": "~6.0.14", "expo-router": "~6.0.14",
"expo-secure-store": "~15.0.7", "expo-secure-store": "~15.0.7",
"expo-sensors": "~14.1.4",
"expo-status-bar": "^3.0.8", "expo-status-bar": "^3.0.8",
"expo-web-browser": "^15.0.10", "expo-web-browser": "^15.0.10",
"react": "19.1.0", "react": "19.1.0",

View File

@ -28,6 +28,7 @@ import { ScanFoodModal } from "../../components/ScanFoodModal";
import { ActivityRing } from "../../components/ActivityRing"; import { ActivityRing } from "../../components/ActivityRing";
import { useMembership } from "../../hooks/useMembership"; import { useMembership } from "../../hooks/useMembership";
import { attendanceApi, type Attendance } from "../../api/attendance"; import { attendanceApi, type Attendance } from "../../api/attendance";
import { useDailySteps } from "../../hooks/useDailySteps";
import { import {
checkInsToActivities, checkInsToActivities,
completedGoalsToActivities, completedGoalsToActivities,
@ -77,6 +78,13 @@ export default function HomeScreen() {
const { user } = useUser(); const { user } = useUser();
const { getToken } = useAuth(); const { getToken } = useAuth();
const { colors, typography } = useTheme(); const { colors, typography } = useTheme();
const {
steps,
goal: stepsGoal,
loading: stepsLoading,
supported: stepsSupported,
permissionGranted: stepsPermissionGranted,
} = useDailySteps();
const { features, membershipType } = useMembership(); const { features, membershipType } = useMembership();
const { refetchStatistics, forceRefresh, statistics, loading } = const { refetchStatistics, forceRefresh, statistics, loading } =
useStatistics(); useStatistics();
@ -825,6 +833,51 @@ export default function HomeScreen() {
style={{ marginTop: 16 }} style={{ marginTop: 16 }}
/> />
</MinimalCard> </MinimalCard>
<MinimalCard
variant="bordered"
style={[styles.progressCard, { marginTop: 12 }]}
>
<View style={styles.progressHeader}>
<View style={styles.progressLabelRow}>
<View
style={[
styles.progressIcon,
{ backgroundColor: `${colors.workouts}20` },
]}
>
<Text style={{ fontSize: 20 }}>👣</Text>
</View>
<View style={{ marginLeft: 12 }}>
<Text style={[typography.h4, { color: colors.textPrimary }]}>
Steps
</Text>
<Text
style={[typography.caption, { color: colors.textTertiary }]}
>
{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`}
</Text>
</View>
</View>
<Text style={[typography.h3, { color: colors.workouts }]}>
{stepsLoading ? "--" : `${steps}/${stepsGoal}`}
</Text>
</View>
<ProgressBar
progress={Math.min(steps / stepsGoal, 1)}
color={colors.workouts}
height={12}
style={{ marginTop: 16 }}
/>
</MinimalCard>
</View> </View>
{/* Recent Activity */} {/* Recent Activity */}

View File

@ -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<void>;
}
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<ReturnType<
typeof Pedometer.watchStepCount
> | null>(null);
const midnightTimerRef = useRef<ReturnType<typeof setTimeout> | 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,
};
}