feat: 新增饮食方案卡片及相关计算功能
- 在教练页面中新增饮食方案卡片,展示用户的饮食计划和相关数据 - 实现BMI计算、每日推荐摄入热量计算及营养素分配功能 - 优化体重历史记录卡片,增加动画效果提升用户体验 - 更新统计页面样式,增加体重卡片样式和按钮功能 - 修复获取HRV值的逻辑,确保数据准确性
This commit is contained in:
@@ -109,6 +109,7 @@ const CardType = {
|
|||||||
WEIGHT_INPUT: '__WEIGHT_INPUT_CARD__',
|
WEIGHT_INPUT: '__WEIGHT_INPUT_CARD__',
|
||||||
DIET_INPUT: '__DIET_INPUT_CARD__',
|
DIET_INPUT: '__DIET_INPUT_CARD__',
|
||||||
DIET_TEXT_INPUT: '__DIET_TEXT_INPUT__',
|
DIET_TEXT_INPUT: '__DIET_TEXT_INPUT__',
|
||||||
|
DIET_PLAN: '__DIET_PLAN_CARD__',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
type CardType = typeof CardType[keyof typeof CardType];
|
type CardType = typeof CardType[keyof typeof CardType];
|
||||||
@@ -279,6 +280,7 @@ export default function CoachScreen() {
|
|||||||
// { key: 'analyze', label: '分析运动记录', action: () => handleAnalyzeRecords() },
|
// { key: 'analyze', label: '分析运动记录', action: () => handleAnalyzeRecords() },
|
||||||
{ key: 'weight', label: '#记体重', action: () => insertWeightInputCard() },
|
{ key: 'weight', label: '#记体重', action: () => insertWeightInputCard() },
|
||||||
{ key: 'diet', label: '#记饮食', action: () => insertDietInputCard() },
|
{ key: 'diet', label: '#记饮食', action: () => insertDietInputCard() },
|
||||||
|
{ key: 'dietPlan', label: '#饮食方案', action: () => insertDietPlanCard() },
|
||||||
], [router, planDraft, checkin]);
|
], [router, planDraft, checkin]);
|
||||||
|
|
||||||
const scrollToEnd = useCallback(() => {
|
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()) {
|
if (isStreaming && pendingAssistantIdRef.current === item.id && item.content?.trim()) {
|
||||||
return (
|
return (
|
||||||
@@ -1380,6 +1543,58 @@ export default function CoachScreen() {
|
|||||||
setTimeout(scrollToEnd, 100);
|
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) {
|
async function handleSubmitWeight(text?: string, cardId?: string) {
|
||||||
const val = parseFloat(String(text ?? '').trim());
|
const val = parseFloat(String(text ?? '').trim());
|
||||||
if (isNaN(val) || val <= 0 || val > 500) {
|
if (isNaN(val) || val <= 0 || val > 500) {
|
||||||
@@ -2419,6 +2634,181 @@ const styles = StyleSheet.create({
|
|||||||
top: 0,
|
top: 0,
|
||||||
bottom: 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 = {
|
const markdownStyles = {
|
||||||
|
|||||||
@@ -718,4 +718,19 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
},
|
},
|
||||||
|
weightCard: {
|
||||||
|
backgroundColor: '#F0F9FF',
|
||||||
|
},
|
||||||
|
weightValue: {
|
||||||
|
fontSize: 22,
|
||||||
|
color: '#0369A1',
|
||||||
|
fontWeight: '800',
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
addWeightButton: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} 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';
|
import Svg, { Circle, Line, Path, Text as SvgText } from 'react-native-svg';
|
||||||
|
|
||||||
const { width: screenWidth } = Dimensions.get('window');
|
const { width: screenWidth } = Dimensions.get('window');
|
||||||
@@ -29,6 +37,10 @@ export function WeightHistoryCard() {
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [showChart, setShowChart] = useState(false);
|
const [showChart, setShowChart] = useState(false);
|
||||||
|
|
||||||
|
// 动画相关状态
|
||||||
|
const animationProgress = useSharedValue(0);
|
||||||
|
const [isAnimating, setIsAnimating] = useState(false);
|
||||||
|
|
||||||
const { pushIfAuthedElseLogin } = useAuthGuard();
|
const { pushIfAuthedElseLogin } = useAuthGuard();
|
||||||
|
|
||||||
const hasWeight = userProfile?.weight && parseFloat(userProfile.weight) > 0;
|
const hasWeight = userProfile?.weight && parseFloat(userProfile.weight) > 0;
|
||||||
@@ -54,6 +66,104 @@ export function WeightHistoryCard() {
|
|||||||
pushIfAuthedElseLogin(ROUTES.TAB_COACH);
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -167,7 +277,7 @@ export function WeightHistoryCard() {
|
|||||||
<View style={styles.headerButtons}>
|
<View style={styles.headerButtons}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.chartToggleButton}
|
style={styles.chartToggleButton}
|
||||||
onPress={() => setShowChart(!showChart)}
|
onPress={toggleChart}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
@@ -186,9 +296,11 @@ export function WeightHistoryCard() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 默认信息显示 */}
|
{/* 动画容器 */}
|
||||||
{!showChart && sortedHistory.length > 0 && (
|
{sortedHistory.length > 0 && (
|
||||||
<View style={styles.summaryInfo}>
|
<Animated.View style={[styles.animationContainer, containerAnimatedStyle]}>
|
||||||
|
{/* 默认信息显示 - 带动画 */}
|
||||||
|
<Animated.View style={[styles.summaryInfo, summaryAnimatedStyle]}>
|
||||||
<View style={styles.summaryRow}>
|
<View style={styles.summaryRow}>
|
||||||
<View style={styles.summaryItem}>
|
<View style={styles.summaryItem}>
|
||||||
<Text style={styles.summaryLabel}>当前体重</Text>
|
<Text style={styles.summaryLabel}>当前体重</Text>
|
||||||
@@ -205,12 +317,10 @@ export function WeightHistoryCard() {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</Animated.View>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 图表容器 - 可折叠 */}
|
{/* 图表容器 - 带动画 */}
|
||||||
{showChart && (
|
<Animated.View style={[styles.chartContainer, chartAnimatedStyle]}>
|
||||||
<View style={styles.chartContainer}>
|
|
||||||
<Svg width={CHART_WIDTH} height={CHART_HEIGHT + 15}>
|
<Svg width={CHART_WIDTH} height={CHART_HEIGHT + 15}>
|
||||||
{/* 背景网格线 */}
|
{/* 背景网格线 */}
|
||||||
{[0, 1, 2, 3, 4].map(i => (
|
{[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')}
|
最近记录:{dayjs(sortedHistory[sortedHistory.length - 1].createdAt).format('MM/DD HH:mm')}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</Animated.View>
|
||||||
|
</Animated.View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -315,7 +426,7 @@ const styles = StyleSheet.create({
|
|||||||
backgroundColor: '#FFFFFF',
|
backgroundColor: '#FFFFFF',
|
||||||
borderRadius: 22,
|
borderRadius: 22,
|
||||||
padding: 18,
|
padding: 18,
|
||||||
marginBottom: 16,
|
marginBottom: 8,
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
shadowOffset: { width: 0, height: 2 },
|
shadowOffset: { width: 0, height: 2 },
|
||||||
shadowOpacity: 0.1,
|
shadowOpacity: 0.1,
|
||||||
@@ -390,7 +501,18 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
},
|
},
|
||||||
|
animationContainer: {
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
minHeight: 40,
|
||||||
|
},
|
||||||
|
summaryInfo: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
chartContainer: {
|
chartContainer: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: '100%',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
minHeight: 100,
|
minHeight: 100,
|
||||||
},
|
},
|
||||||
@@ -419,14 +541,12 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: '#687076',
|
color: '#687076',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
marginTop: 8,
|
marginTop: 4,
|
||||||
},
|
|
||||||
summaryInfo: {
|
|
||||||
},
|
},
|
||||||
summaryRow: {
|
summaryRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-around',
|
justifyContent: 'space-around',
|
||||||
marginBottom: 6,
|
marginBottom: 0,
|
||||||
},
|
},
|
||||||
summaryItem: {
|
summaryItem: {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ export async function fetchHealthDataForDate(date: Date): Promise<TodayHealthDat
|
|||||||
// 获取最新的HRV值
|
// 获取最新的HRV值
|
||||||
const latestHrv = res[res.length - 1];
|
const latestHrv = res[res.length - 1];
|
||||||
if (latestHrv && latestHrv.value) {
|
if (latestHrv && latestHrv.value) {
|
||||||
resolve(latestHrv.value);
|
resolve(Math.round(latestHrv.value * 1000));
|
||||||
} else {
|
} else {
|
||||||
resolve(null);
|
resolve(null);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user