add daily pedometer steps metric on home screen
This commit is contained in:
parent
2cff8eafbd
commit
5010a579d6
@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
14
apps/mobile/package-lock.json
generated
14
apps/mobile/package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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 */}
|
||||||
|
|||||||
136
apps/mobile/src/hooks/useDailySteps.ts
Normal file
136
apps/mobile/src/hooks/useDailySteps.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user