feat: 集成健康数据功能

- 在项目中引入 react-native-health 库以获取健康数据
- 在 Explore 页面中添加步数和能量消耗的显示
- 实现页面聚焦时自动拉取今日健康数据
- 更新 iOS 权限设置以支持健康数据访问
- 添加健康数据相关的工具函数以简化数据获取
This commit is contained in:
richarjiang
2025-08-12 09:29:34 +08:00
parent 9796c614ed
commit 67972fa92b
7 changed files with 278 additions and 6 deletions

View File

@@ -2,8 +2,10 @@ import { ProgressBar } from '@/components/ProgressBar';
import { Colors } from '@/constants/Colors';
import { getTabBarBottomPadding } from '@/constants/TabBar';
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
import { ensureHealthPermissions, fetchTodayHealthData } from '@/utils/health';
import { Ionicons } from '@expo/vector-icons';
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
import { useFocusEffect } from '@react-navigation/native';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
SafeAreaView,
@@ -46,6 +48,30 @@ export default function ExploreScreen() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scrollWidth]);
// HealthKit: 每次页面聚焦都拉取今日数据
const [stepCount, setStepCount] = useState<number | null>(null);
const [activeCalories, setActiveCalories] = useState<number | null>(null);
useFocusEffect(
React.useCallback(() => {
let isActive = true;
const run = async () => {
console.log('HealthKit init start');
const ok = await ensureHealthPermissions();
if (!ok) return;
const data = await fetchTodayHealthData();
if (!isActive) return;
setStepCount(data.steps);
setActiveCalories(Math.round(data.activeEnergyBurned));
};
run();
return () => {
isActive = false;
};
}, [])
);
return (
<View style={styles.container}>
<SafeAreaView style={styles.safeArea}>
@@ -100,15 +126,17 @@ export default function ExploreScreen() {
<View style={styles.metricsRight}>
<View style={[styles.metricsRightCard, styles.caloriesCard, { minHeight: 88 }]}>
<Text style={styles.cardTitleSecondary}></Text>
<Text style={styles.caloriesValue}>645 </Text>
<Text style={styles.caloriesValue}>
{activeCalories != null ? `${activeCalories} 千卡` : '——'}
</Text>
</View>
<View style={[styles.metricsRightCard, styles.stepsCard, { minHeight: 88 }]}>
<View style={styles.cardHeaderRow}>
<View style={styles.iconSquare}><Ionicons name="footsteps-outline" size={18} color="#192126" /></View>
<Text style={styles.cardTitle}></Text>
</View>
<Text style={styles.stepsValue}>999/2000</Text>
<ProgressBar progress={0.5} height={12} trackColor="#FFEBCB" fillColor="#FFC365" />
<Text style={styles.stepsValue}>{stepCount != null ? `${stepCount}/2000` : '——/2000'}</Text>
<ProgressBar progress={Math.min(1, Math.max(0, (stepCount ?? 0) / 2000))} height={12} trackColor="#FFEBCB" fillColor="#FFC365" />
</View>
</View>
</View>

View File

@@ -1855,6 +1855,8 @@ PODS:
- React-logger (= 0.79.5)
- React-perflogger (= 0.79.5)
- React-utils (= 0.79.5)
- RNAppleHealthKit (1.7.0):
- React
- RNGestureHandler (2.24.0):
- DoubleConversion
- glog
@@ -2158,6 +2160,7 @@ DEPENDENCIES:
- ReactAppDependencyProvider (from `build/generated/ios`)
- ReactCodegen (from `build/generated/ios`)
- ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`)
- RNAppleHealthKit (from `../node_modules/react-native-health`)
- RNGestureHandler (from `../node_modules/react-native-gesture-handler`)
- RNReanimated (from `../node_modules/react-native-reanimated`)
- RNScreens (from `../node_modules/react-native-screens`)
@@ -2350,6 +2353,8 @@ EXTERNAL SOURCES:
:path: build/generated/ios
ReactCommon:
:path: "../node_modules/react-native/ReactCommon"
RNAppleHealthKit:
:path: "../node_modules/react-native-health"
RNGestureHandler:
:path: "../node_modules/react-native-gesture-handler"
RNReanimated:
@@ -2450,6 +2455,7 @@ SPEC CHECKSUMS:
ReactAppDependencyProvider: f3e842e6cb5a825b6918a74a38402ba1409411f8
ReactCodegen: 6cb6e0d0b52471abc883541c76589d1c367c64c7
ReactCommon: 1ab5451fc5da87c4cc4c3046e19a8054624ca763
RNAppleHealthKit: 86ef7ab70f762b802f5c5289372de360cca701f9
RNGestureHandler: 7d0931a61d7ba0259f32db0ba7d0963c3ed15d2b
RNReanimated: 2313402fe27fecb7237619e9c6fcee3177f08a65
RNScreens: 482e9707f9826230810c92e765751af53826d509

View File

@@ -49,6 +49,10 @@
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>
</array>
<key>NSHealthShareUsageDescription</key>
<string>应用需要访问您的健康数据(步数与能量消耗)以展示运动统计。</string>
<key>NSHealthUpdateUsageDescription</key>
<string>应用需要写入健康数据以提供更准确的统计。</string>
<key>UILaunchStoryboardName</key>
<string>SplashScreen</string>
<key>UIRequiredDeviceCapabilities</key>

View File

@@ -1,5 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
<dict>
<key>com.apple.developer.healthkit</key>
<true/>
</dict>
</plist>

181
package-lock.json generated
View File

@@ -30,6 +30,7 @@
"react-dom": "19.0.0",
"react-native": "0.79.5",
"react-native-gesture-handler": "~2.24.0",
"react-native-health": "^1.19.0",
"react-native-reanimated": "~3.17.4",
"react-native-safe-area-context": "5.4.0",
"react-native-screens": "~4.11.1",
@@ -2992,6 +2993,12 @@
"node": ">=18"
}
},
"node_modules/@react-native/normalize-color": {
"version": "2.1.0",
"resolved": "https://mirrors.tencent.com/npm/@react-native/normalize-color/-/normalize-color-2.1.0.tgz",
"integrity": "sha512-Z1jQI2NpdFJCVgpY+8Dq/Bt3d+YUi1928Q+/CZm/oh66fzM0RUl54vvuXlPJKybH4pdCZey1eDTPaLHkMPNgWA==",
"license": "MIT"
},
"node_modules/@react-native/normalize-colors": {
"version": "0.79.5",
"resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.79.5.tgz",
@@ -3254,7 +3261,7 @@
"version": "19.0.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.14.tgz",
"integrity": "sha512-ixLZ7zG7j1fM0DijL9hDArwhwcCb4vqmePgwtV0GfnkHRSCUEv4LvzarcTdhoqgyMznUx/EhoTUv31CKZzkQlw==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
@@ -5186,7 +5193,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/data-view-buffer": {
@@ -10223,6 +10230,176 @@
"react-native": "*"
}
},
"node_modules/react-native-health": {
"version": "1.19.0",
"resolved": "https://mirrors.tencent.com/npm/react-native-health/-/react-native-health-1.19.0.tgz",
"integrity": "sha512-IeF/YYWDKBkx3R89uk/zdF3Sql9Jj+okZBXTAC62FHE+Ef3CMN+ArL6D1SzFk/dc0qK+Q7mnZrmFDbTsftszxw==",
"license": "MIT",
"dependencies": {
"@expo/config-plugins": "^7.2.2"
},
"engines": {
"node": ">=16.0.0"
},
"peerDependencies": {
"react-native": ">=0.67.3"
}
},
"node_modules/react-native-health/node_modules/@babel/code-frame": {
"version": "7.10.4",
"resolved": "https://mirrors.tencent.com/npm/@babel/code-frame/-/code-frame-7.10.4.tgz",
"integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==",
"dependencies": {
"@babel/highlight": "^7.10.4"
}
},
"node_modules/react-native-health/node_modules/@expo/config-plugins": {
"version": "7.9.2",
"resolved": "https://mirrors.tencent.com/npm/@expo/config-plugins/-/config-plugins-7.9.2.tgz",
"integrity": "sha512-sRU/OAp7kJxrCUiCTUZqvPMKPdiN1oTmNfnbkG4oPdfWQTpid3jyCH7ZxJEN5SI6jrY/ZsK5B/JPgjDUhuWLBQ==",
"license": "MIT",
"dependencies": {
"@expo/config-types": "^50.0.0-alpha.1",
"@expo/fingerprint": "^0.6.0",
"@expo/json-file": "~8.3.0",
"@expo/plist": "^0.1.0",
"@expo/sdk-runtime-versions": "^1.0.0",
"@react-native/normalize-color": "^2.0.0",
"chalk": "^4.1.2",
"debug": "^4.3.1",
"find-up": "~5.0.0",
"getenv": "^1.0.0",
"glob": "7.1.6",
"resolve-from": "^5.0.0",
"semver": "^7.5.3",
"slash": "^3.0.0",
"slugify": "^1.6.6",
"xcode": "^3.0.1",
"xml2js": "0.6.0"
}
},
"node_modules/react-native-health/node_modules/@expo/config-types": {
"version": "50.0.1",
"resolved": "https://mirrors.tencent.com/npm/@expo/config-types/-/config-types-50.0.1.tgz",
"integrity": "sha512-EZHMgzkWRB9SMHO1e9m8s+OMahf92XYTnsCFjxhSfcDrcEoSdFPyJWDJVloHZPMGhxns7Fi2+A+bEVN/hD4NKA==",
"license": "MIT"
},
"node_modules/react-native-health/node_modules/@expo/fingerprint": {
"version": "0.6.1",
"resolved": "https://mirrors.tencent.com/npm/@expo/fingerprint/-/fingerprint-0.6.1.tgz",
"integrity": "sha512-ggLn6unI6qowlA1FihdQwPpLn16VJulYkvYAEL50gaqVahfNEglRQMSH2giZzjD0d6xq2/EQuUdFyHaJfyJwOQ==",
"license": "MIT",
"dependencies": {
"@expo/spawn-async": "^1.5.0",
"chalk": "^4.1.2",
"debug": "^4.3.4",
"find-up": "^5.0.0",
"minimatch": "^3.0.4",
"p-limit": "^3.1.0",
"resolve-from": "^5.0.0"
},
"bin": {
"fingerprint": "bin/cli.js"
}
},
"node_modules/react-native-health/node_modules/@expo/json-file": {
"version": "8.3.3",
"resolved": "https://mirrors.tencent.com/npm/@expo/json-file/-/json-file-8.3.3.tgz",
"integrity": "sha512-eZ5dld9AD0PrVRiIWpRkm5aIoWBw3kAyd8VkuWEy92sEthBKDDDHAnK2a0dw0Eil6j7rK7lS/Qaq/Zzngv2h5A==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "~7.10.4",
"json5": "^2.2.2",
"write-file-atomic": "^2.3.0"
}
},
"node_modules/react-native-health/node_modules/@expo/plist": {
"version": "0.1.3",
"resolved": "https://mirrors.tencent.com/npm/@expo/plist/-/plist-0.1.3.tgz",
"integrity": "sha512-GW/7hVlAylYg1tUrEASclw1MMk9FP4ZwyFAY/SUTJIhPDQHtfOlXREyWV3hhrHdX/K+pS73GNgdfT6E/e+kBbg==",
"license": "MIT",
"dependencies": {
"@xmldom/xmldom": "~0.7.7",
"base64-js": "^1.2.3",
"xmlbuilder": "^14.0.0"
}
},
"node_modules/react-native-health/node_modules/@xmldom/xmldom": {
"version": "0.7.13",
"resolved": "https://mirrors.tencent.com/npm/@xmldom/xmldom/-/xmldom-0.7.13.tgz",
"integrity": "sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==",
"deprecated": "this version is no longer supported, please update to at least 0.8.*",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/react-native-health/node_modules/getenv": {
"version": "1.0.0",
"resolved": "https://mirrors.tencent.com/npm/getenv/-/getenv-1.0.0.tgz",
"integrity": "sha512-7yetJWqbS9sbn0vIfliPsFgoXMKn/YMF+Wuiog97x+urnSRRRZ7xB+uVkwGKzRgq9CDFfMQnE9ruL5DHv9c6Xg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/react-native-health/node_modules/glob": {
"version": "7.1.6",
"resolved": "https://mirrors.tencent.com/npm/glob/-/glob-7.1.6.tgz",
"integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.0.4",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/react-native-health/node_modules/semver": {
"version": "7.7.2",
"resolved": "https://mirrors.tencent.com/npm/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/react-native-health/node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://mirrors.tencent.com/npm/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC"
},
"node_modules/react-native-health/node_modules/write-file-atomic": {
"version": "2.4.3",
"resolved": "https://mirrors.tencent.com/npm/write-file-atomic/-/write-file-atomic-2.4.3.tgz",
"integrity": "sha512-GaETH5wwsX+GcnzhPgKcKjJ6M2Cq3/iZp1WyY/X1CSqrW+jVNM9Y7D8EC2sM4ZG/V8wZlSniJnCKWPmBYAucRQ==",
"license": "ISC",
"dependencies": {
"graceful-fs": "^4.1.11",
"imurmurhash": "^0.1.4",
"signal-exit": "^3.0.2"
}
},
"node_modules/react-native-health/node_modules/xmlbuilder": {
"version": "14.0.0",
"resolved": "https://mirrors.tencent.com/npm/xmlbuilder/-/xmlbuilder-14.0.0.tgz",
"integrity": "sha512-ts+B2rSe4fIckR6iquDjsKbQFK2NlUk6iG5nf14mDEyldgoc2nEKZ3jZWMPTxGQwVgToSjt6VGIho1H8/fNFTg==",
"license": "MIT",
"engines": {
"node": ">=8.0"
}
},
"node_modules/react-native-is-edge-to-edge": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz",

View File

@@ -33,6 +33,7 @@
"react-dom": "19.0.0",
"react-native": "0.79.5",
"react-native-gesture-handler": "~2.24.0",
"react-native-health": "^1.19.0",
"react-native-reanimated": "~3.17.4",
"react-native-safe-area-context": "5.4.0",
"react-native-screens": "~4.11.1",

53
utils/health.ts Normal file
View File

@@ -0,0 +1,53 @@
import AppleHealthKit, { HealthKitPermissions } from 'react-native-health';
const PERMISSIONS: HealthKitPermissions = {
permissions: {
read: [
AppleHealthKit.Constants.Permissions.StepCount,
AppleHealthKit.Constants.Permissions.ActiveEnergyBurned,
],
write: [],
},
};
export type TodayHealthData = {
steps: number;
activeEnergyBurned: number; // kilocalories
};
export async function ensureHealthPermissions(): Promise<boolean> {
return new Promise((resolve) => {
AppleHealthKit.initHealthKit(PERMISSIONS, (error) => {
if (error) {
console.warn('HealthKit init failed', error);
resolve(false);
return;
}
console.log('HealthKit init success');
resolve(true);
});
});
}
export async function fetchTodayHealthData(): Promise<TodayHealthData> {
const start = new Date();
start.setHours(0, 0, 0, 0);
const options = { startDate: start.toISOString() } as any;
const steps = await new Promise<number>((resolve) => {
AppleHealthKit.getStepCount(options, (err, res) => {
if (err || !res) return resolve(0);
resolve(res.value || 0);
});
});
const calories = await new Promise<number>((resolve) => {
AppleHealthKit.getActiveEnergyBurned(options, (err, res) => {
if (err || !res) return resolve(0);
// library returns value as number in kilocalories
resolve(res[0].value || 0);
});
});
return { steps, activeEnergyBurned: calories };
}