diff --git a/app/(tabs)/coach.tsx b/app/(tabs)/coach.tsx index e091f1f..ed909e9 100644 --- a/app/(tabs)/coach.tsx +++ b/app/(tabs)/coach.tsx @@ -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 ( + + {/* 标题部分 */} + + + + 我的饮食方案 + + MY DIET PLAN + + + {/* 我的档案数据 */} + + 我的档案数据 + + + + {name.charAt(0)} + + + + + {age} + 年龄/岁 + + + {height.toFixed(1)} + 身高/CM + + + {weight.toFixed(1)} + 体重/KG + + + + + + {/* BMI部分 */} + + + 当前BMI + + {bmiStatus.status} + + + {bmi.toFixed(1)} + + + + + + + + 偏瘦 + 正常 + 偏胖 + 肥胖 + + + + {/* 饮食目标 */} + + + 饮食目标 + + + + + {/* 目标体重 */} + + + 目标体重 + + + + + {/* 每日推荐摄入 */} + + + 每日推荐摄入 + {dailyCalories}千卡 + + + + + {nutrition.carbs}g + + + 碳水 + + + + {nutrition.protein}g + + + 蛋白质 + + + + {nutrition.fat}g + + + 脂肪 + + + + + + 根据您的基础代谢率,这到理想体重所需要的热量缺口计算得出每日的热量推荐值。 + + + + {/* 底部按钮 */} + { + // 这里可以添加跳转到详细饮食方案页面的逻辑 + console.log('跳转到饮食方案详情'); + }} + > + + 饮食方案 + + + ); + } + // 在流式回复过程中显示取消按钮 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 = { diff --git a/app/(tabs)/statistics.tsx b/app/(tabs)/statistics.tsx index b38e6d6..f30e5dd 100644 --- a/app/(tabs)/statistics.tsx +++ b/app/(tabs)/statistics.tsx @@ -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, + }, }); diff --git a/components/WeightHistoryCard.tsx b/components/WeightHistoryCard.tsx index 834e3d7..781bbe9 100644 --- a/components/WeightHistoryCard.tsx +++ b/components/WeightHistoryCard.tsx @@ -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() { setShowChart(!showChart)} + onPress={toggleChart} activeOpacity={0.8} > - {/* 默认信息显示 */} - {!showChart && sortedHistory.length > 0 && ( - - - - 当前体重 - {userProfile.weight}kg + {/* 动画容器 */} + {sortedHistory.length > 0 && ( + + {/* 默认信息显示 - 带动画 */} + + + + 当前体重 + {userProfile.weight}kg + + + 记录天数 + {sortedHistory.length}天 + + + 变化范围 + + {minWeight.toFixed(1)}-{maxWeight.toFixed(1)}kg + + - - 记录天数 - {sortedHistory.length}天 - - - 变化范围 - - {minWeight.toFixed(1)}-{maxWeight.toFixed(1)}kg - - - - - )} + - {/* 图表容器 - 可折叠 */} - {showChart && ( - - - {/* 背景网格线 */} - {[0, 1, 2, 3, 4].map(i => ( - + + {/* 背景网格线 */} + {[0, 1, 2, 3, 4].map(i => ( + + ))} + + {/* 折线 */} + - ))} - {/* 折线 */} - + {/* 数据点和标签 */} + {points.map((point, index) => { + const isLastPoint = index === points.length - 1; + const isFirstPoint = index === 0; + const showLabel = isFirstPoint || isLastPoint || points.length <= 3 || points.length === 1; - {/* 数据点和标签 */} - {points.map((point, index) => { - const isLastPoint = index === points.length - 1; - const isFirstPoint = index === 0; - const showLabel = isFirstPoint || isLastPoint || points.length <= 3 || points.length === 1; - - return ( - - - {/* 体重标签 - 只在关键点显示 */} - {showLabel && ( - <> - - - {point.weight} - - - )} - - ); - })} + return ( + + + {/* 体重标签 - 只在关键点显示 */} + {showLabel && ( + <> + + + {point.weight} + + + )} + + ); + })} - + - {/* 图表底部信息 */} - - - 当前体重 - {userProfile.weight}kg + {/* 图表底部信息 */} + + + 当前体重 + {userProfile.weight}kg + + + 记录天数 + {sortedHistory.length}天 + + + 变化范围 + + {minWeight.toFixed(1)}-{maxWeight.toFixed(1)}kg + + - - 记录天数 - {sortedHistory.length}天 - - - 变化范围 - - {minWeight.toFixed(1)}-{maxWeight.toFixed(1)}kg + + {/* 最近记录时间 */} + {sortedHistory.length > 0 && ( + + 最近记录:{dayjs(sortedHistory[sortedHistory.length - 1].createdAt).format('MM/DD HH:mm')} - - - - {/* 最近记录时间 */} - {sortedHistory.length > 0 && ( - - 最近记录:{dayjs(sortedHistory[sortedHistory.length - 1].createdAt).format('MM/DD HH:mm')} - - )} - + )} + + )} ); @@ -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', diff --git a/utils/health.ts b/utils/health.ts index 8b3c8dd..c7b4a50 100644 --- a/utils/health.ts +++ b/utils/health.ts @@ -139,7 +139,7 @@ export async function fetchHealthDataForDate(date: Date): Promise