feat: 新增饮食方案卡片及相关计算功能

- 在教练页面中新增饮食方案卡片,展示用户的饮食计划和相关数据
- 实现BMI计算、每日推荐摄入热量计算及营养素分配功能
- 优化体重历史记录卡片,增加动画效果提升用户体验
- 更新统计页面样式,增加体重卡片样式和按钮功能
- 修复获取HRV值的逻辑,确保数据准确性
This commit is contained in:
richarjiang
2025-08-20 19:10:04 +08:00
parent 1c44c3083b
commit 3d47073d2f
4 changed files with 641 additions and 116 deletions

View File

@@ -109,6 +109,7 @@ const CardType = {
WEIGHT_INPUT: '__WEIGHT_INPUT_CARD__',
DIET_INPUT: '__DIET_INPUT_CARD__',
DIET_TEXT_INPUT: '__DIET_TEXT_INPUT__',
DIET_PLAN: '__DIET_PLAN_CARD__',
} as const;
type CardType = typeof CardType[keyof typeof CardType];
@@ -279,6 +280,7 @@ export default function CoachScreen() {
// { key: 'analyze', label: '分析运动记录', action: () => handleAnalyzeRecords() },
{ key: 'weight', label: '#记体重', action: () => insertWeightInputCard() },
{ key: 'diet', label: '#记饮食', action: () => insertDietInputCard() },
{ key: 'dietPlan', label: '#饮食方案', action: () => insertDietPlanCard() },
], [router, planDraft, checkin]);
const scrollToEnd = useCallback(() => {
@@ -1281,6 +1283,167 @@ export default function CoachScreen() {
);
}
if (item.content?.startsWith(CardType.DIET_PLAN)) {
const cardId = item.content.split('\n')?.[1] || '';
// 获取用户数据
const weight = userProfile?.weight ? Number(userProfile.weight) : 58;
const height = userProfile?.height ? Number(userProfile.height) : 160;
// 计算年龄
const calculateAge = (birthday?: string): number => {
if (!birthday) return 25; // 默认年龄
try {
const birthDate = new Date(birthday);
const today = new Date();
let age = today.getFullYear() - birthDate.getFullYear();
const monthDiff = today.getMonth() - birthDate.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
age--;
}
return age > 0 ? age : 25;
} catch {
return 25;
}
};
const age = calculateAge(userProfile?.birthDate);
const gender = userProfile?.gender || 'female';
const name = userProfile?.name || '用户';
// 计算相关数据
const bmi = calculateBMI(weight, height);
const bmiStatus = getBMIStatus(bmi);
const dailyCalories = calculateDailyCalories(weight, height, age, gender);
const nutrition = calculateNutritionDistribution(dailyCalories);
return (
<View style={styles.dietPlanContainer}>
{/* 标题部分 */}
<View style={styles.dietPlanHeader}>
<View style={styles.dietPlanTitleContainer}>
<Ionicons name="restaurant-outline" size={20} color={Colors.light.accentGreenDark} />
<Text style={styles.dietPlanTitle}></Text>
</View>
<Text style={styles.dietPlanSubtitle}>MY DIET PLAN</Text>
</View>
{/* 我的档案数据 */}
<View style={styles.profileSection}>
<Text style={styles.sectionTitle}></Text>
<View style={styles.profileDataRow}>
<View style={styles.avatarContainer}>
<View style={styles.avatar}>
<Text style={styles.avatarText}>{name.charAt(0)}</Text>
</View>
</View>
<View style={styles.profileStats}>
<View style={styles.statItem}>
<Text style={styles.statValue}>{age}</Text>
<Text style={styles.statLabel}>/</Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>{height.toFixed(1)}</Text>
<Text style={styles.statLabel}>/CM</Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>{weight.toFixed(1)}</Text>
<Text style={styles.statLabel}>/KG</Text>
</View>
</View>
</View>
</View>
{/* BMI部分 */}
<View style={styles.bmiSection}>
<View style={styles.bmiHeader}>
<Text style={styles.sectionTitle}>BMI</Text>
<View style={[styles.bmiStatusBadge, { backgroundColor: bmiStatus.color }]}>
<Text style={styles.bmiStatusText}>{bmiStatus.status}</Text>
</View>
</View>
<Text style={styles.bmiValue}>{bmi.toFixed(1)}</Text>
<View style={styles.bmiScale}>
<View style={[styles.bmiBar, { backgroundColor: '#87CEEB' }]} />
<View style={[styles.bmiBar, { backgroundColor: '#90EE90' }]} />
<View style={[styles.bmiBar, { backgroundColor: '#FFD700' }]} />
<View style={[styles.bmiBar, { backgroundColor: '#FFA07A' }]} />
</View>
<View style={styles.bmiLabels}>
<Text style={styles.bmiLabel}></Text>
<Text style={styles.bmiLabel}></Text>
<Text style={styles.bmiLabel}></Text>
<Text style={styles.bmiLabel}></Text>
</View>
</View>
{/* 饮食目标 */}
<View style={styles.collapsibleSection}>
<View style={styles.collapsibleHeader}>
<Text style={styles.sectionTitle}></Text>
<Ionicons name="chevron-down" size={16} color="#687076" />
</View>
</View>
{/* 目标体重 */}
<View style={styles.collapsibleSection}>
<View style={styles.collapsibleHeader}>
<Text style={styles.sectionTitle}></Text>
<Ionicons name="chevron-down" size={16} color="#687076" />
</View>
</View>
{/* 每日推荐摄入 */}
<View style={styles.caloriesSection}>
<View style={styles.caloriesHeader}>
<Text style={styles.sectionTitle}></Text>
<Text style={styles.caloriesValue}>{dailyCalories}</Text>
</View>
<View style={styles.nutritionGrid}>
<View style={styles.nutritionItem}>
<Text style={styles.nutritionValue}>{nutrition.carbs}g</Text>
<View style={styles.nutritionLabelRow}>
<Ionicons name="nutrition-outline" size={16} color="#687076" />
<Text style={styles.nutritionLabel}></Text>
</View>
</View>
<View style={styles.nutritionItem}>
<Text style={styles.nutritionValue}>{nutrition.protein}g</Text>
<View style={styles.nutritionLabelRow}>
<Ionicons name="fitness-outline" size={16} color="#687076" />
<Text style={styles.nutritionLabel}></Text>
</View>
</View>
<View style={styles.nutritionItem}>
<Text style={styles.nutritionValue}>{nutrition.fat}g</Text>
<View style={styles.nutritionLabelRow}>
<Ionicons name="water-outline" size={16} color="#687076" />
<Text style={styles.nutritionLabel}></Text>
</View>
</View>
</View>
<Text style={styles.nutritionNote}>
</Text>
</View>
{/* 底部按钮 */}
<TouchableOpacity
style={styles.dietPlanButton}
onPress={() => {
// 这里可以添加跳转到详细饮食方案页面的逻辑
console.log('跳转到饮食方案详情');
}}
>
<Ionicons name="restaurant" size={16} color="#FFFFFF" />
<Text style={styles.dietPlanButtonText}></Text>
</TouchableOpacity>
</View>
);
}
// 在流式回复过程中显示取消按钮
if (isStreaming && pendingAssistantIdRef.current === item.id && item.content?.trim()) {
return (
@@ -1380,6 +1543,58 @@ export default function CoachScreen() {
setTimeout(scrollToEnd, 100);
}
function insertDietPlanCard() {
const id = `dpcard_${Date.now()}`;
const payload = `${CardType.DIET_PLAN}\n${id}`;
setMessages((prev) => [...prev, { id, role: 'assistant', content: payload }]);
setTimeout(scrollToEnd, 100);
}
// 计算BMI
function calculateBMI(weight: number, height: number): number {
if (!weight || !height || weight <= 0 || height <= 0) return 0;
const heightInMeters = height / 100;
return Number((weight / (heightInMeters * heightInMeters)).toFixed(1));
}
// 获取BMI状态
function getBMIStatus(bmi: number): { status: string; color: string } {
if (bmi < 18.5) return { status: '偏瘦', color: '#87CEEB' };
if (bmi < 24) return { status: '正常', color: '#90EE90' };
if (bmi < 28) return { status: '偏胖', color: '#FFD700' };
return { status: '肥胖', color: '#FFA07A' };
}
// 计算每日推荐摄入热量
function calculateDailyCalories(weight: number, height: number, age: number, gender: string = 'female'): number {
if (!weight || !height || !age) return 1376; // 默认值
// 使用Harris-Benedict公式计算基础代谢率
let bmr: number;
if (gender === 'male') {
bmr = 88.362 + (13.397 * weight) + (4.799 * height) - (5.677 * age);
} else {
bmr = 447.593 + (9.247 * weight) + (3.098 * height) - (4.330 * age);
}
// 考虑活动水平这里使用轻度活动的系数1.375
return Math.round(bmr * 1.375);
}
// 计算营养素分配
function calculateNutritionDistribution(calories: number) {
// 碳水化合物 50%,蛋白质 15%,脂肪 35%
const carbCalories = calories * 0.5;
const proteinCalories = calories * 0.15;
const fatCalories = calories * 0.35;
return {
carbs: Math.round(carbCalories / 4), // 1g碳水 = 4卡路里
protein: Math.round(proteinCalories / 4), // 1g蛋白质 = 4卡路里
fat: Math.round(fatCalories / 9), // 1g脂肪 = 9卡路里
};
}
async function handleSubmitWeight(text?: string, cardId?: string) {
const val = parseFloat(String(text ?? '').trim());
if (isNaN(val) || val <= 0 || val > 500) {
@@ -2419,6 +2634,181 @@ const styles = StyleSheet.create({
top: 0,
bottom: 0,
},
// 饮食方案卡片样式
dietPlanContainer: {
backgroundColor: 'rgba(255,255,255,0.95)',
borderRadius: 16,
padding: 16,
gap: 16,
borderWidth: 1,
borderColor: `${Colors.light.accentGreen}33`, // 20% opacity
},
dietPlanHeader: {
gap: 4,
},
dietPlanTitleContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
dietPlanTitle: {
fontSize: 18,
fontWeight: '800',
color: '#192126',
},
dietPlanSubtitle: {
fontSize: 12,
fontWeight: '600',
color: '#687076',
letterSpacing: 1,
},
profileSection: {
gap: 12,
},
sectionTitle: {
fontSize: 14,
fontWeight: '700',
color: '#192126',
},
profileDataRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 16,
},
avatarContainer: {
alignItems: 'center',
},
profileStats: {
flexDirection: 'row',
flex: 1,
justifyContent: 'space-around',
},
statItem: {
alignItems: 'center',
gap: 4,
},
statValue: {
fontSize: 20,
fontWeight: '800',
color: '#192126',
},
statLabel: {
fontSize: 12,
color: '#687076',
},
bmiSection: {
gap: 12,
},
bmiHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
bmiStatusBadge: {
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 12,
},
bmiStatusText: {
fontSize: 12,
fontWeight: '700',
color: '#FFFFFF',
},
bmiValue: {
fontSize: 32,
fontWeight: '800',
color: '#192126',
textAlign: 'center',
},
bmiScale: {
flexDirection: 'row',
height: 8,
borderRadius: 4,
overflow: 'hidden',
gap: 1,
},
bmiBar: {
flex: 1,
height: '100%',
},
bmiLabels: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingHorizontal: 4,
},
bmiLabel: {
fontSize: 11,
color: '#687076',
},
collapsibleSection: {
paddingVertical: 8,
borderBottomWidth: 1,
borderBottomColor: 'rgba(0,0,0,0.06)',
},
collapsibleHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
caloriesSection: {
gap: 12,
},
caloriesHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
caloriesValue: {
fontSize: 18,
fontWeight: '800',
color: Colors.light.accentGreenDark,
},
nutritionGrid: {
flexDirection: 'row',
justifyContent: 'space-around',
gap: 16,
},
nutritionItem: {
alignItems: 'center',
gap: 8,
},
nutritionValue: {
fontSize: 24,
fontWeight: '800',
color: '#192126',
},
nutritionLabelRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
nutritionLabel: {
fontSize: 12,
color: '#687076',
},
nutritionNote: {
fontSize: 12,
color: '#687076',
lineHeight: 16,
textAlign: 'center',
marginTop: 8,
},
dietPlanButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
backgroundColor: Colors.light.accentGreenDark,
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 12,
marginTop: 8,
},
dietPlanButtonText: {
fontSize: 14,
fontWeight: '700',
color: '#FFFFFF',
},
});
const markdownStyles = {

View File

@@ -718,4 +718,19 @@ const styles = StyleSheet.create({
fontWeight: '700',
marginTop: 8,
},
weightCard: {
backgroundColor: '#F0F9FF',
},
weightValue: {
fontSize: 22,
color: '#0369A1',
fontWeight: '800',
marginTop: 8,
},
addWeightButton: {
position: 'absolute',
right: 0,
top: 0,
padding: 4,
},
});

View File

@@ -13,6 +13,14 @@ import {
TouchableOpacity,
View,
} from 'react-native';
import Animated, {
Extrapolation,
interpolate,
runOnJS,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import Svg, { Circle, Line, Path, Text as SvgText } from 'react-native-svg';
const { width: screenWidth } = Dimensions.get('window');
@@ -29,6 +37,10 @@ export function WeightHistoryCard() {
const [isLoading, setIsLoading] = useState(false);
const [showChart, setShowChart] = useState(false);
// 动画相关状态
const animationProgress = useSharedValue(0);
const [isAnimating, setIsAnimating] = useState(false);
const { pushIfAuthedElseLogin } = useAuthGuard();
const hasWeight = userProfile?.weight && parseFloat(userProfile.weight) > 0;
@@ -54,6 +66,104 @@ export function WeightHistoryCard() {
pushIfAuthedElseLogin(ROUTES.TAB_COACH);
};
// 切换图表显示状态的动画函数
const toggleChart = () => {
if (isAnimating) return; // 防止动画期间重复触发
setIsAnimating(true);
const newShowChart = !showChart;
setShowChart(newShowChart);
animationProgress.value = withTiming(
newShowChart ? 1 : 0,
{
duration: 350,
},
(finished) => {
if (finished) {
runOnJS(setIsAnimating)(false);
}
}
);
};
// 动画容器的高度动画
const containerAnimatedStyle = useAnimatedStyle(() => {
// 只有在展开状态时才应用固定高度
if (animationProgress.value === 0) {
return {};
}
const height = interpolate(
animationProgress.value,
[0, 1],
[40, 200], // 从摘要高度到图表高度
Extrapolation.CLAMP
);
return {
height,
};
});
// 摘要信息的动画样式
const summaryAnimatedStyle = useAnimatedStyle(() => {
const opacity = interpolate(
animationProgress.value,
[0, 0.4, 1],
[1, 0.2, 0],
Extrapolation.CLAMP
);
const scale = interpolate(
animationProgress.value,
[0, 1],
[1, 0.9],
Extrapolation.CLAMP
);
const translateY = interpolate(
animationProgress.value,
[0, 1],
[0, -20],
Extrapolation.CLAMP
);
return {
opacity,
transform: [{ scale }, { translateY }],
};
});
// 图表容器的动画样式
const chartAnimatedStyle = useAnimatedStyle(() => {
const opacity = interpolate(
animationProgress.value,
[0, 0.6, 1],
[0, 0.2, 1],
Extrapolation.CLAMP
);
const scale = interpolate(
animationProgress.value,
[0, 1],
[0.9, 1],
Extrapolation.CLAMP
);
const translateY = interpolate(
animationProgress.value,
[0, 1],
[20, 0],
Extrapolation.CLAMP
);
return {
opacity,
transform: [{ scale }, { translateY }],
};
});
// 如果正在加载,显示加载状态
if (isLoading) {
return (
@@ -167,7 +277,7 @@ export function WeightHistoryCard() {
<View style={styles.headerButtons}>
<TouchableOpacity
style={styles.chartToggleButton}
onPress={() => setShowChart(!showChart)}
onPress={toggleChart}
activeOpacity={0.8}
>
<Ionicons
@@ -186,9 +296,11 @@ export function WeightHistoryCard() {
</View>
</View>
{/* 默认信息显示 */}
{!showChart && sortedHistory.length > 0 && (
<View style={styles.summaryInfo}>
{/* 动画容器 */}
{sortedHistory.length > 0 && (
<Animated.View style={[styles.animationContainer, containerAnimatedStyle]}>
{/* 默认信息显示 - 带动画 */}
<Animated.View style={[styles.summaryInfo, summaryAnimatedStyle]}>
<View style={styles.summaryRow}>
<View style={styles.summaryItem}>
<Text style={styles.summaryLabel}></Text>
@@ -205,12 +317,10 @@ export function WeightHistoryCard() {
</Text>
</View>
</View>
</View>
)}
</Animated.View>
{/* 图表容器 - 可折叠 */}
{showChart && (
<View style={styles.chartContainer}>
{/* 图表容器 - 带动画 */}
<Animated.View style={[styles.chartContainer, chartAnimatedStyle]}>
<Svg width={CHART_WIDTH} height={CHART_HEIGHT + 15}>
{/* 背景网格线 */}
{[0, 1, 2, 3, 4].map(i => (
@@ -304,7 +414,8 @@ export function WeightHistoryCard() {
{dayjs(sortedHistory[sortedHistory.length - 1].createdAt).format('MM/DD HH:mm')}
</Text>
)}
</View>
</Animated.View>
</Animated.View>
)}
</View>
);
@@ -315,7 +426,7 @@ const styles = StyleSheet.create({
backgroundColor: '#FFFFFF',
borderRadius: 22,
padding: 18,
marginBottom: 16,
marginBottom: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
@@ -390,7 +501,18 @@ const styles = StyleSheet.create({
fontSize: 14,
fontWeight: '700',
},
animationContainer: {
position: 'relative',
overflow: 'hidden',
minHeight: 40,
},
summaryInfo: {
position: 'absolute',
width: '100%',
},
chartContainer: {
position: 'absolute',
width: '100%',
alignItems: 'center',
minHeight: 100,
},
@@ -419,14 +541,12 @@ const styles = StyleSheet.create({
fontSize: 12,
color: '#687076',
textAlign: 'center',
marginTop: 8,
},
summaryInfo: {
marginTop: 4,
},
summaryRow: {
flexDirection: 'row',
justifyContent: 'space-around',
marginBottom: 6,
marginBottom: 0,
},
summaryItem: {
alignItems: 'center',

View File

@@ -139,7 +139,7 @@ export async function fetchHealthDataForDate(date: Date): Promise<TodayHealthDat
// 获取最新的HRV值
const latestHrv = res[res.length - 1];
if (latestHrv && latestHrv.value) {
resolve(latestHrv.value);
resolve(Math.round(latestHrv.value * 1000));
} else {
resolve(null);
}