feat: 新增体重记录功能,优化用户资料更新及图片组件缓存

This commit is contained in:
richarjiang
2025-08-27 19:18:54 +08:00
parent aaa462d476
commit ba2d829e02
10 changed files with 767 additions and 32 deletions

View File

@@ -0,0 +1,887 @@
import { Colors } from '@/constants/Colors';
import { ROUTES } from '@/constants/Routes';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme';
import { fetchWeightHistory } from '@/store/userSlice';
import { BMI_CATEGORIES, canCalculateBMI, getBMIResult } from '@/utils/bmi';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useEffect, useState } from 'react';
import {
Dimensions,
Image,
Modal,
ScrollView,
StyleSheet,
Text,
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');
const CARD_WIDTH = screenWidth - 40; // 减去左右边距
const CHART_WIDTH = CARD_WIDTH - 36; // 减去卡片内边距
const CHART_HEIGHT = 100;
const PADDING = 10;
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 [showBMIModal, setShowBMIModal] = useState(false);
// 动画相关状态
const animationProgress = useSharedValue(0);
const [isAnimating, setIsAnimating] = useState(false);
const { pushIfAuthedElseLogin } = useAuthGuard();
const colorScheme = useColorScheme();
const themeColors = Colors[colorScheme ?? 'light'];
const hasWeight = userProfile?.weight && parseFloat(userProfile.weight) > 0;
const hasHeight = userProfile?.height && parseFloat(userProfile.height) > 0;
// BMI 计算
const canCalculate = canCalculateBMI(
userProfile?.weight ? parseFloat(userProfile.weight) : undefined,
userProfile?.height ? parseFloat(userProfile.height) : undefined
);
const bmiResult = canCalculate && userProfile?.weight && userProfile?.height
? getBMIResult(parseFloat(userProfile.weight), parseFloat(userProfile.height))
: null;
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);
};
const handleShowBMIModal = () => {
setShowBMIModal(true);
};
const handleHideBMIModal = () => {
setShowBMIModal(false);
};
// 切换图表显示状态的动画函数
const navigateToWeightRecords = () => {
pushIfAuthedElseLogin(ROUTES.WEIGHT_RECORDS);
};
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 (
<TouchableOpacity style={styles.card} onPress={navigateToWeightRecords} activeOpacity={0.8}>
<View style={styles.cardHeader}>
<View style={styles.iconSquare}>
<Image source={require('@/assets/images/icons/iconWeight.png')} style={{ width: 18, height: 18 }} />
</View>
<Text style={styles.cardTitle}></Text>
</View>
<View style={styles.emptyContent}>
<Text style={styles.emptyDescription}>...</Text>
</View>
</TouchableOpacity>
);
}
// 如果没有体重数据,显示引导卡片
if (!hasWeight) {
return (
<TouchableOpacity style={styles.card} onPress={navigateToWeightRecords} activeOpacity={0.8}>
<View style={styles.cardHeader}>
<View style={styles.iconSquare}>
<Image source={require('@/assets/images/icons/iconWeight.png')} style={{ width: 18, height: 18 }} />
</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={(e) => {
e.stopPropagation();
navigateToCoach();
}}
activeOpacity={0.8}
>
<Ionicons name="add" size={18} color="#192126" />
<Text style={styles.recordButtonText}></Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
);
}
// 处理体重历史数据
const sortedHistory = [...weightHistory]
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
.slice(-7); // 只显示最近7条记录
if (sortedHistory.length === 0) {
return (
<TouchableOpacity style={styles.card} onPress={navigateToWeightRecords} activeOpacity={0.8}>
<View style={styles.cardHeader}>
<View style={styles.iconSquare}>
<Image source={require('@/assets/images/icons/iconWeight.png')} style={{ width: 18, height: 18 }} />
</View>
<Text style={styles.cardTitle}></Text>
</View>
<View style={styles.emptyContent}>
<Text style={styles.emptyDescription}>
</Text>
<TouchableOpacity
style={styles.recordButton}
onPress={(e) => {
e.stopPropagation();
navigateToCoach();
}}
activeOpacity={0.8}
>
<Ionicons name="add" size={18} color="#FFFFFF" />
<Text style={styles.recordButtonText}></Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
);
}
// 生成图表数据
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 (
<TouchableOpacity style={styles.card} onPress={navigateToWeightRecords} activeOpacity={0.8}>
<View style={styles.cardHeader}>
<View style={styles.iconSquare}>
<Image source={require('@/assets/images/icons/iconWeight.png')} style={{ width: 18, height: 18 }} />
</View>
<Text style={styles.cardTitle}></Text>
<View style={styles.headerButtons}>
<TouchableOpacity
style={styles.chartToggleButton}
onPress={(e) => {
e.stopPropagation();
toggleChart();
}}
activeOpacity={0.8}
>
<Ionicons
name={showChart ? "chevron-up" : "chevron-down"}
size={16}
color={Colors.light.primary}
/>
</TouchableOpacity>
<TouchableOpacity
style={styles.addButton}
onPress={(e) => {
e.stopPropagation();
navigateToCoach();
}}
activeOpacity={0.8}
>
<Ionicons name="add" size={16} color={Colors.light.primary} />
</TouchableOpacity>
</View>
</View>
{/* 动画容器 */}
{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>
<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)}
</Text>
</View>
{bmiResult && (
<View style={styles.summaryItem}>
<Text style={styles.summaryLabel}>BMI</Text>
<View style={styles.bmiValueContainer}>
<Text style={[styles.bmiValue, { color: bmiResult.color }]}>
{bmiResult.value}
</Text>
<TouchableOpacity
onPress={(e) => {
e.stopPropagation();
handleShowBMIModal();
}}
style={styles.bmiInfoButton}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="information-circle-outline" size={12} color="#9AA3AE" />
</TouchableOpacity>
</View>
</View>
)}
</View>
</Animated.View>
{/* 图表容器 - 带动画 */}
<Animated.View style={[styles.chartContainer, chartAnimatedStyle]}>
<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={Colors.light.accentGreen}
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={Colors.light.accentGreen}
stroke="#FFFFFF"
strokeWidth={2}
/>
{/* 体重标签 - 只在关键点显示 */}
{showLabel && (
<>
<Circle
cx={point.x}
cy={point.y - 15}
r={10}
fill="rgba(255,255,255,0.9)"
stroke={Colors.light.accentGreen}
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>
)}
</Animated.View>
</Animated.View>
)}
{/* BMI 信息弹窗 */}
<Modal
visible={showBMIModal}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={handleHideBMIModal}
>
<LinearGradient
colors={[themeColors.backgroundGradientStart, themeColors.backgroundGradientEnd]}
style={styles.bmiModalContainer}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
>
<ScrollView style={styles.bmiModalContent} showsVerticalScrollIndicator={false}>
{/* 标题 */}
<Text style={styles.bmiModalTitle}>BMI </Text>
{/* 介绍部分 */}
<View style={styles.bmiModalIntroSection}>
<Text style={styles.bmiModalDescription}>
BMI
</Text>
<View style={styles.bmiModalFormulaContainer}>
<Text style={styles.bmiModalFormulaText}>
(kg) ÷ ²(m)
</Text>
</View>
</View>
{/* BMI 分类标准 */}
<Text style={styles.bmiModalSectionTitle}>BMI </Text>
<View style={styles.bmiModalStatsCard}>
{BMI_CATEGORIES.map((category, index) => {
const colors = [
{ bg: '#FEF3C7', text: '#B45309', border: '#F59E0B' }, // 偏瘦
{ bg: '#E8F5E8', text: Colors.light.accentGreen, border: Colors.light.accentGreen }, // 正常
{ bg: '#FEF3C7', text: '#B45309', border: '#F59E0B' }, // 超重
{ bg: '#FEE2E2', text: '#B91C1C', border: '#EF4444' } // 肥胖
][index];
return (
<View key={index} style={[styles.bmiModalStatItem, { backgroundColor: colors.bg, borderColor: colors.border }]}>
<View style={styles.bmiModalStatHeader}>
<Text style={[styles.bmiModalStatTitle, { color: colors.text }]}>
{category.name}
</Text>
<Text style={[styles.bmiModalStatRange, { color: colors.text }]}>
{category.range}
</Text>
</View>
<Text style={[styles.bmiModalStatAdvice, { color: colors.text }]}>
{category.advice}
</Text>
</View>
);
})}
</View>
{/* 健康建议 */}
<Text style={styles.bmiModalSectionTitle}></Text>
<View style={styles.bmiModalHealthTips}>
<View style={styles.bmiModalTipsItem}>
<Ionicons name="nutrition-outline" size={20} color="#3B82F6" />
<Text style={styles.bmiModalTipsText}></Text>
</View>
<View style={styles.bmiModalTipsItem}>
<Ionicons name="walk-outline" size={20} color="#3B82F6" />
<Text style={styles.bmiModalTipsText}>150</Text>
</View>
<View style={styles.bmiModalTipsItem}>
<Ionicons name="moon-outline" size={20} color="#3B82F6" />
<Text style={styles.bmiModalTipsText}>7-9</Text>
</View>
<View style={styles.bmiModalTipsItem}>
<Ionicons name="calendar-outline" size={20} color="#3B82F6" />
<Text style={styles.bmiModalTipsText}></Text>
</View>
</View>
{/* 免责声明 */}
<View style={styles.bmiModalDisclaimer}>
<Ionicons name="information-circle-outline" size={16} color="#6B7280" />
<Text style={styles.bmiModalDisclaimerText}>
BMI
</Text>
</View>
</ScrollView>
{/* 底部继续按钮 */}
<View style={styles.bmiModalBottomContainer}>
<TouchableOpacity style={styles.bmiModalContinueButton} onPress={handleHideBMIModal}>
<View style={styles.bmiModalButtonBackground}>
<Text style={styles.bmiModalButtonText}></Text>
</View>
</TouchableOpacity>
<View style={styles.bmiModalHomeIndicator} />
</View>
</LinearGradient>
</Modal>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: '#FFFFFF',
borderRadius: 22,
padding: 18,
marginBottom: 8,
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,
alignItems: 'center',
justifyContent: 'center',
marginRight: 2,
},
cardTitle: {
fontSize: 14,
fontWeight: '800',
color: '#192126',
flex: 1,
},
headerButtons: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
chartToggleButton: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
addButton: {
width: 28,
height: 28,
borderRadius: 14,
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: Colors.light.accentGreen,
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 20,
gap: 6,
},
recordButtonText: {
color: '#192126',
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,
},
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: 4,
},
summaryRow: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 0,
flexWrap: 'wrap',
gap: 8,
},
summaryItem: {
alignItems: 'center',
flex: 1,
minWidth: 0,
},
summaryLabel: {
fontSize: 11,
color: '#687076',
marginBottom: 3,
},
summaryValue: {
fontSize: 13,
fontWeight: '700',
color: '#192126',
},
// BMI 相关样式
bmiValueContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 1,
},
bmiValue: {
fontSize: 12,
fontWeight: '700',
},
bmiInfoButton: {
padding: 0,
},
bmiStatusBadge: {
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 8,
},
bmiStatusText: {
fontSize: 10,
fontWeight: '700',
},
// BMI 弹窗样式
bmiModalContainer: {
flex: 1,
},
bmiModalContent: {
flex: 1,
padding: 20,
},
bmiModalTitle: {
fontSize: 28,
fontWeight: '800',
color: '#111827',
textAlign: 'center',
marginBottom: 24,
letterSpacing: -0.5,
},
bmiModalIntroSection: {
marginBottom: 32,
},
bmiModalDescription: {
fontSize: 16,
color: '#374151',
lineHeight: 24,
textAlign: 'center',
marginBottom: 16,
},
bmiModalFormulaContainer: {
backgroundColor: '#F3F4F6',
borderRadius: 12,
padding: 16,
alignItems: 'center',
},
bmiModalFormulaText: {
fontSize: 14,
fontWeight: '600',
color: '#374151',
},
bmiModalSectionTitle: {
fontSize: 20,
fontWeight: '700',
color: '#111827',
marginBottom: 16,
letterSpacing: -0.5,
},
bmiModalStatsCard: {
marginBottom: 32,
},
bmiModalStatItem: {
borderRadius: 12,
padding: 16,
marginBottom: 12,
borderWidth: 1,
},
bmiModalStatHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
bmiModalStatTitle: {
fontSize: 16,
fontWeight: '700',
},
bmiModalStatRange: {
fontSize: 14,
fontWeight: '600',
},
bmiModalStatAdvice: {
fontSize: 14,
lineHeight: 20,
},
bmiModalHealthTips: {
marginBottom: 32,
},
bmiModalTipsItem: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 16,
paddingHorizontal: 16,
paddingVertical: 12,
backgroundColor: '#F8FAFC',
borderRadius: 12,
},
bmiModalTipsText: {
fontSize: 14,
color: '#374151',
marginLeft: 12,
flex: 1,
lineHeight: 20,
},
bmiModalDisclaimer: {
flexDirection: 'row',
alignItems: 'flex-start',
backgroundColor: '#FEF3C7',
borderRadius: 12,
padding: 16,
marginBottom: 20,
},
bmiModalDisclaimerText: {
fontSize: 13,
color: '#B45309',
marginLeft: 8,
flex: 1,
lineHeight: 18,
},
bmiModalBottomContainer: {
padding: 20,
paddingBottom: 34,
},
bmiModalContinueButton: {
marginBottom: 8,
},
bmiModalButtonBackground: {
backgroundColor: '#192126',
borderRadius: 16,
paddingVertical: 16,
alignItems: 'center',
},
bmiModalButtonText: {
fontSize: 16,
fontWeight: '700',
color: '#FFFFFF',
},
bmiModalHomeIndicator: {
height: 5,
backgroundColor: '#D1D5DB',
borderRadius: 3,
alignSelf: 'center',
width: 36,
},
});