feat: 新增营养摄入分析卡片并优化相关页面
- 在统计页面中引入营养摄入分析卡片,展示用户的营养数据 - 更新探索页面,增加营养数据加载逻辑,确保用户体验一致性 - 移除不再使用的推荐文章逻辑,简化代码结构 - 更新路由常量,确保路径管理集中化 - 优化体重历史记录卡片,提升用户交互体验
This commit is contained in:
158
components/NutritionRadarCard.tsx
Normal file
158
components/NutritionRadarCard.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { NutritionSummary } from '@/services/dietRecords';
|
||||
import React, { useMemo } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { RadarCategory, RadarChart } from './RadarChart';
|
||||
|
||||
export type NutritionRadarCardProps = {
|
||||
nutritionSummary: NutritionSummary | null;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
// 营养维度定义
|
||||
const NUTRITION_DIMENSIONS: RadarCategory[] = [
|
||||
{ key: 'calories', label: '热量' },
|
||||
{ key: 'protein', label: '蛋白质' },
|
||||
{ key: 'carbohydrate', label: '碳水' },
|
||||
{ key: 'fat', label: '脂肪' },
|
||||
{ key: 'fiber', label: '膳食纤维' },
|
||||
{ key: 'sodium', label: '钠含量' },
|
||||
];
|
||||
|
||||
export function NutritionRadarCard({ nutritionSummary, isLoading = false }: NutritionRadarCardProps) {
|
||||
const radarValues = useMemo(() => {
|
||||
// 基于推荐日摄入量计算分数
|
||||
const recommendations = {
|
||||
calories: 2000, // 卡路里
|
||||
protein: 50, // 蛋白质(g)
|
||||
carbohydrate: 300, // 碳水化合物(g)
|
||||
fat: 65, // 脂肪(g)
|
||||
fiber: 25, // 膳食纤维(g)
|
||||
sodium: 2300, // 钠(mg)
|
||||
};
|
||||
|
||||
if (!nutritionSummary) return [0, 0, 0, 0, 0, 0];
|
||||
|
||||
return [
|
||||
Math.min(5, (nutritionSummary.totalCalories / recommendations.calories) * 5),
|
||||
Math.min(5, (nutritionSummary.totalProtein / recommendations.protein) * 5),
|
||||
Math.min(5, (nutritionSummary.totalCarbohydrate / recommendations.carbohydrate) * 5),
|
||||
Math.min(5, (nutritionSummary.totalFat / recommendations.fat) * 5),
|
||||
Math.min(5, (nutritionSummary.totalFiber / recommendations.fiber) * 5),
|
||||
Math.min(5, Math.max(0, 5 - (nutritionSummary.totalSodium / recommendations.sodium) * 5)), // 钠含量越低越好
|
||||
];
|
||||
}, [nutritionSummary]);
|
||||
|
||||
const nutritionStats = useMemo(() => {
|
||||
return [
|
||||
{ label: '热量', value: nutritionSummary ? `${Math.round(nutritionSummary.totalCalories)} 千卡` : '0 千卡', color: '#FF6B6B' },
|
||||
{ label: '蛋白质', value: nutritionSummary ? `${nutritionSummary.totalProtein.toFixed(1)} g` : '0.0 g', color: '#4ECDC4' },
|
||||
{ label: '碳水', value: nutritionSummary ? `${nutritionSummary.totalCarbohydrate.toFixed(1)} g` : '0.0 g', color: '#45B7D1' },
|
||||
{ label: '脂肪', value: nutritionSummary ? `${nutritionSummary.totalFat.toFixed(1)} g` : '0.0 g', color: '#FFA07A' },
|
||||
{ label: '膳食纤维', value: nutritionSummary ? `${nutritionSummary.totalFiber.toFixed(1)} g` : '0.0 g', color: '#98D8C8' },
|
||||
{ label: '钠', value: nutritionSummary ? `${Math.round(nutritionSummary.totalSodium)} mg` : '0 mg', color: '#F7DC6F' },
|
||||
];
|
||||
}, [nutritionSummary]);
|
||||
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardTitle}>营养摄入分析</Text>
|
||||
|
||||
{isLoading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<Text style={styles.loadingText}>加载中...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.contentContainer}>
|
||||
<View style={styles.radarContainer}>
|
||||
<RadarChart
|
||||
categories={NUTRITION_DIMENSIONS}
|
||||
values={radarValues}
|
||||
size={100}
|
||||
maxValue={5}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.statsContainer}>
|
||||
{nutritionStats.map((stat, index) => (
|
||||
<View key={stat.label} style={styles.statItem}>
|
||||
<View style={[styles.statDot, { backgroundColor: stat.color }]} />
|
||||
<Text style={styles.statLabel}>{stat.label}</Text>
|
||||
<Text style={styles.statValue}>{stat.value}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 22,
|
||||
padding: 18,
|
||||
marginBottom: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
color: '#192126',
|
||||
marginBottom: 16,
|
||||
},
|
||||
contentContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
radarContainer: {
|
||||
alignItems: 'center',
|
||||
marginRight: 20,
|
||||
},
|
||||
statsContainer: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
statItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
width: '48%',
|
||||
marginBottom: 8,
|
||||
},
|
||||
statDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
marginRight: 8,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 12,
|
||||
color: '#9AA3AE',
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 12,
|
||||
color: '#192126',
|
||||
fontWeight: '700',
|
||||
},
|
||||
loadingContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: 80,
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: 16,
|
||||
color: '#9AA3AE',
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { fetchWeightHistory } from '@/store/userSlice';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Dimensions,
|
||||
@@ -26,13 +27,14 @@ type WeightHistoryItem = {
|
||||
};
|
||||
|
||||
export function WeightHistoryCard() {
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const userProfile = useAppSelector((s) => s.user.profile);
|
||||
const weightHistory = useAppSelector((s) => s.user.weightHistory);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showChart, setShowChart] = useState(false);
|
||||
|
||||
const { pushIfAuthedElseLogin } = useAuthGuard();
|
||||
|
||||
const hasWeight = userProfile?.weight && parseFloat(userProfile.weight) > 0;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -53,7 +55,7 @@ export function WeightHistoryCard() {
|
||||
};
|
||||
|
||||
const navigateToCoach = () => {
|
||||
router.push('/(tabs)/coach');
|
||||
pushIfAuthedElseLogin(ROUTES.TAB_COACH);
|
||||
};
|
||||
|
||||
// 如果正在加载,显示加载状态
|
||||
@@ -83,22 +85,19 @@ export function WeightHistoryCard() {
|
||||
</View>
|
||||
<Text style={styles.cardTitle}>体重记录</Text>
|
||||
</View>
|
||||
|
||||
|
||||
<View style={styles.emptyContent}>
|
||||
<View style={styles.emptyIconContainer}>
|
||||
<Ionicons name="scale-outline" size={32} color="#BBF246" />
|
||||
</View>
|
||||
<Text style={styles.emptyTitle}>开始记录你的体重变化</Text>
|
||||
<Text style={styles.emptyDescription}>
|
||||
记录体重变化,追踪你的健康进展
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.recordButton}
|
||||
<TouchableOpacity
|
||||
style={styles.recordButton}
|
||||
onPress={navigateToCoach}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Ionicons name="add" size={18} color="#FFFFFF" />
|
||||
<Text style={styles.recordButtonText}>去记录体重</Text>
|
||||
<Ionicons name="add" size={18} color="#192126" />
|
||||
<Text style={styles.recordButtonText}>记录</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
@@ -119,13 +118,13 @@ export function WeightHistoryCard() {
|
||||
</View>
|
||||
<Text style={styles.cardTitle}>体重记录</Text>
|
||||
</View>
|
||||
|
||||
|
||||
<View style={styles.emptyContent}>
|
||||
<Text style={styles.emptyDescription}>
|
||||
暂无体重记录,点击下方按钮开始记录
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.recordButton}
|
||||
<TouchableOpacity
|
||||
style={styles.recordButton}
|
||||
onPress={navigateToCoach}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
@@ -158,8 +157,8 @@ export function WeightHistoryCard() {
|
||||
}).join(' ');
|
||||
|
||||
// 如果只有一个数据点,显示为水平线
|
||||
const singlePointPath = points.length === 1 ?
|
||||
`M ${PADDING} ${points[0].y} L ${CHART_WIDTH - PADDING} ${points[0].y}` :
|
||||
const singlePointPath = points.length === 1 ?
|
||||
`M ${PADDING} ${points[0].y} L ${CHART_WIDTH - PADDING} ${points[0].y}` :
|
||||
pathData;
|
||||
|
||||
return (
|
||||
@@ -170,19 +169,19 @@ export function WeightHistoryCard() {
|
||||
</View>
|
||||
<Text style={styles.cardTitle}>体重记录</Text>
|
||||
<View style={styles.headerButtons}>
|
||||
<TouchableOpacity
|
||||
style={styles.chartToggleButton}
|
||||
<TouchableOpacity
|
||||
style={styles.chartToggleButton}
|
||||
onPress={() => setShowChart(!showChart)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Ionicons
|
||||
name={showChart ? "chevron-up" : "chevron-down"}
|
||||
size={16}
|
||||
color="#BBF246"
|
||||
<Ionicons
|
||||
name={showChart ? "chevron-up" : "chevron-down"}
|
||||
size={16}
|
||||
color="#BBF246"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.addButton}
|
||||
<TouchableOpacity
|
||||
style={styles.addButton}
|
||||
onPress={navigateToCoach}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
@@ -210,8 +209,8 @@ export function WeightHistoryCard() {
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={styles.viewTrendButton}
|
||||
<TouchableOpacity
|
||||
style={styles.viewTrendButton}
|
||||
onPress={() => setShowChart(true)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
@@ -226,100 +225,100 @@ export function WeightHistoryCard() {
|
||||
<View style={styles.chartContainer}>
|
||||
<Svg width={CHART_WIDTH} height={CHART_HEIGHT + 15}>
|
||||
{/* 背景网格线 */}
|
||||
{[0, 1, 2, 3, 4].map(i => (
|
||||
<Line
|
||||
key={`grid-${i}`}
|
||||
x1={PADDING}
|
||||
y1={PADDING + 15 + i * (CHART_HEIGHT - 2 * PADDING - 30) / 4}
|
||||
x2={CHART_WIDTH - PADDING}
|
||||
y2={PADDING + 15 + i * (CHART_HEIGHT - 2 * PADDING - 30) / 4}
|
||||
stroke="#F0F0F0"
|
||||
strokeWidth={1}
|
||||
{[0, 1, 2, 3, 4].map(i => (
|
||||
<Line
|
||||
key={`grid-${i}`}
|
||||
x1={PADDING}
|
||||
y1={PADDING + 15 + i * (CHART_HEIGHT - 2 * PADDING - 30) / 4}
|
||||
x2={CHART_WIDTH - PADDING}
|
||||
y2={PADDING + 15 + i * (CHART_HEIGHT - 2 * PADDING - 30) / 4}
|
||||
stroke="#F0F0F0"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 折线 */}
|
||||
<Path
|
||||
d={singlePointPath}
|
||||
stroke="#BBF246"
|
||||
strokeWidth={3}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 折线 */}
|
||||
<Path
|
||||
d={singlePointPath}
|
||||
stroke="#BBF246"
|
||||
strokeWidth={3}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* 数据点和标签 */}
|
||||
{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 (
|
||||
<React.Fragment key={index}>
|
||||
<Circle
|
||||
cx={point.x}
|
||||
cy={point.y}
|
||||
r={isLastPoint ? 6 : 4}
|
||||
fill="#BBF246"
|
||||
stroke="#FFFFFF"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
{/* 体重标签 - 只在关键点显示 */}
|
||||
{showLabel && (
|
||||
<>
|
||||
<Circle
|
||||
cx={point.x}
|
||||
cy={point.y - 15}
|
||||
r={10}
|
||||
fill="rgba(255,255,255,0.9)"
|
||||
stroke="#BBF246"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<SvgText
|
||||
x={point.x}
|
||||
y={point.y - 12}
|
||||
fontSize="9"
|
||||
fill="#192126"
|
||||
textAnchor="middle"
|
||||
>
|
||||
{point.weight}
|
||||
</SvgText>
|
||||
</>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
<Circle
|
||||
cx={point.x}
|
||||
cy={point.y}
|
||||
r={isLastPoint ? 6 : 4}
|
||||
fill="#BBF246"
|
||||
stroke="#FFFFFF"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
{/* 体重标签 - 只在关键点显示 */}
|
||||
{showLabel && (
|
||||
<>
|
||||
<Circle
|
||||
cx={point.x}
|
||||
cy={point.y - 15}
|
||||
r={10}
|
||||
fill="rgba(255,255,255,0.9)"
|
||||
stroke="#BBF246"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<SvgText
|
||||
x={point.x}
|
||||
y={point.y - 12}
|
||||
fontSize="9"
|
||||
fill="#192126"
|
||||
textAnchor="middle"
|
||||
>
|
||||
{point.weight}
|
||||
</SvgText>
|
||||
</>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
|
||||
</Svg>
|
||||
</Svg>
|
||||
|
||||
{/* 图表底部信息 */}
|
||||
<View style={styles.chartInfo}>
|
||||
<View style={styles.infoItem}>
|
||||
<Text style={styles.infoLabel}>当前体重</Text>
|
||||
<Text style={styles.infoValue}>{userProfile.weight}kg</Text>
|
||||
{/* 图表底部信息 */}
|
||||
<View style={styles.chartInfo}>
|
||||
<View style={styles.infoItem}>
|
||||
<Text style={styles.infoLabel}>当前体重</Text>
|
||||
<Text style={styles.infoValue}>{userProfile.weight}kg</Text>
|
||||
</View>
|
||||
<View style={styles.infoItem}>
|
||||
<Text style={styles.infoLabel}>记录天数</Text>
|
||||
<Text style={styles.infoValue}>{sortedHistory.length}天</Text>
|
||||
</View>
|
||||
<View style={styles.infoItem}>
|
||||
<Text style={styles.infoLabel}>变化范围</Text>
|
||||
<Text style={styles.infoValue}>
|
||||
{minWeight.toFixed(1)}-{maxWeight.toFixed(1)}kg
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.infoItem}>
|
||||
<Text style={styles.infoLabel}>记录天数</Text>
|
||||
<Text style={styles.infoValue}>{sortedHistory.length}天</Text>
|
||||
</View>
|
||||
<View style={styles.infoItem}>
|
||||
<Text style={styles.infoLabel}>变化范围</Text>
|
||||
<Text style={styles.infoValue}>
|
||||
{minWeight.toFixed(1)}-{maxWeight.toFixed(1)}kg
|
||||
|
||||
{/* 最近记录时间 */}
|
||||
{sortedHistory.length > 0 && (
|
||||
<Text style={styles.lastRecordText}>
|
||||
最近记录:{dayjs(sortedHistory[sortedHistory.length - 1].createdAt).format('MM/DD HH:mm')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 最近记录时间 */}
|
||||
{sortedHistory.length > 0 && (
|
||||
<Text style={styles.lastRecordText}>
|
||||
最近记录:{dayjs(sortedHistory[sortedHistory.length - 1].createdAt).format('MM/DD HH:mm')}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -378,16 +377,6 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
emptyContent: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 20,
|
||||
},
|
||||
emptyIconContainer: {
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
backgroundColor: '#F0F8E0',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: 16,
|
||||
|
||||
Reference in New Issue
Block a user