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

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

View File

@@ -2,13 +2,18 @@ import { Colors } from '@/constants/Colors';
import { ROUTES } from '@/constants/Routes';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme';
import { fetchWeightHistory } from '@/store/userSlice';
import { BMI_CATEGORIES, canCalculateBMI, getBMIResult } from '@/utils/bmi';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useEffect, useState } from 'react';
import {
Dimensions,
Image,
Modal,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
@@ -37,14 +42,28 @@ export function WeightHistoryCard() {
const weightHistory = useAppSelector((s) => 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() {
<View style={styles.summaryItem}>
<Text style={styles.summaryLabel}></Text>
<Text style={styles.summaryValue}>
{minWeight.toFixed(1)}-{maxWeight.toFixed(1)}kg
{minWeight.toFixed(1)}-{maxWeight.toFixed(1)}
</Text>
</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>
</Animated.View>
@@ -418,6 +462,107 @@ export function WeightHistoryCard() {
</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>
);
}
@@ -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,
},
});