feat: 添加日历功能和进度条组件

- 在项目中引入 dayjs 库以处理日期
- 新增 PlanCard 和 ProgressBar 组件,分别用于展示训练计划和进度条
- 更新首页以显示推荐的训练计划
- 优化个人中心页面的底部留白处理
- 本地化界面文本为中文
This commit is contained in:
richarjiang
2025-08-12 09:16:59 +08:00
parent 1646085428
commit 9796c614ed
11 changed files with 680 additions and 159 deletions

92
components/PlanCard.tsx Normal file
View File

@@ -0,0 +1,92 @@
import { ProgressBar } from '@/components/ProgressBar';
import React from 'react';
import { Image, StyleSheet, Text, View } from 'react-native';
type Level = '初学者' | '中级' | '高级';
type PlanCardProps = {
image: string;
title: string;
subtitle: string;
level: Level;
progress: number; // 0 - 1
};
export function PlanCard({ image, title, subtitle, level, progress }: PlanCardProps) {
return (
<View style={styles.card}>
<Image source={{ uri: image }} style={styles.image} />
<View style={styles.content}>
<View style={styles.badgeContainer}>
<View style={styles.badge}>
<Text style={styles.badgeText}>{level}</Text>
</View>
</View>
<Text style={styles.title}>{title}</Text>
<Text style={styles.subtitle}>{subtitle}</Text>
<View style={styles.progressWrapper}>
<ProgressBar progress={progress} showLabel={true} />
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
card: {
flexDirection: 'row',
backgroundColor: '#FFFFFF',
borderRadius: 28,
padding: 20,
marginBottom: 18,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.06,
shadowRadius: 12,
elevation: 3,
},
image: {
width: 100,
height: 100,
borderRadius: 22,
},
content: {
flex: 1,
paddingLeft: 18,
justifyContent: 'center',
},
badgeContainer: {
position: 'absolute',
top: -10,
right: -10,
},
badge: {
backgroundColor: '#192126',
borderTopRightRadius: 12,
borderBottomLeftRadius: 12,
paddingHorizontal: 14,
paddingVertical: 6,
},
badgeText: {
color: '#FFFFFF',
fontSize: 12,
fontWeight: '500',
},
title: {
fontSize: 16,
color: '#192126',
fontWeight: '800',
},
subtitle: {
marginTop: 8,
fontSize: 12,
color: '#B1B6BD',
},
progressWrapper: {
marginTop: 18,
},
});

View File

@@ -0,0 +1,87 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Animated, Easing, LayoutChangeEvent, StyleSheet, Text, View, ViewStyle } from 'react-native';
type ProgressBarProps = {
progress: number; // 0 - 1
height?: number;
style?: ViewStyle;
trackColor?: string;
fillColor?: string;
animated?: boolean;
showLabel?: boolean;
};
export function ProgressBar({
progress,
height = 18,
style,
trackColor = '#EDEDED',
fillColor = '#BBF246',
animated = true,
showLabel = true,
}: ProgressBarProps) {
const [trackWidth, setTrackWidth] = useState(0);
const animatedValue = useRef(new Animated.Value(0)).current;
const clamped = useMemo(() => {
if (Number.isNaN(progress)) return 0;
return Math.min(1, Math.max(0, progress));
}, [progress]);
useEffect(() => {
if (!animated) {
animatedValue.setValue(clamped);
return;
}
Animated.timing(animatedValue, {
toValue: clamped,
duration: 650,
easing: Easing.out(Easing.cubic),
useNativeDriver: false,
}).start();
}, [clamped, animated, animatedValue]);
const onLayout = (e: LayoutChangeEvent) => {
setTrackWidth(e.nativeEvent.layout.width);
};
const fillWidth = Animated.multiply(animatedValue, trackWidth || 1);
const percent = Math.round(clamped * 100);
return (
<View style={[styles.container, { height }, style]} onLayout={onLayout}>
<View style={[styles.track, { backgroundColor: trackColor }]} />
<Animated.View style={[styles.fill, { width: fillWidth, backgroundColor: fillColor }]}>
{showLabel && (
<Text style={styles.label}>{percent}%</Text>
)}
</Animated.View>
</View>
);
}
const styles = StyleSheet.create({
container: {
width: '100%',
borderRadius: 10,
overflow: 'hidden',
backgroundColor: 'transparent',
},
track: {
...StyleSheet.absoluteFillObject,
borderRadius: 10,
},
fill: {
height: '100%',
borderRadius: 10,
justifyContent: 'center',
paddingHorizontal: 10,
},
label: {
color: '#192126',
fontWeight: '700',
fontSize: 12,
},
});

View File

@@ -4,8 +4,8 @@ import { ImageBackground, StyleSheet, Text, TouchableOpacity, View } from 'react
interface WorkoutCardProps {
title: string;
calories: number;
duration: number;
calories?: number;
duration?: number;
imageSource: any;
onPress?: () => void;
}
@@ -14,7 +14,7 @@ export function WorkoutCard({ title, calories, duration, imageSource, onPress }:
return (
<TouchableOpacity style={styles.container} onPress={onPress}>
<ImageBackground
source={imageSource}
source={{ uri: imageSource }}
style={styles.backgroundImage}
imageStyle={styles.imageStyle}
>
@@ -31,10 +31,12 @@ export function WorkoutCard({ title, calories, duration, imageSource, onPress }:
</View>
)}
<View style={styles.statItem}>
<Ionicons name="time-outline" size={16} color="#fff" />
<Text style={styles.statText}>{duration} Min</Text>
</View>
{duration !== undefined && (
<View style={styles.statItem}>
<Ionicons name="time-outline" size={16} color="#fff" />
<Text style={styles.statText}>{duration} Min</Text>
</View>
)}
</View>
</View>