feat: 集成健康数据功能
- 在项目中引入 react-native-health 库以获取健康数据 - 在 Explore 页面中添加步数和能量消耗的显示 - 实现页面聚焦时自动拉取今日健康数据 - 更新 iOS 权限设置以支持健康数据访问 - 添加健康数据相关的工具函数以简化数据获取
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
181
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
53
utils/health.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user