From f10b7a0fb54d37bc62d9d482b0c30ba70022ce61 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Thu, 21 Aug 2025 22:53:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=9F=BA=E7=A1=80?= =?UTF-8?q?=E4=BB=A3=E8=B0=A2=E7=8E=87=E5=8A=9F=E8=83=BD=E5=8F=8A=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在健康数据中引入基础代谢率的读取和展示,支持用户记录健身进度 - 更新统计页面,替换BMI卡片为基础代谢卡片,提升用户体验 - 优化健康数据获取逻辑,确保基础代谢数据的准确性 - 更新权限描述,明确应用对健康数据的访问需求 --- app.json | 3 +- app/(tabs)/statistics.tsx | 25 ++- components/BasalMetabolismCard.tsx | 188 +++++++++++++++++ components/StressMeter.tsx | 16 +- components/WeightHistoryCard.tsx | 313 ++++++++++++++++++++++++++++- ios/digitalpilates/Info.plist | 2 +- utils/health.ts | 25 ++- 7 files changed, 538 insertions(+), 34 deletions(-) create mode 100644 components/BasalMetabolismCard.tsx diff --git a/app.json b/app.json index bbcbfef..e3acc84 100644 --- a/app.json +++ b/app.json @@ -47,7 +47,8 @@ "react-native-health", { "enableHealthAPI": true, - "healthSharePermission": "应用需要访问您的健康数据(步数与能量消耗)以展示运动统计。" + "healthSharePermission": "应用需要访问您的健康数据(步数与能量消耗)以展示运动统计。", + "healthUpdatePermission": "应用需要更新您的健康数据(体重信息)以记录您的健身进度。" } ] ], diff --git a/app/(tabs)/statistics.tsx b/app/(tabs)/statistics.tsx index 151784e..5e24f75 100644 --- a/app/(tabs)/statistics.tsx +++ b/app/(tabs)/statistics.tsx @@ -1,5 +1,5 @@ import { AnimatedNumber } from '@/components/AnimatedNumber'; -import { BMICard } from '@/components/BMICard'; +import { BasalMetabolismCard } from '@/components/BasalMetabolismCard'; import { FitnessRingsCard } from '@/components/FitnessRingsCard'; import { MoodCard } from '@/components/MoodCard'; import { NutritionRadarCard } from '@/components/NutritionRadarCard'; @@ -140,6 +140,8 @@ export default function ExploreScreen() { // HealthKit: 每次页面聚焦都拉取今日数据 const [stepCount, setStepCount] = useState(null); const [activeCalories, setActiveCalories] = useState(null); + // 基础代谢率(千卡) + const [basalMetabolism, setBasalMetabolism] = useState(null); // 睡眠时长(分钟) const [sleepDuration, setSleepDuration] = useState(null); // HRV数据 @@ -235,6 +237,7 @@ export default function ExploreScreen() { if (latestRequestKeyRef.current === requestKey) { setStepCount(data.steps); setActiveCalories(Math.round(data.activeEnergyBurned)); + setBasalMetabolism(Math.round(data.basalEnergyBurned)); setSleepDuration(data.sleepDuration); // 更新健身圆环数据 setFitnessRingsData({ @@ -455,14 +458,6 @@ export default function ExploreScreen() { {/* 右列 */} - - - - - + 睡眠 @@ -487,6 +482,14 @@ export default function ExploreScreen() { )} + {/* 基础代谢卡片 */} + + + @@ -823,7 +826,7 @@ const styles = StyleSheet.create({ shadowRadius: 12, elevation: 6, }, - bmiCardOverride: { + basalMetabolismCardOverride: { margin: -16, // 抵消 masonryCard 的 padding borderRadius: 16, }, diff --git a/components/BasalMetabolismCard.tsx b/components/BasalMetabolismCard.tsx new file mode 100644 index 0000000..e84c582 --- /dev/null +++ b/components/BasalMetabolismCard.tsx @@ -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 ( + + {/* 渐变背景 */} + + + {/* 装饰性圆圈 */} + + + + {/* 头部区域 */} + + + + 基础代谢 + + + {status.text} + + + + {/* 数值显示区域 */} + + {value != null && value > 0 ? ( + Math.round(v).toString()} + /> + ) : ( + -- + )} + 千卡/日 + + + ); +} + +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, + }, +}); diff --git a/components/StressMeter.tsx b/components/StressMeter.tsx index a5066eb..73c4dc7 100644 --- a/components/StressMeter.tsx +++ b/components/StressMeter.tsx @@ -83,7 +83,7 @@ export function StressMeter({ value, updateTime, style, hrvValue }: StressMeterP {/* 渐变背景进度条 */} s.user.weightHistory); const [isLoading, setIsLoading] = useState(false); const [showChart, setShowChart] = useState(false); + const [showBMIModal, setShowBMIModal] = useState(false); // 动画相关状态 const animationProgress = useSharedValue(0); const [isAnimating, setIsAnimating] = useState(false); const { pushIfAuthedElseLogin } = useAuthGuard(); + const colorScheme = useColorScheme(); + const themeColors = Colors[colorScheme ?? 'light']; 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(() => { if (hasWeight) { @@ -67,6 +86,14 @@ export function WeightHistoryCard() { pushIfAuthedElseLogin(ROUTES.TAB_COACH); }; + const handleShowBMIModal = () => { + setShowBMIModal(true); + }; + + const handleHideBMIModal = () => { + setShowBMIModal(false); + }; + // 切换图表显示状态的动画函数 const toggleChart = () => { if (isAnimating) return; // 防止动画期间重复触发 @@ -314,9 +341,26 @@ export function WeightHistoryCard() { 变化范围 - {minWeight.toFixed(1)}-{maxWeight.toFixed(1)}kg + {minWeight.toFixed(1)}-{maxWeight.toFixed(1)} + {bmiResult && ( + + BMI + + + {bmiResult.value} + + + + + + + )} @@ -418,6 +462,107 @@ export function WeightHistoryCard() { )} + + {/* BMI 信息弹窗 */} + + + + {/* 标题 */} + BMI 指数说明 + + {/* 介绍部分 */} + + + BMI(身体质量指数)是评估体重与身高关系的国际通用健康指标 + + + + 计算公式:体重(kg) ÷ 身高²(m) + + + + + {/* BMI 分类标准 */} + BMI 分类标准 + + + {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 ( + + + + {category.name} + + + {category.range} + + + + {category.advice} + + + ); + })} + + + {/* 健康建议 */} + 健康建议 + + + + 保持均衡饮食,控制热量摄入 + + + + 每周至少150分钟中等强度运动 + + + + 保证7-9小时充足睡眠 + + + + 定期监测体重变化,及时调整 + + + + {/* 免责声明 */} + + + + BMI 仅供参考,不能反映肌肉量、骨密度等指标。如有健康疑问,请咨询专业医生。 + + + + + {/* 底部继续按钮 */} + + + + 继续 + + + + + + ); } @@ -546,20 +691,178 @@ const styles = StyleSheet.create({ }, summaryRow: { flexDirection: 'row', - justifyContent: 'space-around', + justifyContent: 'space-between', marginBottom: 0, + flexWrap: 'wrap', + gap: 8, }, summaryItem: { alignItems: 'center', + flex: 1, + minWidth: 0, }, summaryLabel: { - fontSize: 12, + fontSize: 11, color: '#687076', - marginBottom: 4, + marginBottom: 3, }, summaryValue: { - fontSize: 14, + fontSize: 13, fontWeight: '700', 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, + }, }); diff --git a/ios/digitalpilates/Info.plist b/ios/digitalpilates/Info.plist index 6fc1c63..4155f96 100644 --- a/ios/digitalpilates/Info.plist +++ b/ios/digitalpilates/Info.plist @@ -52,7 +52,7 @@ NSHealthShareUsageDescription 应用需要访问您的健康数据(步数与能量消耗)以展示运动统计。 NSHealthUpdateUsageDescription - Allow $(PRODUCT_NAME) to update health info + 应用需要更新您的健康数据(体重信息)以记录您的健身进度。 NSPhotoLibraryAddUsageDescription 应用需要写入相册以保存拍摄的体态照片(可选)。 NSPhotoLibraryUsageDescription diff --git a/utils/health.ts b/utils/health.ts index be9abb5..357d974 100644 --- a/utils/health.ts +++ b/utils/health.ts @@ -7,6 +7,7 @@ const PERMISSIONS: HealthKitPermissions = { read: [ AppleHealthKit.Constants.Permissions.StepCount, AppleHealthKit.Constants.Permissions.ActiveEnergyBurned, + AppleHealthKit.Constants.Permissions.BasalEnergyBurned, AppleHealthKit.Constants.Permissions.SleepAnalysis, AppleHealthKit.Constants.Permissions.HeartRateVariability, AppleHealthKit.Constants.Permissions.ActivitySummary, @@ -21,6 +22,7 @@ const PERMISSIONS: HealthKitPermissions = { export type TodayHealthData = { steps: number; activeEnergyBurned: number; // kilocalories + basalEnergyBurned: number; // kilocalories - 基础代谢率 sleepDuration: number; // 睡眠时长(分钟) hrv: number | null; // 心率变异性 (ms) // 健身圆环数据 @@ -73,7 +75,7 @@ export async function fetchHealthDataForDate(date: Date): Promise((resolve) => { AppleHealthKit.getStepCount({ @@ -110,6 +112,24 @@ export async function fetchHealthDataForDate(date: Date): Promise((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((resolve) => { AppleHealthKit.getSleepSamples(options, (err, res) => { @@ -181,11 +201,12 @@ export async function fetchHealthDataForDate(date: Date): Promise