feat: 新增健康档案模块,支持家庭邀请与个人健康数据管理
This commit is contained in:
278
components/weight/WeightProgressBar.tsx
Normal file
278
components/weight/WeightProgressBar.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user