From 67972fa92b499ee897cfde4627ce5b747bed4d61 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Tue, 12 Aug 2025 09:29:34 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=9B=86=E6=88=90=E5=81=A5=E5=BA=B7?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在项目中引入 react-native-health 库以获取健康数据 - 在 Explore 页面中添加步数和能量消耗的显示 - 实现页面聚焦时自动拉取今日健康数据 - 更新 iOS 权限设置以支持健康数据访问 - 添加健康数据相关的工具函数以简化数据获取 --- app/(tabs)/explore.tsx | 34 +++- ios/Podfile.lock | 6 + ios/digitalpilates/Info.plist | 4 + .../digitalpilates.entitlements | 5 +- package-lock.json | 181 +++++++++++++++++- package.json | 1 + utils/health.ts | 53 +++++ 7 files changed, 278 insertions(+), 6 deletions(-) create mode 100644 utils/health.ts diff --git a/app/(tabs)/explore.tsx b/app/(tabs)/explore.tsx index 9dadf87..ff7c141 100644 --- a/app/(tabs)/explore.tsx +++ b/app/(tabs)/explore.tsx @@ -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(null); + const [activeCalories, setActiveCalories] = useState(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 ( @@ -100,15 +126,17 @@ export default function ExploreScreen() { 消耗卡路里 - 645 千卡 + + {activeCalories != null ? `${activeCalories} 千卡` : '——'} + 步数 - 999/2000 - + {stepCount != null ? `${stepCount}/2000` : '——/2000'} + diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 85d5d48..47f7bd4 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -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 diff --git a/ios/digitalpilates/Info.plist b/ios/digitalpilates/Info.plist index f337140..9eca003 100644 --- a/ios/digitalpilates/Info.plist +++ b/ios/digitalpilates/Info.plist @@ -49,6 +49,10 @@ $(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route + NSHealthShareUsageDescription + 应用需要访问您的健康数据(步数与能量消耗)以展示运动统计。 + NSHealthUpdateUsageDescription + 应用需要写入健康数据以提供更准确的统计。 UILaunchStoryboardName SplashScreen UIRequiredDeviceCapabilities diff --git a/ios/digitalpilates/digitalpilates.entitlements b/ios/digitalpilates/digitalpilates.entitlements index f683276..9c24ef2 100644 --- a/ios/digitalpilates/digitalpilates.entitlements +++ b/ios/digitalpilates/digitalpilates.entitlements @@ -1,5 +1,8 @@ - + + com.apple.developer.healthkit + + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 445324d..03f99be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index b5d97a2..a02ce2b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/utils/health.ts b/utils/health.ts new file mode 100644 index 0000000..9c08d0a --- /dev/null +++ b/utils/health.ts @@ -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 { + 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 { + const start = new Date(); + start.setHours(0, 0, 0, 0); + const options = { startDate: start.toISOString() } as any; + + const steps = await new Promise((resolve) => { + AppleHealthKit.getStepCount(options, (err, res) => { + if (err || !res) return resolve(0); + resolve(res.value || 0); + }); + }); + + const calories = await new Promise((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 }; +} \ No newline at end of file