Files
digital-pilates/components/weight/WeightHistoryCard.tsx
richarjiang 63ed820e93 feat(ui): 统一健康卡片标题图标并优化语音录音稳定性
- 为所有健康数据卡片添加对应功能图标,提升视觉一致性
- 将“小鱼干”文案统一为“能量值”,并更新获取说明
- 语音录音页面增加组件卸载保护、错误提示与资源清理逻辑
- 个人页支持毛玻璃按钮样式,默认用户名置空
- 新增血氧、饮食、心情、压力、睡眠、步数、体重等图标资源
- 升级 react-native-purchases 至 9.4.3
- 移除 useAuthGuard 调试日志
2025-09-16 09:35:50 +08:00

574 lines
16 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 { 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 { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useEffect, useState } from 'react';
import {
Dimensions,
Modal,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
import Svg, { Circle, Path } from 'react-native-svg';
const { width: screenWidth } = Dimensions.get('window');
const CARD_WIDTH = screenWidth - 40; // 减去左右边距
const CHART_WIDTH = CARD_WIDTH - 36; // 减去卡片内边距
const CHART_HEIGHT = 60;
const PADDING = 10;
export function WeightHistoryCard() {
const dispatch = useAppDispatch();
const userProfile = useAppSelector((s) => s.user.profile);
const weightHistory = useAppSelector((s) => s.user.weightHistory);
const [showBMIModal, setShowBMIModal] = useState(false);
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
const colorScheme = useColorScheme();
const themeColors = Colors[colorScheme ?? 'light'];
const hasWeight = userProfile?.weight && parseFloat(userProfile.weight) > 0;
useEffect(() => {
loadWeightHistory();
}, [userProfile?.weight, isLoggedIn]);
const loadWeightHistory = async () => {
try {
await dispatch(fetchWeightHistory() as any).unwrap();
} catch (error) {
console.error('加载体重历史失败:', error);
}
};
const navigateToCoach = () => {
pushIfAuthedElseLogin(ROUTES.WEIGHT_RECORDS);
};
const handleHideBMIModal = () => {
setShowBMIModal(false);
};
const navigateToWeightRecords = () => {
pushIfAuthedElseLogin(ROUTES.WEIGHT_RECORDS);
};
// 如果没有体重数据,显示引导卡片
if (!hasWeight) {
return (
<TouchableOpacity style={styles.card} onPress={navigateToWeightRecords} activeOpacity={0.8}>
<View style={styles.cardHeader}>
<Image
source={require('@/assets/images/icons/icon-weight.png')}
style={styles.iconSquare}
/>
<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}>
<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 + 8 + (1 - normalizedWeight) * (CHART_HEIGHT - 2 * PADDING - 16);
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}>
<Image
source={require('@/assets/images/icons/icon-weight.png')}
style={styles.iconSquare}
/>
<Text style={styles.cardTitle}></Text>
<TouchableOpacity
style={styles.addButton}
onPress={(e) => {
e.stopPropagation();
navigateToCoach();
}}
activeOpacity={0.8}
>
<Ionicons name="add" size={18} color={Colors.light.primary} />
</TouchableOpacity>
</View>
{/* 默认显示图表 */}
{sortedHistory.length > 0 && (
<View style={styles.chartContainer}>
<Svg width={CHART_WIDTH} height={CHART_HEIGHT + 15}>
{/* 背景网格线 */}
{/* 更抽象的折线 - 减小线宽和显示的细节 */}
<Path
d={singlePointPath}
stroke={Colors.light.accentGreen}
strokeWidth={2}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
opacity={0.8}
/>
{/* 简化的数据点 - 更小更精致 */}
{points.map((point, index) => {
const isLastPoint = index === points.length - 1;
return (
<React.Fragment key={index}>
<Circle
cx={point.x}
cy={point.y}
r={isLastPoint ? 3 : 2}
fill={Colors.light.accentGreen}
opacity={0.9}
/>
</React.Fragment>
);
})}
</Svg>
{/* 精简的图表信息 */}
<View style={styles.chartInfo}>
<View style={styles.infoItem}>
<Text style={styles.infoLabel}>{userProfile.weight}kg</Text>
</View>
<View style={styles.infoItem}>
<Text style={styles.infoLabel}>{sortedHistory.length}</Text>
</View>
<View style={styles.infoItem}>
<Text style={styles.infoLabel}>
{minWeight.toFixed(1)}-{maxWeight.toFixed(1)}kg
</Text>
</View>
</View>
</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: 16,
marginBottom: 4,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 3,
marginTop: 16
},
cardHeader: {
flexDirection: 'row',
alignItems: 'center',
},
iconSquare: {
width: 14,
height: 14,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
marginRight: 4,
},
cardTitle: {
fontSize: 14,
color: '#192126',
flex: 1,
fontWeight: '600'
},
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: {
width: '100%',
alignItems: 'center',
marginTop: 12,
},
chartInfo: {
flexDirection: 'row',
justifyContent: 'space-around',
width: '100%',
},
infoItem: {
alignItems: 'center',
},
infoLabel: {
fontSize: 11,
color: '#687076',
fontWeight: '500',
},
infoValue: {
fontSize: 14,
fontWeight: '700',
color: '#192126',
},
// 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,
},
});