feat: 新增基础代谢率功能及相关组件

- 在健康数据中引入基础代谢率的读取和展示,支持用户记录健身进度
- 更新统计页面,替换BMI卡片为基础代谢卡片,提升用户体验
- 优化健康数据获取逻辑,确保基础代谢数据的准确性
- 更新权限描述,明确应用对健康数据的访问需求
This commit is contained in:
2025-08-21 22:53:22 +08:00
parent 098c65b23e
commit f10b7a0fb5
7 changed files with 538 additions and 34 deletions

View File

@@ -47,7 +47,8 @@
"react-native-health", "react-native-health",
{ {
"enableHealthAPI": true, "enableHealthAPI": true,
"healthSharePermission": "应用需要访问您的健康数据(步数与能量消耗)以展示运动统计。" "healthSharePermission": "应用需要访问您的健康数据(步数与能量消耗)以展示运动统计。",
"healthUpdatePermission": "应用需要更新您的健康数据(体重信息)以记录您的健身进度。"
} }
] ]
], ],

View File

@@ -1,5 +1,5 @@
import { AnimatedNumber } from '@/components/AnimatedNumber'; import { AnimatedNumber } from '@/components/AnimatedNumber';
import { BMICard } from '@/components/BMICard'; import { BasalMetabolismCard } from '@/components/BasalMetabolismCard';
import { FitnessRingsCard } from '@/components/FitnessRingsCard'; import { FitnessRingsCard } from '@/components/FitnessRingsCard';
import { MoodCard } from '@/components/MoodCard'; import { MoodCard } from '@/components/MoodCard';
import { NutritionRadarCard } from '@/components/NutritionRadarCard'; import { NutritionRadarCard } from '@/components/NutritionRadarCard';
@@ -140,6 +140,8 @@ export default function ExploreScreen() {
// HealthKit: 每次页面聚焦都拉取今日数据 // HealthKit: 每次页面聚焦都拉取今日数据
const [stepCount, setStepCount] = useState<number | null>(null); const [stepCount, setStepCount] = useState<number | null>(null);
const [activeCalories, setActiveCalories] = useState<number | null>(null); const [activeCalories, setActiveCalories] = useState<number | null>(null);
// 基础代谢率(千卡)
const [basalMetabolism, setBasalMetabolism] = useState<number | null>(null);
// 睡眠时长(分钟) // 睡眠时长(分钟)
const [sleepDuration, setSleepDuration] = useState<number | null>(null); const [sleepDuration, setSleepDuration] = useState<number | null>(null);
// HRV数据 // HRV数据
@@ -235,6 +237,7 @@ export default function ExploreScreen() {
if (latestRequestKeyRef.current === requestKey) { if (latestRequestKeyRef.current === requestKey) {
setStepCount(data.steps); setStepCount(data.steps);
setActiveCalories(Math.round(data.activeEnergyBurned)); setActiveCalories(Math.round(data.activeEnergyBurned));
setBasalMetabolism(Math.round(data.basalEnergyBurned));
setSleepDuration(data.sleepDuration); setSleepDuration(data.sleepDuration);
// 更新健身圆环数据 // 更新健身圆环数据
setFitnessRingsData({ setFitnessRingsData({
@@ -455,14 +458,6 @@ export default function ExploreScreen() {
{/* 右列 */} {/* 右列 */}
<View style={styles.masonryColumn}> <View style={styles.masonryColumn}>
<FloatingCard style={styles.masonryCard} delay={250}> <FloatingCard style={styles.masonryCard} delay={250}>
<BMICard
weight={userProfile?.weight ? parseFloat(userProfile.weight) : undefined}
height={userProfile?.height ? parseFloat(userProfile.height) : undefined}
style={styles.bmiCardOverride}
/>
</FloatingCard>
<FloatingCard style={styles.masonryCard} delay={750}>
<FitnessRingsCard <FitnessRingsCard
activeCalories={fitnessRingsData.activeCalories} activeCalories={fitnessRingsData.activeCalories}
activeCaloriesGoal={fitnessRingsData.activeCaloriesGoal} activeCaloriesGoal={fitnessRingsData.activeCaloriesGoal}
@@ -474,7 +469,7 @@ export default function ExploreScreen() {
/> />
</FloatingCard> </FloatingCard>
<FloatingCard style={[styles.masonryCard, styles.sleepCard]} delay={1250}> <FloatingCard style={[styles.masonryCard, styles.sleepCard]} delay={750}>
<View style={styles.cardHeaderRow}> <View style={styles.cardHeaderRow}>
<Text style={styles.cardTitle}></Text> <Text style={styles.cardTitle}></Text>
</View> </View>
@@ -487,6 +482,14 @@ export default function ExploreScreen() {
)} )}
</FloatingCard> </FloatingCard>
{/* 基础代谢卡片 */}
<FloatingCard style={styles.masonryCard} delay={1250}>
<BasalMetabolismCard
value={basalMetabolism}
resetToken={animToken}
style={styles.basalMetabolismCardOverride}
/>
</FloatingCard>
</View> </View>
</View> </View>
@@ -823,7 +826,7 @@ const styles = StyleSheet.create({
shadowRadius: 12, shadowRadius: 12,
elevation: 6, elevation: 6,
}, },
bmiCardOverride: { basalMetabolismCardOverride: {
margin: -16, // 抵消 masonryCard 的 padding margin: -16, // 抵消 masonryCard 的 padding
borderRadius: 16, borderRadius: 16,
}, },

View File

@@ -0,0 +1,188 @@
import { AnimatedNumber } from '@/components/AnimatedNumber';
import { LinearGradient } from 'expo-linear-gradient';
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
interface BasalMetabolismCardProps {
value: number | null;
resetToken?: number;
style?: any;
}
export function BasalMetabolismCard({ value, resetToken, style }: BasalMetabolismCardProps) {
// 获取基础代谢状态描述
const getMetabolismStatus = () => {
if (value === null || value === 0) {
return { text: '未知', color: '#9AA3AE' };
}
// 基于常见的基础代谢范围来判断状态
if (value >= 1800) {
return { text: '高代谢', color: '#10B981' };
} else if (value >= 1400) {
return { text: '正常', color: '#3B82F6' };
} else if (value >= 1000) {
return { text: '偏低', color: '#F59E0B' };
} else {
return { text: '较低', color: '#EF4444' };
}
};
const status = getMetabolismStatus();
return (
<View style={[styles.container, style]}>
{/* 渐变背景 */}
<LinearGradient
colors={['#F0F9FF', '#E0F2FE']}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
{/* 装饰性圆圈 */}
<View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} />
{/* 头部区域 */}
<View style={styles.header}>
<View style={styles.leftSection}>
<Text style={styles.title}></Text>
</View>
<View style={[styles.statusBadge, { backgroundColor: status.color + '20' }]}>
<Text style={[styles.statusText, { color: status.color }]}>{status.text}</Text>
</View>
</View>
{/* 数值显示区域 */}
<View style={styles.valueSection}>
{value != null && value > 0 ? (
<AnimatedNumber
value={value}
resetToken={resetToken}
style={styles.value}
format={(v) => Math.round(v).toString()}
/>
) : (
<Text style={styles.value}>--</Text>
)}
<Text style={styles.unit}>/</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
backgroundColor: '#FFFFFF',
borderRadius: 20,
padding: 16,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.08,
shadowRadius: 12,
elevation: 4,
position: 'relative',
overflow: 'hidden',
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
opacity: 0.6,
},
decorativeCircle1: {
position: 'absolute',
top: -20,
right: -20,
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: '#0EA5E9',
opacity: 0.1,
},
decorativeCircle2: {
position: 'absolute',
bottom: -15,
left: -15,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#0EA5E9',
opacity: 0.05,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 16,
zIndex: 1,
},
leftSection: {
flexDirection: 'row',
alignItems: 'center',
},
iconContainer: {
width: 32,
height: 32,
borderRadius: 10,
backgroundColor: '#FFFFFF',
alignItems: 'center',
justifyContent: 'center',
marginRight: 8,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 1,
},
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 1,
},
fireIcon: {
width: 14,
height: 18,
backgroundColor: '#EF4444',
borderTopLeftRadius: 7,
borderTopRightRadius: 7,
borderBottomLeftRadius: 2,
borderBottomRightRadius: 2,
},
title: {
fontSize: 16,
fontWeight: '700',
color: '#0F172A',
},
statusBadge: {
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 12,
},
statusText: {
fontSize: 11,
fontWeight: '600',
},
valueSection: {
flexDirection: 'row',
alignItems: 'center',
zIndex: 1,
},
value: {
fontSize: 24,
fontWeight: '800',
color: '#0F172A',
lineHeight: 28,
},
unit: {
fontSize: 14,
fontWeight: '500',
color: '#64748B',
marginLeft: 6,
},
});

View File

@@ -83,7 +83,7 @@ export function StressMeter({ value, updateTime, style, hrvValue }: StressMeterP
{/* 渐变背景进度条 */} {/* 渐变背景进度条 */}
<View style={[styles.progressBar, { width: '100%' }]}> <View style={[styles.progressBar, { width: '100%' }]}>
<LinearGradient <LinearGradient
colors={['#10B981', '#FCD34D', '#EF4444']} colors={['#EF4444', '#FCD34D', '#10B981']}
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }} end={{ x: 1, y: 0 }}
style={styles.gradientBar} style={styles.gradientBar}
@@ -114,9 +114,7 @@ export function StressMeter({ value, updateTime, style, hrvValue }: StressMeterP
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
backgroundColor: '#FFFFFF', backgroundColor: '#FFFFFF',
borderRadius: 16, padding: 2,
padding: 14,
marginBottom: 12,
shadowColor: '#000', shadowColor: '#000',
shadowOffset: { shadowOffset: {
width: 0, width: 0,
@@ -127,16 +125,6 @@ const styles = StyleSheet.create({
elevation: 3, elevation: 3,
position: 'relative', position: 'relative',
overflow: 'hidden', overflow: 'hidden',
minHeight: 110,
width: 140,
},
gradientBackground: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
borderRadius: 16,
}, },
header: { header: {
flexDirection: 'row', flexDirection: 'row',

View File

@@ -2,13 +2,18 @@ import { Colors } from '@/constants/Colors';
import { ROUTES } from '@/constants/Routes'; import { ROUTES } from '@/constants/Routes';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme';
import { fetchWeightHistory } from '@/store/userSlice'; import { fetchWeightHistory } from '@/store/userSlice';
import { BMI_CATEGORIES, canCalculateBMI, getBMIResult } from '@/utils/bmi';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { import {
Dimensions, Dimensions,
Image, Image,
Modal,
ScrollView,
StyleSheet, StyleSheet,
Text, Text,
TouchableOpacity, TouchableOpacity,
@@ -37,14 +42,28 @@ export function WeightHistoryCard() {
const weightHistory = useAppSelector((s) => s.user.weightHistory); const weightHistory = useAppSelector((s) => s.user.weightHistory);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [showChart, setShowChart] = useState(false); const [showChart, setShowChart] = useState(false);
const [showBMIModal, setShowBMIModal] = useState(false);
// 动画相关状态 // 动画相关状态
const animationProgress = useSharedValue(0); const animationProgress = useSharedValue(0);
const [isAnimating, setIsAnimating] = useState(false); const [isAnimating, setIsAnimating] = useState(false);
const { pushIfAuthedElseLogin } = useAuthGuard(); const { pushIfAuthedElseLogin } = useAuthGuard();
const colorScheme = useColorScheme();
const themeColors = Colors[colorScheme ?? 'light'];
const hasWeight = userProfile?.weight && parseFloat(userProfile.weight) > 0; const hasWeight = userProfile?.weight && parseFloat(userProfile.weight) > 0;
const hasHeight = userProfile?.height && parseFloat(userProfile.height) > 0;
// BMI 计算
const canCalculate = canCalculateBMI(
userProfile?.weight ? parseFloat(userProfile.weight) : undefined,
userProfile?.height ? parseFloat(userProfile.height) : undefined
);
const bmiResult = canCalculate && userProfile?.weight && userProfile?.height
? getBMIResult(parseFloat(userProfile.weight), parseFloat(userProfile.height))
: null;
useEffect(() => { useEffect(() => {
if (hasWeight) { if (hasWeight) {
@@ -67,6 +86,14 @@ export function WeightHistoryCard() {
pushIfAuthedElseLogin(ROUTES.TAB_COACH); pushIfAuthedElseLogin(ROUTES.TAB_COACH);
}; };
const handleShowBMIModal = () => {
setShowBMIModal(true);
};
const handleHideBMIModal = () => {
setShowBMIModal(false);
};
// 切换图表显示状态的动画函数 // 切换图表显示状态的动画函数
const toggleChart = () => { const toggleChart = () => {
if (isAnimating) return; // 防止动画期间重复触发 if (isAnimating) return; // 防止动画期间重复触发
@@ -314,9 +341,26 @@ export function WeightHistoryCard() {
<View style={styles.summaryItem}> <View style={styles.summaryItem}>
<Text style={styles.summaryLabel}></Text> <Text style={styles.summaryLabel}></Text>
<Text style={styles.summaryValue}> <Text style={styles.summaryValue}>
{minWeight.toFixed(1)}-{maxWeight.toFixed(1)}kg {minWeight.toFixed(1)}-{maxWeight.toFixed(1)}
</Text> </Text>
</View> </View>
{bmiResult && (
<View style={styles.summaryItem}>
<Text style={styles.summaryLabel}>BMI</Text>
<View style={styles.bmiValueContainer}>
<Text style={[styles.bmiValue, { color: bmiResult.color }]}>
{bmiResult.value}
</Text>
<TouchableOpacity
onPress={handleShowBMIModal}
style={styles.bmiInfoButton}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="information-circle-outline" size={12} color="#9AA3AE" />
</TouchableOpacity>
</View>
</View>
)}
</View> </View>
</Animated.View> </Animated.View>
@@ -418,6 +462,107 @@ export function WeightHistoryCard() {
</Animated.View> </Animated.View>
</Animated.View> </Animated.View>
)} )}
{/* BMI 信息弹窗 */}
<Modal
visible={showBMIModal}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={handleHideBMIModal}
>
<LinearGradient
colors={[themeColors.backgroundGradientStart, themeColors.backgroundGradientEnd]}
style={styles.bmiModalContainer}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
>
<ScrollView style={styles.bmiModalContent} showsVerticalScrollIndicator={false}>
{/* 标题 */}
<Text style={styles.bmiModalTitle}>BMI </Text>
{/* 介绍部分 */}
<View style={styles.bmiModalIntroSection}>
<Text style={styles.bmiModalDescription}>
BMI
</Text>
<View style={styles.bmiModalFormulaContainer}>
<Text style={styles.bmiModalFormulaText}>
(kg) ÷ ²(m)
</Text>
</View>
</View>
{/* BMI 分类标准 */}
<Text style={styles.bmiModalSectionTitle}>BMI </Text>
<View style={styles.bmiModalStatsCard}>
{BMI_CATEGORIES.map((category, index) => {
const colors = [
{ bg: '#FEF3C7', text: '#B45309', border: '#F59E0B' }, // 偏瘦
{ bg: '#E8F5E8', text: Colors.light.accentGreenDark, border: Colors.light.accentGreen }, // 正常
{ bg: '#FEF3C7', text: '#B45309', border: '#F59E0B' }, // 超重
{ bg: '#FEE2E2', text: '#B91C1C', border: '#EF4444' } // 肥胖
][index];
return (
<View key={index} style={[styles.bmiModalStatItem, { backgroundColor: colors.bg, borderColor: colors.border }]}>
<View style={styles.bmiModalStatHeader}>
<Text style={[styles.bmiModalStatTitle, { color: colors.text }]}>
{category.name}
</Text>
<Text style={[styles.bmiModalStatRange, { color: colors.text }]}>
{category.range}
</Text>
</View>
<Text style={[styles.bmiModalStatAdvice, { color: colors.text }]}>
{category.advice}
</Text>
</View>
);
})}
</View>
{/* 健康建议 */}
<Text style={styles.bmiModalSectionTitle}></Text>
<View style={styles.bmiModalHealthTips}>
<View style={styles.bmiModalTipsItem}>
<Ionicons name="nutrition-outline" size={20} color="#3B82F6" />
<Text style={styles.bmiModalTipsText}></Text>
</View>
<View style={styles.bmiModalTipsItem}>
<Ionicons name="walk-outline" size={20} color="#3B82F6" />
<Text style={styles.bmiModalTipsText}>150</Text>
</View>
<View style={styles.bmiModalTipsItem}>
<Ionicons name="moon-outline" size={20} color="#3B82F6" />
<Text style={styles.bmiModalTipsText}>7-9</Text>
</View>
<View style={styles.bmiModalTipsItem}>
<Ionicons name="calendar-outline" size={20} color="#3B82F6" />
<Text style={styles.bmiModalTipsText}></Text>
</View>
</View>
{/* 免责声明 */}
<View style={styles.bmiModalDisclaimer}>
<Ionicons name="information-circle-outline" size={16} color="#6B7280" />
<Text style={styles.bmiModalDisclaimerText}>
BMI
</Text>
</View>
</ScrollView>
{/* 底部继续按钮 */}
<View style={styles.bmiModalBottomContainer}>
<TouchableOpacity style={styles.bmiModalContinueButton} onPress={handleHideBMIModal}>
<View style={styles.bmiModalButtonBackground}>
<Text style={styles.bmiModalButtonText}></Text>
</View>
</TouchableOpacity>
<View style={styles.bmiModalHomeIndicator} />
</View>
</LinearGradient>
</Modal>
</View> </View>
); );
} }
@@ -546,20 +691,178 @@ const styles = StyleSheet.create({
}, },
summaryRow: { summaryRow: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-around', justifyContent: 'space-between',
marginBottom: 0, marginBottom: 0,
flexWrap: 'wrap',
gap: 8,
}, },
summaryItem: { summaryItem: {
alignItems: 'center', alignItems: 'center',
flex: 1,
minWidth: 0,
}, },
summaryLabel: { summaryLabel: {
fontSize: 12, fontSize: 11,
color: '#687076', color: '#687076',
marginBottom: 4, marginBottom: 3,
}, },
summaryValue: { summaryValue: {
fontSize: 14, fontSize: 13,
fontWeight: '700', fontWeight: '700',
color: '#192126', color: '#192126',
}, },
// BMI 相关样式
bmiValueContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 1,
},
bmiValue: {
fontSize: 12,
fontWeight: '700',
},
bmiInfoButton: {
padding: 0,
},
bmiStatusBadge: {
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 8,
},
bmiStatusText: {
fontSize: 10,
fontWeight: '700',
},
// BMI 弹窗样式
bmiModalContainer: {
flex: 1,
},
bmiModalContent: {
flex: 1,
padding: 20,
},
bmiModalTitle: {
fontSize: 28,
fontWeight: '800',
color: '#111827',
textAlign: 'center',
marginBottom: 24,
letterSpacing: -0.5,
},
bmiModalIntroSection: {
marginBottom: 32,
},
bmiModalDescription: {
fontSize: 16,
color: '#374151',
lineHeight: 24,
textAlign: 'center',
marginBottom: 16,
},
bmiModalFormulaContainer: {
backgroundColor: '#F3F4F6',
borderRadius: 12,
padding: 16,
alignItems: 'center',
},
bmiModalFormulaText: {
fontSize: 14,
fontWeight: '600',
color: '#374151',
},
bmiModalSectionTitle: {
fontSize: 20,
fontWeight: '700',
color: '#111827',
marginBottom: 16,
letterSpacing: -0.5,
},
bmiModalStatsCard: {
marginBottom: 32,
},
bmiModalStatItem: {
borderRadius: 12,
padding: 16,
marginBottom: 12,
borderWidth: 1,
},
bmiModalStatHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
bmiModalStatTitle: {
fontSize: 16,
fontWeight: '700',
},
bmiModalStatRange: {
fontSize: 14,
fontWeight: '600',
},
bmiModalStatAdvice: {
fontSize: 14,
lineHeight: 20,
},
bmiModalHealthTips: {
marginBottom: 32,
},
bmiModalTipsItem: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 16,
paddingHorizontal: 16,
paddingVertical: 12,
backgroundColor: '#F8FAFC',
borderRadius: 12,
},
bmiModalTipsText: {
fontSize: 14,
color: '#374151',
marginLeft: 12,
flex: 1,
lineHeight: 20,
},
bmiModalDisclaimer: {
flexDirection: 'row',
alignItems: 'flex-start',
backgroundColor: '#FEF3C7',
borderRadius: 12,
padding: 16,
marginBottom: 20,
},
bmiModalDisclaimerText: {
fontSize: 13,
color: '#B45309',
marginLeft: 8,
flex: 1,
lineHeight: 18,
},
bmiModalBottomContainer: {
padding: 20,
paddingBottom: 34,
},
bmiModalContinueButton: {
marginBottom: 8,
},
bmiModalButtonBackground: {
backgroundColor: '#192126',
borderRadius: 16,
paddingVertical: 16,
alignItems: 'center',
},
bmiModalButtonText: {
fontSize: 16,
fontWeight: '700',
color: '#FFFFFF',
},
bmiModalHomeIndicator: {
height: 5,
backgroundColor: '#D1D5DB',
borderRadius: 3,
alignSelf: 'center',
width: 36,
},
}); });

View File

@@ -52,7 +52,7 @@
<key>NSHealthShareUsageDescription</key> <key>NSHealthShareUsageDescription</key>
<string>应用需要访问您的健康数据(步数与能量消耗)以展示运动统计。</string> <string>应用需要访问您的健康数据(步数与能量消耗)以展示运动统计。</string>
<key>NSHealthUpdateUsageDescription</key> <key>NSHealthUpdateUsageDescription</key>
<string>Allow $(PRODUCT_NAME) to update health info</string> <string>应用需要更新您的健康数据(体重信息)以记录您的健身进度。</string>
<key>NSPhotoLibraryAddUsageDescription</key> <key>NSPhotoLibraryAddUsageDescription</key>
<string>应用需要写入相册以保存拍摄的体态照片(可选)。</string> <string>应用需要写入相册以保存拍摄的体态照片(可选)。</string>
<key>NSPhotoLibraryUsageDescription</key> <key>NSPhotoLibraryUsageDescription</key>

View File

@@ -7,6 +7,7 @@ const PERMISSIONS: HealthKitPermissions = {
read: [ read: [
AppleHealthKit.Constants.Permissions.StepCount, AppleHealthKit.Constants.Permissions.StepCount,
AppleHealthKit.Constants.Permissions.ActiveEnergyBurned, AppleHealthKit.Constants.Permissions.ActiveEnergyBurned,
AppleHealthKit.Constants.Permissions.BasalEnergyBurned,
AppleHealthKit.Constants.Permissions.SleepAnalysis, AppleHealthKit.Constants.Permissions.SleepAnalysis,
AppleHealthKit.Constants.Permissions.HeartRateVariability, AppleHealthKit.Constants.Permissions.HeartRateVariability,
AppleHealthKit.Constants.Permissions.ActivitySummary, AppleHealthKit.Constants.Permissions.ActivitySummary,
@@ -21,6 +22,7 @@ const PERMISSIONS: HealthKitPermissions = {
export type TodayHealthData = { export type TodayHealthData = {
steps: number; steps: number;
activeEnergyBurned: number; // kilocalories activeEnergyBurned: number; // kilocalories
basalEnergyBurned: number; // kilocalories - 基础代谢率
sleepDuration: number; // 睡眠时长(分钟) sleepDuration: number; // 睡眠时长(分钟)
hrv: number | null; // 心率变异性 (ms) hrv: number | null; // 心率变异性 (ms)
// 健身圆环数据 // 健身圆环数据
@@ -73,7 +75,7 @@ export async function fetchHealthDataForDate(date: Date): Promise<TodayHealthDat
console.log('查询选项:', options); console.log('查询选项:', options);
// 并行获取所有健康数据包括ActivitySummary // 并行获取所有健康数据包括ActivitySummary
const [steps, calories, sleepDuration, hrv, activitySummary] = await Promise.all([ const [steps, calories, basalMetabolism, sleepDuration, hrv, activitySummary] = await Promise.all([
// 获取步数 // 获取步数
new Promise<number>((resolve) => { new Promise<number>((resolve) => {
AppleHealthKit.getStepCount({ AppleHealthKit.getStepCount({
@@ -110,6 +112,24 @@ export async function fetchHealthDataForDate(date: Date): Promise<TodayHealthDat
}); });
}), }),
// 获取基础代谢率
new Promise<number>((resolve) => {
AppleHealthKit.getBasalEnergyBurned(options, (err, res) => {
if (err) {
console.error('获取基础代谢失败:', err);
return resolve(0);
}
if (!res || !Array.isArray(res) || res.length === 0) {
console.warn('基础代谢数据为空或格式错误');
return resolve(0);
}
console.log('基础代谢数据:', res);
// 求和该日内的所有记录(单位:千卡)
const total = res.reduce((acc: number, item: any) => acc + (item?.value || 0), 0);
resolve(total);
});
}),
// 获取睡眠时长 // 获取睡眠时长
new Promise<number>((resolve) => { new Promise<number>((resolve) => {
AppleHealthKit.getSleepSamples(options, (err, res) => { AppleHealthKit.getSleepSamples(options, (err, res) => {
@@ -181,11 +201,12 @@ export async function fetchHealthDataForDate(date: Date): Promise<TodayHealthDat
}) })
]); ]);
console.log('指定日期健康数据获取完成:', { steps, calories, sleepDuration, hrv, activitySummary }); console.log('指定日期健康数据获取完成:', { steps, calories, basalMetabolism, sleepDuration, hrv, activitySummary });
return { return {
steps, steps,
activeEnergyBurned: calories, activeEnergyBurned: calories,
basalEnergyBurned: basalMetabolism,
sleepDuration, sleepDuration,
hrv, hrv,
// 健身圆环数据 // 健身圆环数据