Files
digital-pilates/components/WeightHistoryCard.tsx
richarjiang 9aa0a692a8 feat: 新增营养摄入分析卡片并优化相关页面
- 在统计页面中引入营养摄入分析卡片,展示用户的营养数据
- 更新探索页面,增加营养数据加载逻辑,确保用户体验一致性
- 移除不再使用的推荐文章逻辑,简化代码结构
- 更新路由常量,确保路径管理集中化
- 优化体重历史记录卡片,提升用户交互体验
2025-08-19 10:01:26 +08:00

477 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 React, { useEffect, useState } from 'react';
import {
Dimensions,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import Svg, { Circle, Line, Path, Text as SvgText } from 'react-native-svg';
const { width: screenWidth } = Dimensions.get('window');
const CARD_WIDTH = screenWidth - 40; // 减去左右边距
const CHART_WIDTH = CARD_WIDTH - 36; // 减去卡片内边距
const CHART_HEIGHT = 100;
const PADDING = 10;
type WeightHistoryItem = {
weight: string;
source: string;
createdAt: string;
};
export function WeightHistoryCard() {
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(() => {
if (hasWeight) {
loadWeightHistory();
}
}, [hasWeight]);
const loadWeightHistory = async () => {
try {
setIsLoading(true);
await dispatch(fetchWeightHistory() as any);
} catch (error) {
console.error('加载体重历史失败:', error);
} finally {
setIsLoading(false);
}
};
const navigateToCoach = () => {
pushIfAuthedElseLogin(ROUTES.TAB_COACH);
};
// 如果正在加载,显示加载状态
if (isLoading) {
return (
<View style={styles.card}>
<View style={styles.cardHeader}>
<View style={styles.iconSquare}>
<Ionicons name="scale-outline" size={18} color="#192126" />
</View>
<Text style={styles.cardTitle}></Text>
</View>
<View style={styles.emptyContent}>
<Text style={styles.emptyDescription}>...</Text>
</View>
</View>
);
}
// 如果没有体重数据,显示引导卡片
if (!hasWeight) {
return (
<View style={styles.card}>
<View style={styles.cardHeader}>
<View style={styles.iconSquare}>
<Ionicons name="scale-outline" size={18} color="#192126" />
</View>
<Text style={styles.cardTitle}></Text>
</View>
<View style={styles.emptyContent}>
<Text style={styles.emptyTitle}></Text>
<Text style={styles.emptyDescription}>
</Text>
<TouchableOpacity
style={styles.recordButton}
onPress={navigateToCoach}
activeOpacity={0.8}
>
<Ionicons name="add" size={18} color="#192126" />
<Text style={styles.recordButtonText}></Text>
</TouchableOpacity>
</View>
</View>
);
}
// 处理体重历史数据
const sortedHistory = [...weightHistory]
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
.slice(-7); // 只显示最近7条记录
if (sortedHistory.length === 0) {
return (
<View style={styles.card}>
<View style={styles.cardHeader}>
<View style={styles.iconSquare}>
<Ionicons name="scale-outline" size={18} color="#192126" />
</View>
<Text style={styles.cardTitle}></Text>
</View>
<View style={styles.emptyContent}>
<Text style={styles.emptyDescription}>
</Text>
<TouchableOpacity
style={styles.recordButton}
onPress={navigateToCoach}
activeOpacity={0.8}
>
<Ionicons name="add" size={18} color="#FFFFFF" />
<Text style={styles.recordButtonText}></Text>
</TouchableOpacity>
</View>
</View>
);
}
// 生成图表数据
const weights = sortedHistory.map(item => parseFloat(item.weight));
const minWeight = Math.min(...weights);
const maxWeight = Math.max(...weights);
const weightRange = maxWeight - minWeight || 1;
const points = sortedHistory.map((item, index) => {
const x = PADDING + (index / Math.max(sortedHistory.length - 1, 1)) * (CHART_WIDTH - 2 * PADDING);
const normalizedWeight = (parseFloat(item.weight) - minWeight) / weightRange;
// 减少顶部边距,压缩留白
const y = PADDING + 15 + (1 - normalizedWeight) * (CHART_HEIGHT - 2 * PADDING - 30);
return { x, y, weight: item.weight, date: item.createdAt };
});
// 生成路径
const pathData = points.map((point, index) => {
if (index === 0) return `M ${point.x} ${point.y}`;
return `L ${point.x} ${point.y}`;
}).join(' ');
// 如果只有一个数据点,显示为水平线
const singlePointPath = points.length === 1 ?
`M ${PADDING} ${points[0].y} L ${CHART_WIDTH - PADDING} ${points[0].y}` :
pathData;
return (
<View style={styles.card}>
<View style={styles.cardHeader}>
<View style={styles.iconSquare}>
<Ionicons name="scale-outline" size={18} color="#192126" />
</View>
<Text style={styles.cardTitle}></Text>
<View style={styles.headerButtons}>
<TouchableOpacity
style={styles.chartToggleButton}
onPress={() => setShowChart(!showChart)}
activeOpacity={0.8}
>
<Ionicons
name={showChart ? "chevron-up" : "chevron-down"}
size={16}
color="#BBF246"
/>
</TouchableOpacity>
<TouchableOpacity
style={styles.addButton}
onPress={navigateToCoach}
activeOpacity={0.8}
>
<Ionicons name="add" size={16} color="#BBF246" />
</TouchableOpacity>
</View>
</View>
{/* 默认信息显示 */}
{!showChart && sortedHistory.length > 0 && (
<View style={styles.summaryInfo}>
<View style={styles.summaryRow}>
<View style={styles.summaryItem}>
<Text style={styles.summaryLabel}></Text>
<Text style={styles.summaryValue}>{userProfile.weight}kg</Text>
</View>
<View style={styles.summaryItem}>
<Text style={styles.summaryLabel}></Text>
<Text style={styles.summaryValue}>{sortedHistory.length}</Text>
</View>
<View style={styles.summaryItem}>
<Text style={styles.summaryLabel}></Text>
<Text style={styles.summaryValue}>
{minWeight.toFixed(1)}-{maxWeight.toFixed(1)}kg
</Text>
</View>
</View>
<TouchableOpacity
style={styles.viewTrendButton}
onPress={() => setShowChart(true)}
activeOpacity={0.8}
>
<Ionicons name="trending-up" size={14} color="#BBF246" />
<Text style={styles.viewTrendText}></Text>
</TouchableOpacity>
</View>
)}
{/* 图表容器 - 可折叠 */}
{showChart && (
<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}
/>
))}
{/* 折线 */}
<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;
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>
{/* 图表底部信息 */}
<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>
{/* 最近记录时间 */}
{sortedHistory.length > 0 && (
<Text style={styles.lastRecordText}>
{dayjs(sortedHistory[sortedHistory.length - 1].createdAt).format('MM/DD HH:mm')}
</Text>
)}
</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: 8,
elevation: 3,
},
cardHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 16,
},
iconSquare: {
width: 30,
height: 30,
borderRadius: 8,
backgroundColor: '#F0F8E0',
alignItems: 'center',
justifyContent: 'center',
marginRight: 10,
},
cardTitle: {
fontSize: 18,
fontWeight: '800',
color: '#192126',
flex: 1,
},
headerButtons: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
chartToggleButton: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: '#F0F8E0',
alignItems: 'center',
justifyContent: 'center',
},
addButton: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: '#F0F8E0',
alignItems: 'center',
justifyContent: 'center',
},
emptyContent: {
alignItems: 'center',
},
emptyTitle: {
fontSize: 16,
fontWeight: '700',
color: '#192126',
marginBottom: 6,
},
emptyDescription: {
fontSize: 14,
color: '#687076',
textAlign: 'center',
marginBottom: 16,
lineHeight: 20,
},
recordButton: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#BBF246',
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 20,
gap: 6,
},
recordButtonText: {
color: '#192126',
fontSize: 14,
fontWeight: '700',
},
chartContainer: {
alignItems: 'center',
minHeight: 100,
},
chartInfo: {
flexDirection: 'row',
justifyContent: 'space-around',
width: '100%',
paddingTop: 16,
borderTopWidth: 1,
borderTopColor: '#F0F0F0',
},
infoItem: {
alignItems: 'center',
},
infoLabel: {
fontSize: 12,
color: '#687076',
marginBottom: 4,
},
infoValue: {
fontSize: 14,
fontWeight: '700',
color: '#192126',
},
lastRecordText: {
fontSize: 12,
color: '#687076',
textAlign: 'center',
marginTop: 8,
},
summaryInfo: {
paddingVertical: 12,
},
summaryRow: {
flexDirection: 'row',
justifyContent: 'space-around',
marginBottom: 12,
},
summaryItem: {
alignItems: 'center',
},
summaryLabel: {
fontSize: 12,
color: '#687076',
marginBottom: 4,
},
summaryValue: {
fontSize: 14,
fontWeight: '700',
color: '#192126',
},
viewTrendButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#F0F8E0',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 16,
gap: 6,
alignSelf: 'center',
},
viewTrendText: {
fontSize: 13,
fontWeight: '600',
color: '#192126',
},
});