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

279 lines
7.2 KiB
TypeScript

import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Animated,
Easing,
StyleSheet,
Text,
View,
ViewStyle
} from 'react-native';
// 主题色
const THEME_PRIMARY = '#4F5BD5';
const THEME_SECONDARY = '#6B6CFF';
const THEME_SUCCESS = '#22C55E';
const THEME_TEXT_SECONDARY = '#6f7ba7';
export interface WeightProgressBarProps {
/** 进度值 0-1 */
progress: number;
/** 当前体重 */
currentWeight: number;
/** 目标体重 */
targetWeight: number;
/** 初始体重 */
initialWeight: number;
/** 容器样式 */
style?: ViewStyle;
/** 是否显示顶部分隔线,默认 true */
showTopBorder?: boolean;
}
export const WeightProgressBar: React.FC<WeightProgressBarProps> = ({
progress,
currentWeight,
targetWeight,
initialWeight,
style,
showTopBorder = true,
}) => {
const { t } = useTranslation();
const animatedProgress = useRef(new Animated.Value(0)).current;
const [barWidth, setBarWidth] = useState(0);
const clampedProgress = Math.min(1, Math.max(0, progress));
const percent = Math.round(clampedProgress * 100);
// 判断是否有有效数据
const hasInitialWeight = initialWeight > 0;
const hasTargetWeight = targetWeight > 0;
const hasCurrentWeight = currentWeight > 0;
// 只要有初始体重和当前体重,就可以显示已减重量
const canShowLost = hasInitialWeight && hasCurrentWeight;
// 需要有目标体重才能显示距离目标和进度
const canShowTarget = hasTargetWeight && hasCurrentWeight;
useEffect(() => {
// 延迟 500ms 开始动画,避免页面刚进入时卡顿
const timer = setTimeout(() => {
Animated.timing(animatedProgress, {
toValue: clampedProgress,
duration: 800,
easing: Easing.out(Easing.cubic),
useNativeDriver: false,
}).start();
}, 800);
return () => clearTimeout(timer);
}, [clampedProgress]);
const fillWidth = animatedProgress.interpolate({
inputRange: [0, 1],
outputRange: [0, barWidth],
});
const sliderPosition = animatedProgress.interpolate({
inputRange: [0, 1],
outputRange: [-12, barWidth - 12],
});
const weightLost = initialWeight - currentWeight;
const weightToGo = currentWeight - targetWeight;
return (
<View style={[
styles.container,
showTopBorder && styles.topBorder,
style
]}>
{/* 进度信息 */}
<View style={styles.infoRow}>
<View style={styles.infoItem}>
<Text style={styles.infoLabel}>{t('statistics.components.weight.progress.lost')}</Text>
<Text style={[styles.infoValue, { color: canShowLost && weightLost >= 0 ? THEME_SUCCESS : (canShowLost ? '#FF6B6B' : THEME_TEXT_SECONDARY) }]}>
{canShowLost ? `${weightLost >= 0 ? '-' : '+'}${Math.abs(weightLost).toFixed(1)}kg` : '--'}
</Text>
</View>
<View style={styles.percentContainer}>
<Text style={styles.percentValue}>{percent}</Text>
<Text style={styles.percentSymbol}>%</Text>
</View>
<View style={[styles.infoItem, { alignItems: 'flex-end' }]}>
<Text style={styles.infoLabel}>{t('statistics.components.weight.progress.toGo')}</Text>
<Text style={[styles.infoValue, { color: THEME_PRIMARY }]}>
{canShowTarget ? `${weightToGo > 0 ? weightToGo.toFixed(1) : '0'}kg` : '--'}
</Text>
</View>
</View>
{/* 进度条 */}
<View
style={styles.trackContainer}
onLayout={(e) => setBarWidth(e.nativeEvent.layout.width)}
>
{/* 背景轨道 */}
<View style={styles.track} />
{/* 填充进度 */}
<Animated.View style={[styles.fill, { width: fillWidth }]}>
<LinearGradient
colors={[THEME_PRIMARY, THEME_SECONDARY]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={StyleSheet.absoluteFillObject}
/>
</Animated.View>
{/* 滑块 - 圆角矩形 */}
<Animated.View style={[styles.slider, { left: sliderPosition }]}>
<LinearGradient
colors={['#ffffff', '#f8f9fc']}
style={styles.sliderInner}
>
<View style={styles.sliderLine} />
</LinearGradient>
</Animated.View>
</View>
{/* 起止标签 */}
<View style={styles.labelRow}>
<Text style={styles.labelText}>{hasInitialWeight ? `${initialWeight.toFixed(1)}kg` : '--'}</Text>
<View style={styles.targetBadge}>
<Ionicons name="flag" size={10} color={THEME_PRIMARY} />
<Text style={styles.targetText}>{hasTargetWeight ? `${targetWeight.toFixed(1)}kg` : '--'}</Text>
</View>
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
marginTop: 12,
paddingTop: 10,
marginLeft:12,
marginRight: 12
},
topBorder: {
borderTopWidth: 1,
borderTopColor: 'rgba(0,0,0,0.04)',
},
infoRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
infoItem: {
flex: 1,
},
infoLabel: {
fontSize: 11,
color: THEME_TEXT_SECONDARY,
fontFamily: 'AliRegular',
marginBottom: 2,
},
infoValue: {
fontSize: 14,
fontWeight: '700',
fontFamily: 'AliBold',
},
percentContainer: {
flexDirection: 'row',
alignItems: 'baseline',
justifyContent: 'center',
},
percentValue: {
fontSize: 24,
fontWeight: '800',
color: THEME_PRIMARY,
fontFamily: 'AliBold',
},
percentSymbol: {
fontSize: 12,
fontWeight: '600',
color: THEME_PRIMARY,
fontFamily: 'AliBold',
marginLeft: 2,
},
trackContainer: {
height: 8,
position: 'relative',
marginBottom: 8,
},
track: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
backgroundColor: '#E8EAF0',
borderRadius: 4,
},
fill: {
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
borderRadius: 4,
overflow: 'hidden',
},
slider: {
position: 'absolute',
top: -8,
width: 24,
height: 24,
borderRadius: 8,
shadowColor: THEME_PRIMARY,
shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.35,
shadowRadius: 6,
elevation: 6,
},
sliderInner: {
width: '100%',
height: '100%',
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 2.5,
borderColor: THEME_PRIMARY,
},
sliderLine: {
width: 8,
height: 3,
borderRadius: 1.5,
backgroundColor: THEME_PRIMARY,
},
labelRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
labelText: {
fontSize: 11,
color: THEME_TEXT_SECONDARY,
fontFamily: 'AliRegular',
},
targetBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(79, 91, 213, 0.1)',
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 10,
gap: 4,
},
targetText: {
fontSize: 11,
color: THEME_PRIMARY,
fontWeight: '600',
fontFamily: 'AliBold',
},
});
export default WeightProgressBar;