Files
digital-pilates/components/weight/WeightHistoryCard.tsx

704 lines
21 KiB
TypeScript

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 } from '@/utils/bmi';
import { Ionicons } from '@expo/vector-icons';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Dimensions,
Modal,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
import Svg, { Circle, Defs, Path, Stop, LinearGradient as SvgLinearGradient } from 'react-native-svg';
import { WeightProgressBar } from './WeightProgressBar';
const { width: screenWidth } = Dimensions.get('window');
const CARD_WIDTH = screenWidth - 40;
const CHART_WIDTH = CARD_WIDTH - 36;
const CHART_HEIGHT = 70;
const PADDING = 10;
// 主题色
const THEME_PRIMARY = '#4F5BD5';
const THEME_SECONDARY = '#6B6CFF';
const THEME_SUCCESS = '#22C55E';
const THEME_TEXT_PRIMARY = '#1c1f3a';
const THEME_TEXT_SECONDARY = '#6f7ba7';
export function WeightHistoryCard() {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const router = useRouter();
const userProfile = useAppSelector((s) => s.user.profile);
const weightHistory = useAppSelector((s) => s.user.weightHistory);
const [showBMIModal, setShowBMIModal] = useState(false);
const isLgAvaliable = isLiquidGlassAvailable();
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
const colorScheme = useColorScheme();
const themeColors = Colors[colorScheme ?? 'light'];
const hasWeight = userProfile?.weight && parseFloat(userProfile.weight) > 0;
useEffect(() => {
if (isLoggedIn) {
loadWeightHistory();
}
}, [userProfile?.weight, isLoggedIn]);
const loadWeightHistory = async () => {
try {
await dispatch(fetchWeightHistory() as any).unwrap();
} catch (error) {
console.error('Failed to load weight history:', error);
}
};
// 点击添加按钮 - 需要登录
const handleAddWeight = () => {
pushIfAuthedElseLogin(ROUTES.WEIGHT_RECORDS);
};
const handleHideBMIModal = () => {
setShowBMIModal(false);
};
// 点击卡片 - 直接跳转,不需要登录
const navigateToWeightRecords = () => {
router.push(ROUTES.WEIGHT_RECORDS);
};
// Process weight history data
const sortedHistory = [...weightHistory]
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
.slice(-7);
// 是否有数据
const hasData = sortedHistory.length > 0;
// 计算减重进度
const currentWeight = userProfile?.weight ? parseFloat(userProfile.weight) : 0;
const initialWeight = userProfile?.initialWeight
? parseFloat(userProfile.initialWeight)
: (sortedHistory.length > 0 ? parseFloat(sortedHistory[0].weight) : 0);
const targetWeight = userProfile?.targetWeight ? parseFloat(userProfile.targetWeight) : 0;
// 计算进度百分比
const hasTargetWeight = targetWeight > 0 && initialWeight > targetWeight;
const totalToLose = initialWeight - targetWeight;
const actualLost = initialWeight - currentWeight;
const weightProgress = hasTargetWeight && totalToLose > 0 ? actualLost / totalToLose : 0;
// Generate chart data
const weights = hasData ? sortedHistory.map(item => parseFloat(item.weight)) : [];
const minWeight = hasData ? Math.min(...weights) : 0;
const maxWeight = hasData ? Math.max(...weights) : 0;
const weightRange = maxWeight - minWeight || 1;
const points = hasData ? 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 + 10 + (1 - normalizedWeight) * (CHART_HEIGHT - 2 * PADDING - 20);
return { x, y, weight: item.weight, date: item.createdAt };
}) : [];
// 生成平滑曲线路径(使用贝塞尔曲线)
const generateSmoothPath = (pts: typeof points) => {
if (pts.length === 0) return '';
if (pts.length === 1) return `M ${pts[0].x} ${pts[0].y}`;
let path = `M ${pts[0].x} ${pts[0].y}`;
for (let i = 0; i < pts.length - 1; i++) {
const p0 = pts[Math.max(0, i - 1)];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[Math.min(pts.length - 1, i + 2)];
const cp1x = p1.x + (p2.x - p0.x) / 6;
const cp1y = p1.y + (p2.y - p0.y) / 6;
const cp2x = p2.x - (p3.x - p1.x) / 6;
const cp2y = p2.y - (p3.y - p1.y) / 6;
path += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p2.x} ${p2.y}`;
}
return path;
};
const smoothPath = generateSmoothPath(points);
const singlePointPath = points.length === 1
? `M ${PADDING} ${points[0].y} L ${CHART_WIDTH - PADDING} ${points[0].y}`
: smoothPath;
// 空状态下的占位曲线路径(水平虚线效果)
const emptyLinePath = `M ${PADDING} ${CHART_HEIGHT / 2} L ${CHART_WIDTH - PADDING} ${CHART_HEIGHT / 2}`;
return (
<TouchableOpacity style={styles.card} onPress={navigateToWeightRecords} activeOpacity={0.8}>
<View style={styles.cardHeader}>
<View style={styles.iconContainer}>
<Image
source={require('@/assets/images/icons/icon-weight.png')}
style={styles.iconSquare}
/>
</View>
<Text style={styles.cardTitle}>{t('statistics.components.weight.title')}</Text>
{isLgAvaliable ? (
<TouchableOpacity
onPress={(e) => {
e.stopPropagation();
handleAddWeight();
}}
activeOpacity={0.8}
>
<GlassView style={styles.addButtonGlass}>
<Ionicons name="add" size={18} color={THEME_PRIMARY} />
</GlassView>
</TouchableOpacity>
) : (
<TouchableOpacity
style={styles.addButton}
onPress={(e) => {
e.stopPropagation();
handleAddWeight();
}}
activeOpacity={0.8}
>
<Ionicons name="add" size={18} color={THEME_PRIMARY} />
</TouchableOpacity>
)}
</View>
{/* 当前体重显示 */}
<View style={styles.currentWeightSection}>
<View style={styles.weightValueContainer}>
<Text style={styles.weightValue}>{hasWeight ? currentWeight.toFixed(1) : '--'}</Text>
<Text style={styles.weightUnit}>kg</Text>
</View>
{sortedHistory.length > 1 && (
<View style={[
styles.changeTag,
{ backgroundColor: actualLost >= 0 ? 'rgba(34, 197, 94, 0.1)' : 'rgba(255, 107, 107, 0.1)' }
]}>
<Ionicons
name={actualLost >= 0 ? 'trending-down' : 'trending-up'}
size={12}
color={actualLost >= 0 ? THEME_SUCCESS : '#FF6B6B'}
/>
<Text style={[
styles.changeText,
{ color: actualLost >= 0 ? THEME_SUCCESS : '#FF6B6B' }
]}>
{actualLost >= 0 ? '-' : '+'}{Math.abs(actualLost).toFixed(1)}kg
</Text>
</View>
)}
</View>
{/* 图表显示 */}
<View style={styles.chartContainer}>
<Svg width={CHART_WIDTH} height={CHART_HEIGHT + 15}>
<Defs>
<SvgLinearGradient id="lineGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<Stop offset="0%" stopColor={THEME_PRIMARY} stopOpacity="1" />
<Stop offset="100%" stopColor={THEME_SECONDARY} stopOpacity="1" />
</SvgLinearGradient>
</Defs>
{hasData ? (
<>
{/* 平滑曲线 */}
<Path
d={singlePointPath}
stroke="url(#lineGradient)"
strokeWidth={2.5}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* 数据点 */}
{points.map((point, index) => {
const isLastPoint = index === points.length - 1;
return (
<React.Fragment key={index}>
{/* 外圈光晕 */}
{isLastPoint && (
<Circle
cx={point.x}
cy={point.y}
r={8}
fill={THEME_PRIMARY}
opacity={0.15}
/>
)}
{/* 数据点 */}
<Circle
cx={point.x}
cy={point.y}
r={isLastPoint ? 4 : 2.5}
fill={isLastPoint ? THEME_PRIMARY : THEME_SECONDARY}
stroke={isLastPoint ? '#ffffff' : 'none'}
strokeWidth={isLastPoint ? 2 : 0}
/>
</React.Fragment>
);
})}
</>
) : (
/* 空状态 - 虚线占位 */
<Path
d={emptyLinePath}
stroke="#E8EAF0"
strokeWidth={2}
fill="none"
strokeLinecap="round"
strokeDasharray="8,6"
/>
)}
</Svg>
{/* 图表信息 */}
<View style={styles.chartInfo}>
<View style={styles.infoItem}>
<Text style={styles.infoLabel}>{hasData ? sortedHistory.length : '--'}{t('statistics.components.weight.days')}</Text>
</View>
<View style={styles.infoItem}>
<Text style={styles.infoLabel}>
{hasData ? `${minWeight.toFixed(1)}-${maxWeight.toFixed(1)}kg` : '--'}
</Text>
</View>
</View>
</View>
{/* 减重进度条 - 始终显示 */}
<WeightProgressBar
progress={weightProgress}
currentWeight={currentWeight}
targetWeight={targetWeight}
initialWeight={initialWeight}
/>
{/* BMI information modal */}
<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}>
{/* Title */}
<Text style={styles.bmiModalTitle}>{t('statistics.components.weight.bmiModal.title')}</Text>
{/* Introduction section */}
<View style={styles.bmiModalIntroSection}>
<Text style={styles.bmiModalDescription}>
{t('statistics.components.weight.bmiModal.description')}
</Text>
<View style={styles.bmiModalFormulaContainer}>
<Text style={styles.bmiModalFormulaText}>
{t('statistics.components.weight.bmiModal.formula')}
</Text>
</View>
</View>
{/* BMI classification standards */}
<Text style={styles.bmiModalSectionTitle}>{t('statistics.components.weight.bmiModal.classificationTitle')}</Text>
<View style={styles.bmiModalStatsCard}>
{BMI_CATEGORIES.map((category, index) => {
const colors = [
{ bg: '#FEF3C7', text: '#B45309', border: '#F59E0B' }, // Underweight
{ bg: '#E8F5E8', text: Colors.light.accentGreen, border: Colors.light.accentGreen }, // Normal
{ bg: '#FEF3C7', text: '#B45309', border: '#F59E0B' }, // Overweight
{ bg: '#FEE2E2', text: '#B91C1C', border: '#EF4444' } // Obese
][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>
{/* Health tips */}
<Text style={styles.bmiModalSectionTitle}>{t('statistics.components.weight.bmiModal.healthTipsTitle')}</Text>
<View style={styles.bmiModalHealthTips}>
<View style={styles.bmiModalTipsItem}>
<Ionicons name="nutrition-outline" size={20} color="#3B82F6" />
<Text style={styles.bmiModalTipsText}>{t('statistics.components.weight.bmiModal.tips.nutrition')}</Text>
</View>
<View style={styles.bmiModalTipsItem}>
<Ionicons name="walk-outline" size={20} color="#3B82F6" />
<Text style={styles.bmiModalTipsText}>{t('statistics.components.weight.bmiModal.tips.exercise')}</Text>
</View>
<View style={styles.bmiModalTipsItem}>
<Ionicons name="moon-outline" size={20} color="#3B82F6" />
<Text style={styles.bmiModalTipsText}>{t('statistics.components.weight.bmiModal.tips.sleep')}</Text>
</View>
<View style={styles.bmiModalTipsItem}>
<Ionicons name="calendar-outline" size={20} color="#3B82F6" />
<Text style={styles.bmiModalTipsText}>{t('statistics.components.weight.bmiModal.tips.monitoring')}</Text>
</View>
</View>
{/* Disclaimer */}
<View style={styles.bmiModalDisclaimer}>
<Ionicons name="information-circle-outline" size={16} color="#6B7280" />
<Text style={styles.bmiModalDisclaimerText}>
{t('statistics.components.weight.bmiModal.disclaimer')}
</Text>
</View>
</ScrollView>
{/* Bottom continue button */}
<View style={styles.bmiModalBottomContainer}>
<TouchableOpacity style={styles.bmiModalContinueButton} onPress={handleHideBMIModal}>
<View style={styles.bmiModalButtonBackground}>
<Text style={styles.bmiModalButtonText}>{t('statistics.components.weight.bmiModal.continueButton')}</Text>
</View>
</TouchableOpacity>
<View style={styles.bmiModalHomeIndicator} />
</View>
</LinearGradient>
</Modal>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: '#FFFFFF',
borderRadius: 24,
padding: 18,
shadowColor: 'rgba(30, 41, 59, 0.08)',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.12,
shadowRadius: 12,
elevation: 4,
marginTop: 16,
},
cardHeader: {
flexDirection: 'row',
alignItems: 'center',
},
iconContainer: {
width: 28,
height: 28,
borderRadius: 8,
backgroundColor: 'rgba(79, 91, 213, 0.1)',
alignItems: 'center',
justifyContent: 'center',
marginRight: 10,
},
iconSquare: {
width: 16,
height: 16,
tintColor: THEME_PRIMARY,
},
cardTitle: {
fontSize: 15,
color: THEME_TEXT_PRIMARY,
flex: 1,
fontWeight: '700',
fontFamily: 'AliBold',
},
headerButtons: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
chartToggleButton: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
addButton: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(79, 91, 213, 0.1)',
},
addButtonGlass: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(79, 91, 213, 0.15)',
},
currentWeightSection: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 12,
gap: 12,
},
weightValueContainer: {
flexDirection: 'row',
alignItems: 'baseline',
},
weightValue: {
fontSize: 32,
fontWeight: '800',
color: THEME_TEXT_PRIMARY,
fontFamily: 'AliBold',
},
weightUnit: {
fontSize: 14,
fontWeight: '600',
color: THEME_TEXT_SECONDARY,
fontFamily: 'AliRegular',
marginLeft: 4,
},
changeTag: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 12,
gap: 4,
},
changeText: {
fontSize: 12,
fontWeight: '700',
fontFamily: 'AliBold',
},
emptyContent: {
alignItems: 'center',
},
emptyTitle: {
fontSize: 16,
fontWeight: '700',
color: THEME_TEXT_PRIMARY,
marginBottom: 6,
},
emptyDescription: {
fontSize: 14,
color: THEME_TEXT_SECONDARY,
textAlign: 'center',
marginBottom: 16,
lineHeight: 20,
},
recordButton: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: THEME_PRIMARY,
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 20,
gap: 6,
},
recordButtonText: {
color: '#FFFFFF',
fontSize: 14,
fontWeight: '700',
fontFamily: 'AliBold',
},
chartContainer: {
width: '100%',
alignItems: 'center',
marginTop: 12,
},
chartInfo: {
flexDirection: 'row',
justifyContent: 'space-around',
width: '100%',
marginTop: -14,
},
infoItem: {
alignItems: 'center',
backgroundColor: 'rgba(79, 91, 213, 0.06)',
paddingHorizontal: 12,
paddingVertical: 4,
borderRadius: 10,
},
infoLabel: {
fontSize: 11,
color: THEME_TEXT_SECONDARY,
fontWeight: '500',
fontFamily: 'AliRegular',
},
infoValue: {
fontSize: 14,
fontWeight: '700',
color: THEME_TEXT_PRIMARY,
},
// BMI modal styles
bmiModalContainer: {
flex: 1,
},
bmiModalContent: {
flex: 1,
padding: 20,
},
bmiModalTitle: {
fontSize: 28,
fontWeight: '800',
color: '#111827',
textAlign: 'center',
marginBottom: 24,
letterSpacing: -0.5,
fontFamily: 'AliBold',
},
bmiModalIntroSection: {
marginBottom: 32,
},
bmiModalDescription: {
fontSize: 16,
color: '#374151',
lineHeight: 24,
textAlign: 'center',
marginBottom: 16,
fontFamily: 'AliRegular',
},
bmiModalFormulaContainer: {
backgroundColor: '#F3F4F6',
borderRadius: 12,
padding: 16,
alignItems: 'center',
},
bmiModalFormulaText: {
fontSize: 14,
fontWeight: '600',
color: '#374151',
fontFamily: 'AliBold',
},
bmiModalSectionTitle: {
fontSize: 20,
fontWeight: '700',
color: '#111827',
marginBottom: 16,
letterSpacing: -0.5,
fontFamily: 'AliBold',
},
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',
fontFamily: 'AliBold',
},
bmiModalStatRange: {
fontSize: 14,
fontWeight: '600',
fontFamily: 'AliBold',
},
bmiModalStatAdvice: {
fontSize: 14,
lineHeight: 20,
fontFamily: 'AliRegular',
},
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,
fontFamily: 'AliRegular',
},
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,
fontFamily: 'AliRegular',
},
bmiModalBottomContainer: {
padding: 20,
paddingBottom: 34,
},
bmiModalContinueButton: {
marginBottom: 8,
},
bmiModalButtonBackground: {
backgroundColor: THEME_TEXT_PRIMARY,
borderRadius: 16,
paddingVertical: 16,
alignItems: 'center',
},
bmiModalButtonText: {
fontSize: 16,
fontWeight: '700',
color: '#FFFFFF',
fontFamily: 'AliBold',
},
bmiModalHomeIndicator: {
height: 5,
backgroundColor: '#D1D5DB',
borderRadius: 3,
alignSelf: 'center',
width: 36,
},
});