将应用主色调从 '#BBF246' 更改为 '#87CEEB'(天空蓝),并更新所有相关组件和页面中的颜色引用。同时为多个页面添加统一的渐变背景,提升视觉效果和用户体验。新增压力分析模态框组件,并优化压力计组件的交互与显示逻辑。更新应用图标和启动图资源。
445 lines
13 KiB
TypeScript
445 lines
13 KiB
TypeScript
import { Colors } from '@/constants/Colors';
|
||
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;
|
||
|
||
|
||
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={Colors.light.primary}
|
||
/>
|
||
</TouchableOpacity>
|
||
<TouchableOpacity
|
||
style={styles.addButton}
|
||
onPress={navigateToCoach}
|
||
activeOpacity={0.8}
|
||
>
|
||
<Ionicons name="add" size={16} color={Colors.light.primary} />
|
||
</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>
|
||
</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={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>
|
||
)}
|
||
</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,
|
||
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,
|
||
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',
|
||
},
|
||
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: {
|
||
},
|
||
summaryRow: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-around',
|
||
marginBottom: 6,
|
||
},
|
||
summaryItem: {
|
||
alignItems: 'center',
|
||
},
|
||
summaryLabel: {
|
||
fontSize: 12,
|
||
color: '#687076',
|
||
marginBottom: 4,
|
||
},
|
||
summaryValue: {
|
||
fontSize: 14,
|
||
fontWeight: '700',
|
||
color: '#192126',
|
||
},
|
||
});
|