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

View File

@@ -4,6 +4,7 @@ import React from 'react';
import { Text, TouchableOpacity, View } from 'react-native'; import { Text, TouchableOpacity, View } from 'react-native';
import { IconSymbol } from '@/components/ui/IconSymbol'; import { IconSymbol } from '@/components/ui/IconSymbol';
import { TAB_BAR_BOTTOM_OFFSET, TAB_BAR_HEIGHT } from '@/constants/TabBar';
export default function TabLayout() { export default function TabLayout() {
const pathname = usePathname(); const pathname = usePathname();
@@ -51,8 +52,8 @@ export default function TabLayout() {
}, },
tabBarStyle: { tabBarStyle: {
position: 'absolute', position: 'absolute',
bottom: 20, bottom: TAB_BAR_BOTTOM_OFFSET,
height: 68, height: TAB_BAR_HEIGHT,
borderRadius: 34, borderRadius: 34,
backgroundColor: '#192126', backgroundColor: '#192126',
shadowColor: '#000', shadowColor: '#000',
@@ -69,7 +70,7 @@ export default function TabLayout() {
}, },
tabBarItemStyle: { tabBarItemStyle: {
backgroundColor: 'transparent', backgroundColor: 'transparent',
height: 68, height: TAB_BAR_HEIGHT,
marginTop: 0, marginTop: 0,
marginBottom: 0, marginBottom: 0,
paddingTop: 0, paddingTop: 0,

View File

@@ -1,110 +1,362 @@
import { Image } from 'expo-image'; import { ProgressBar } from '@/components/ProgressBar';
import { Platform, StyleSheet } from 'react-native'; import { Colors } from '@/constants/Colors';
import { getTabBarBottomPadding } from '@/constants/TabBar';
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
import { Ionicons } from '@expo/vector-icons';
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
SafeAreaView,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Collapsible } from '@/components/Collapsible'; export default function ExploreScreen() {
import { ExternalLink } from '@/components/ExternalLink'; // 使用 dayjs当月日期与默认选中“今天”
import ParallaxScrollView from '@/components/ParallaxScrollView'; const days = getMonthDaysZh();
import { ThemedText } from '@/components/ThemedText'; const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
import { ThemedView } from '@/components/ThemedView'; const tabBarHeight = useBottomTabBarHeight();
import { IconSymbol } from '@/components/ui/IconSymbol'; const insets = useSafeAreaInsets();
const bottomPadding = useMemo(() => {
return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0);
}, [tabBarHeight, insets?.bottom]);
const monthTitle = getMonthTitleZh();
// 日期条自动滚动到选中项
const daysScrollRef = useRef<import('react-native').ScrollView | null>(null);
const [scrollWidth, setScrollWidth] = useState(0);
const DAY_PILL_WIDTH = 68;
const DAY_PILL_SPACING = 12;
const scrollToIndex = (index: number, animated = true) => {
const baseOffset = index * (DAY_PILL_WIDTH + DAY_PILL_SPACING);
const centerOffset = Math.max(0, baseOffset - (scrollWidth / 2 - DAY_PILL_WIDTH / 2));
daysScrollRef.current?.scrollTo({ x: centerOffset, animated });
};
useEffect(() => {
if (scrollWidth > 0) {
scrollToIndex(selectedIndex, false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scrollWidth]);
export default function TabTwoScreen() {
return ( return (
<ParallaxScrollView <View style={styles.container}>
headerBackgroundColor={{ light: '#D0D0D0', dark: '#353636' }} <SafeAreaView style={styles.safeArea}>
headerImage={ <ScrollView
<IconSymbol style={styles.scrollView}
size={310} contentContainerStyle={{ paddingBottom: bottomPadding }}
color="#808080" showsVerticalScrollIndicator={false}
name="chevron.left.forwardslash.chevron.right" >
style={styles.headerImage} {/* 标题与日期选择 */}
/> <Text style={styles.monthTitle}>{monthTitle}</Text>
}> <ScrollView
<ThemedView style={styles.titleContainer}> horizontal
<ThemedText type="title">Explore</ThemedText> showsHorizontalScrollIndicator={false}
</ThemedView> contentContainerStyle={styles.daysContainer}
<ThemedText>This app includes example code to help you get started.</ThemedText> ref={daysScrollRef}
<Collapsible title="File-based routing"> onLayout={(e) => setScrollWidth(e.nativeEvent.layout.width)}
<ThemedText> >
This app has two screens:{' '} {days.map((d, i) => {
<ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> and{' '} const selected = i === selectedIndex;
<ThemedText type="defaultSemiBold">app/(tabs)/explore.tsx</ThemedText> return (
</ThemedText> <View key={`${d.dayOfMonth}`} style={styles.dayItemWrapper}>
<ThemedText> <TouchableOpacity
The layout file in <ThemedText type="defaultSemiBold">app/(tabs)/_layout.tsx</ThemedText>{' '} style={[styles.dayPill, selected ? styles.dayPillSelected : styles.dayPillNormal]}
sets up the tab navigator. onPress={() => {
</ThemedText> setSelectedIndex(i);
<ExternalLink href="https://docs.expo.dev/router/introduction"> scrollToIndex(i);
<ThemedText type="link">Learn more</ThemedText> }}
</ExternalLink> activeOpacity={0.8}
</Collapsible> >
<Collapsible title="Android, iOS, and web support"> <Text style={[styles.dayLabel, selected && styles.dayLabelSelected]}> {d.weekdayZh} </Text>
<ThemedText> <Text style={[styles.dayDate, selected && styles.dayDateSelected]}>{d.dayOfMonth}</Text>
You can open this project on Android, iOS, and the web. To open the web version, press{' '} </TouchableOpacity>
<ThemedText type="defaultSemiBold">w</ThemedText> in the terminal running this project. {selected && <View style={styles.selectedDot} />}
</ThemedText> </View>
</Collapsible> );
<Collapsible title="Images"> })}
<ThemedText> </ScrollView>
For static images, you can use the <ThemedText type="defaultSemiBold">@2x</ThemedText> and{' '}
<ThemedText type="defaultSemiBold">@3x</ThemedText> suffixes to provide files for {/* 今日报告 标题 */}
different screen densities <Text style={styles.sectionTitle}></Text>
</ThemedText>
<Image source={require('@/assets/images/react-logo.png')} style={{ alignSelf: 'center' }} /> {/* 指标行:左大卡(训练时间),右两小卡(消耗卡路里、步数) */}
<ExternalLink href="https://reactnative.dev/docs/images"> <View style={styles.metricsRow}>
<ThemedText type="link">Learn more</ThemedText> <View style={[styles.trainingCard, styles.metricsLeft]}>
</ExternalLink> <Text style={styles.cardTitleSecondary}></Text>
</Collapsible> <View style={styles.trainingContent}>
<Collapsible title="Custom fonts"> <View style={styles.trainingRingTrack} />
<ThemedText> <View style={styles.trainingRingProgress} />
Open <ThemedText type="defaultSemiBold">app/_layout.tsx</ThemedText> to see how to load{' '} <Text style={styles.trainingPercent}>80%</Text>
<ThemedText style={{ fontFamily: 'SpaceMono' }}> </View>
custom fonts such as this one. </View>
</ThemedText> <View style={styles.metricsRight}>
</ThemedText> <View style={[styles.metricsRightCard, styles.caloriesCard, { minHeight: 88 }]}>
<ExternalLink href="https://docs.expo.dev/versions/latest/sdk/font"> <Text style={styles.cardTitleSecondary}></Text>
<ThemedText type="link">Learn more</ThemedText> <Text style={styles.caloriesValue}>645 </Text>
</ExternalLink> </View>
</Collapsible> <View style={[styles.metricsRightCard, styles.stepsCard, { minHeight: 88 }]}>
<Collapsible title="Light and dark mode components"> <View style={styles.cardHeaderRow}>
<ThemedText> <View style={styles.iconSquare}><Ionicons name="footsteps-outline" size={18} color="#192126" /></View>
This template has light and dark mode support. The{' '} <Text style={styles.cardTitle}></Text>
<ThemedText type="defaultSemiBold">useColorScheme()</ThemedText> hook lets you inspect </View>
what the user&apos;s current color scheme is, and so you can adjust UI colors accordingly. <Text style={styles.stepsValue}>999/2000</Text>
</ThemedText> <ProgressBar progress={0.5} height={12} trackColor="#FFEBCB" fillColor="#FFC365" />
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/"> </View>
<ThemedText type="link">Learn more</ThemedText> </View>
</ExternalLink> </View>
</Collapsible> </ScrollView>
<Collapsible title="Animations"> </SafeAreaView>
<ThemedText> </View>
This template includes an example of an animated component. The{' '}
<ThemedText type="defaultSemiBold">components/HelloWave.tsx</ThemedText> component uses
the powerful <ThemedText type="defaultSemiBold">react-native-reanimated</ThemedText>{' '}
library to create a waving hand animation.
</ThemedText>
{Platform.select({
ios: (
<ThemedText>
The <ThemedText type="defaultSemiBold">components/ParallaxScrollView.tsx</ThemedText>{' '}
component provides a parallax effect for the header image.
</ThemedText>
),
})}
</Collapsible>
</ParallaxScrollView>
); );
} }
const primary = Colors.light.primary;
const styles = StyleSheet.create({ const styles = StyleSheet.create({
headerImage: { container: {
color: '#808080', flex: 1,
bottom: -90, backgroundColor: '#F6F7F8',
left: -35,
position: 'absolute',
}, },
titleContainer: { safeArea: {
flex: 1,
},
scrollView: {
flex: 1,
paddingHorizontal: 20,
},
monthTitle: {
fontSize: 24,
fontWeight: '800',
color: '#192126',
marginTop: 8,
marginBottom: 14,
},
daysContainer: {
paddingBottom: 8,
},
dayItemWrapper: {
alignItems: 'center',
width: 68,
marginRight: 12,
},
dayPill: {
width: 68,
height: 68,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
},
dayPillNormal: {
backgroundColor: '#C8F852',
},
dayPillSelected: {
backgroundColor: '#192126',
},
dayLabel: {
fontSize: 16,
fontWeight: '700',
color: '#192126',
marginBottom: 2,
},
dayLabelSelected: {
color: '#FFFFFF',
},
dayDate: {
fontSize: 16,
fontWeight: '800',
color: '#192126',
},
dayDateSelected: {
color: '#FFFFFF',
},
selectedDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: '#192126',
marginTop: 10,
marginBottom: 4,
alignSelf: 'center',
},
sectionTitle: {
fontSize: 24,
fontWeight: '800',
color: '#192126',
marginTop: 24,
marginBottom: 14,
},
metricsRow: {
flexDirection: 'row', flexDirection: 'row',
gap: 8, justifyContent: 'space-between',
marginBottom: 16,
}, },
card: {
backgroundColor: '#0F1418',
borderRadius: 22,
padding: 18,
marginBottom: 16,
},
metricsLeft: {
flex: 1,
backgroundColor: '#EEE9FF',
borderRadius: 22,
padding: 18,
marginRight: 12,
},
metricsRight: {
width: 160,
gap: 12,
},
metricsRightCard: {
backgroundColor: '#FFFFFF',
borderRadius: 22,
padding: 16,
},
caloriesCard: {
backgroundColor: '#FFFFFF',
},
trainingCard: {
backgroundColor: '#EEE9FF',
},
cardTitleSecondary: {
color: '#9AA3AE',
fontSize: 14,
fontWeight: '600',
marginBottom: 10,
},
caloriesValue: {
color: '#192126',
fontSize: 22,
fontWeight: '800',
},
trainingContent: {
marginTop: 8,
width: 120,
height: 120,
borderRadius: 60,
alignItems: 'center',
justifyContent: 'center',
alignSelf: 'center',
},
trainingRingTrack: {
position: 'absolute',
width: '100%',
height: '100%',
borderRadius: 60,
borderWidth: 12,
borderColor: '#E2D9FD',
},
trainingRingProgress: {
position: 'absolute',
width: '100%',
height: '100%',
borderRadius: 60,
borderWidth: 12,
borderColor: 'transparent',
borderTopColor: '#8B74F3',
borderRightColor: '#8B74F3',
transform: [{ rotateZ: '45deg' }],
},
trainingPercent: {
fontSize: 18,
fontWeight: '800',
color: '#8B74F3',
},
cyclingHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 12,
},
cyclingIconBadge: {
width: 30,
height: 30,
borderRadius: 6,
backgroundColor: primary,
alignItems: 'center',
justifyContent: 'center',
marginRight: 8,
},
cyclingTitle: {
color: '#FFFFFF',
fontSize: 20,
fontWeight: '800',
},
mapArea: {
backgroundColor: 'rgba(255,255,255,0.08)',
borderRadius: 14,
height: 180,
padding: 8,
flexDirection: 'row',
flexWrap: 'wrap',
overflow: 'hidden',
},
mapTile: {
width: '25%',
height: '25%',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.12)',
},
routeLine: {
position: 'absolute',
height: 6,
backgroundColor: primary,
borderRadius: 3,
},
cardHeaderRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 12,
},
iconSquare: {
width: 30,
height: 30,
borderRadius: 8,
backgroundColor: '#FFFFFF',
alignItems: 'center',
justifyContent: 'center',
marginRight: 10,
},
cardTitle: {
fontSize: 18,
fontWeight: '800',
color: '#192126',
},
heartCard: {
backgroundColor: '#FFE5E5',
},
waveContainer: {
flexDirection: 'row',
alignItems: 'flex-end',
height: 70,
gap: 6,
marginBottom: 8,
},
waveBar: {
width: 6,
borderRadius: 3,
backgroundColor: '#E54D4D',
},
heartValue: {
alignSelf: 'flex-end',
color: '#5B5B5B',
fontWeight: '600',
},
stepsCard: {
backgroundColor: '#FFE4B8',
},
stepsValue: {
fontSize: 16,
color: '#7A6A42',
fontWeight: '700',
marginBottom: 8,
}
}); });

View File

@@ -1,31 +1,24 @@
import { PlanCard } from '@/components/PlanCard';
import { SearchBox } from '@/components/SearchBox'; import { SearchBox } from '@/components/SearchBox';
import { ThemedText } from '@/components/ThemedText'; import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView'; import { ThemedView } from '@/components/ThemedView';
import { WorkoutCard } from '@/components/WorkoutCard'; import { WorkoutCard } from '@/components/WorkoutCard';
import { getChineseGreeting } from '@/utils/date';
import React from 'react'; import React from 'react';
import { SafeAreaView, ScrollView, StyleSheet, View } from 'react-native'; import { SafeAreaView, ScrollView, StyleSheet, View } from 'react-native';
const workoutData = [ const workoutData = [
{ {
id: 1, id: 1,
title: '体态评估', title: 'AI体态评估',
duration: 50, duration: 5,
imageSource: require('@/assets/images/react-logo.png'), imageSource: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/Imagettpg.png',
}, },
{ {
id: 2, id: 2,
title: 'Hand\nTraining', title: '认证教练',
calories: 600,
duration: 40,
imageSource: require('@/assets/images/react-logo.png'), imageSource: require('@/assets/images/react-logo.png'),
}, }
{
id: 3,
title: 'Core\nWorkout',
calories: 450,
duration: 35,
imageSource: require('@/assets/images/react-logo.png'),
},
]; ];
export default function HomeScreen() { export default function HomeScreen() {
@@ -35,16 +28,16 @@ export default function HomeScreen() {
<ScrollView showsVerticalScrollIndicator={false}> <ScrollView showsVerticalScrollIndicator={false}>
{/* Header Section */} {/* Header Section */}
<View style={styles.header}> <View style={styles.header}>
<ThemedText style={styles.greeting}>Good Morning 🔥</ThemedText> <ThemedText style={styles.greeting}>{getChineseGreeting()} 🔥</ThemedText>
<ThemedText style={styles.userName}>Pramuditya Uzumaki</ThemedText> <ThemedText style={styles.userName}></ThemedText>
</View> </View>
{/* Search Box */} {/* Search Box */}
<SearchBox placeholder="Search" /> <SearchBox placeholder="搜索" />
{/* Popular Workouts Section */} {/* Popular Workouts Section */}
<View style={styles.sectionContainer}> <View style={styles.sectionContainer}>
<ThemedText style={styles.sectionTitle}>Popular Workouts</ThemedText> <ThemedText style={styles.sectionTitle}></ThemedText>
<ScrollView <ScrollView
horizontal horizontal
@@ -56,7 +49,6 @@ export default function HomeScreen() {
<WorkoutCard <WorkoutCard
key={workout.id} key={workout.id}
title={workout.title} title={workout.title}
calories={workout.calories}
duration={workout.duration} duration={workout.duration}
imageSource={workout.imageSource} imageSource={workout.imageSource}
onPress={() => console.log(`Pressed ${workout.title}`)} onPress={() => console.log(`Pressed ${workout.title}`)}
@@ -65,6 +57,30 @@ export default function HomeScreen() {
</ScrollView> </ScrollView>
</View> </View>
{/* Today Plan Section */}
<View style={styles.sectionContainer}>
<ThemedText style={styles.sectionTitle}></ThemedText>
<View style={styles.planList}>
<PlanCard
image={'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/Imagettpg.png'}
title="体态评估"
subtitle="评估你的体态,制定训练计划"
level="初学者"
progress={0}
/>
<PlanCard
image={'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/Image30play@2x.png'}
title="30日训练打卡"
subtitle="坚持30天养成训练习惯"
level="初学者"
progress={0.75}
/>
</View>
</View>
{/* Add some spacing at the bottom */} {/* Add some spacing at the bottom */}
<View style={styles.bottomSpacing} /> <View style={styles.bottomSpacing} />
</ScrollView> </ScrollView>
@@ -109,6 +125,9 @@ const styles = StyleSheet.create({
paddingHorizontal: 24, paddingHorizontal: 24,
marginBottom: 18, marginBottom: 18,
}, },
planList: {
paddingHorizontal: 24,
},
workoutScroll: { workoutScroll: {
paddingLeft: 24, paddingLeft: 24,
}, },

View File

@@ -1,7 +1,9 @@
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { getTabBarBottomPadding } from '@/constants/TabBar';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import React, { useState } from 'react'; import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
import React, { useMemo, useState } from 'react';
import { import {
SafeAreaView, SafeAreaView,
ScrollView, ScrollView,
@@ -12,18 +14,19 @@ import {
TouchableOpacity, TouchableOpacity,
View View
} from 'react-native'; } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export default function PersonalScreen() { export default function PersonalScreen() {
const insets = useSafeAreaInsets();
const tabBarHeight = useBottomTabBarHeight();
const bottomPadding = useMemo(() => {
// 统一的页面底部留白TabBar 高度 + TabBar 与底部的额外间距 + 安全区底部
return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0);
}, [tabBarHeight, insets?.bottom]);
const [notificationEnabled, setNotificationEnabled] = useState(true); const [notificationEnabled, setNotificationEnabled] = useState(true);
const colorScheme = useColorScheme(); const colorScheme = useColorScheme();
const colors = Colors[colorScheme ?? 'light']; const colors = Colors[colorScheme ?? 'light'];
const ProfileHeader = () => (
<View style={styles.headerContainer}>
{/* 标题 */}
<Text style={styles.headerTitle}>Profile</Text>
</View>
);
const UserInfoSection = () => ( const UserInfoSection = () => (
<View style={styles.userInfoCard}> <View style={styles.userInfoCard}>
@@ -47,10 +50,10 @@ export default function PersonalScreen() {
<Text style={styles.userProgram}>Lose a Fat Program</Text> <Text style={styles.userProgram}>Lose a Fat Program</Text>
</View> </View>
{/* 编辑按钮 */} {/* 编辑按钮 */}
<TouchableOpacity style={dynamicStyles.editButton}> <TouchableOpacity style={dynamicStyles.editButton}>
<Text style={dynamicStyles.editButtonText}>Edit</Text> <Text style={dynamicStyles.editButtonText}>Edit</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
); );
@@ -198,8 +201,11 @@ export default function PersonalScreen() {
<View style={styles.container}> <View style={styles.container}>
<StatusBar barStyle="dark-content" backgroundColor="transparent" translucent /> <StatusBar barStyle="dark-content" backgroundColor="transparent" translucent />
<SafeAreaView style={styles.safeArea}> <SafeAreaView style={styles.safeArea}>
<ScrollView style={styles.scrollView} showsVerticalScrollIndicator={false}> <ScrollView
<ProfileHeader /> style={styles.scrollView}
contentContainerStyle={{ paddingBottom: bottomPadding }}
showsVerticalScrollIndicator={false}
>
<UserInfoSection /> <UserInfoSection />
<StatsSection /> <StatsSection />
<MenuSection title="Account" items={accountItems} /> <MenuSection title="Account" items={accountItems} />
@@ -207,7 +213,7 @@ export default function PersonalScreen() {
<MenuSection title="Other" items={otherItems} /> <MenuSection title="Other" items={otherItems} />
{/* 底部浮动按钮 */} {/* 底部浮动按钮 */}
<View style={styles.floatingButtonContainer}> <View style={[styles.floatingButtonContainer, { bottom: Math.max(30, tabBarHeight / 2) + (insets?.bottom ?? 0) }]}>
<TouchableOpacity style={dynamicStyles.floatingButton}> <TouchableOpacity style={dynamicStyles.floatingButton}>
<Ionicons name="search" size={24} color="#192126" /> <Ionicons name="search" size={24} color="#192126" />
</TouchableOpacity> </TouchableOpacity>
@@ -231,21 +237,6 @@ const styles = StyleSheet.create({
paddingHorizontal: 20, paddingHorizontal: 20,
backgroundColor: '#F5F5F5', backgroundColor: '#F5F5F5',
}, },
// 头部导航
headerContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingTop: 10,
paddingBottom: 20,
},
headerTitle: {
fontSize: 20,
fontWeight: 'bold',
color: '#000',
},
// 用户信息区域 // 用户信息区域
userInfoCard: { userInfoCard: {
borderRadius: 16, borderRadius: 16,

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

10
constants/TabBar.ts Normal file
View File

@@ -0,0 +1,10 @@
export const TAB_BAR_HEIGHT = 68;
export const TAB_BAR_BOTTOM_OFFSET = 20;
// 为需要避让底部 TabBar 的页面提供一个统一的底部内边距计算
export const getTabBarBottomPadding = (measuredTabBarHeight?: number) => {
const height = Math.max(measuredTabBarHeight ?? 0, TAB_BAR_HEIGHT);
return height + TAB_BAR_BOTTOM_OFFSET;
};

7
package-lock.json generated
View File

@@ -12,6 +12,7 @@
"@react-navigation/bottom-tabs": "^7.3.10", "@react-navigation/bottom-tabs": "^7.3.10",
"@react-navigation/elements": "^2.3.8", "@react-navigation/elements": "^2.3.8",
"@react-navigation/native": "^7.1.6", "@react-navigation/native": "^7.1.6",
"dayjs": "^1.11.13",
"expo": "~53.0.20", "expo": "~53.0.20",
"expo-blur": "~14.1.5", "expo-blur": "~14.1.5",
"expo-constants": "~17.1.7", "expo-constants": "~17.1.7",
@@ -5242,6 +5243,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/dayjs": {
"version": "1.11.13",
"resolved": "https://mirrors.tencent.com/npm/dayjs/-/dayjs-1.11.13.tgz",
"integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==",
"license": "MIT"
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.1", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",

View File

@@ -15,6 +15,7 @@
"@react-navigation/bottom-tabs": "^7.3.10", "@react-navigation/bottom-tabs": "^7.3.10",
"@react-navigation/elements": "^2.3.8", "@react-navigation/elements": "^2.3.8",
"@react-navigation/native": "^7.1.6", "@react-navigation/native": "^7.1.6",
"dayjs": "^1.11.13",
"expo": "~53.0.20", "expo": "~53.0.20",
"expo-blur": "~14.1.5", "expo-blur": "~14.1.5",
"expo-constants": "~17.1.7", "expo-constants": "~17.1.7",
@@ -41,9 +42,9 @@
"devDependencies": { "devDependencies": {
"@babel/core": "^7.25.2", "@babel/core": "^7.25.2",
"@types/react": "~19.0.10", "@types/react": "~19.0.10",
"typescript": "~5.8.3",
"eslint": "^9.25.0", "eslint": "^9.25.0",
"eslint-config-expo": "~9.2.0" "eslint-config-expo": "~9.2.0",
"typescript": "~5.8.3"
}, },
"private": true "private": true
} }

59
utils/date.ts Normal file
View File

@@ -0,0 +1,59 @@
import dayjs, { Dayjs } from 'dayjs';
import 'dayjs/locale/zh-cn';
dayjs.locale('zh-cn');
/**
* 返回基于当前时间的中文问候语:早上好 / 下午好 / 晚上好
* - 早上05:00 - 11:59
* - 下午12:00 - 17:59
* - 晚上18:00 - 04:59
*/
export function getChineseGreeting(now: Date = new Date()): string {
const hour = now.getHours();
if (hour >= 5 && hour < 12) {
return '早上好';
}
if (hour >= 12 && hour < 18) {
return '下午好';
}
return '晚上好';
}
/** 获取中文月份标题例如2025年8月 */
export function getMonthTitleZh(date: Dayjs = dayjs()): string {
return date.format('YYYY年M月');
}
export type MonthDay = {
/** 中文星期:日/一/二/三/四/五/六 */
weekdayZh: string;
/** 月内第几日1-31 */
dayOfMonth: number;
/** 对应的 dayjs 对象 */
date: Dayjs;
};
/** 获取某月的所有日期(中文星期+日号) */
export function getMonthDaysZh(date: Dayjs = dayjs()): MonthDay[] {
const year = date.year();
const monthIndex = date.month();
const daysInMonth = date.daysInMonth();
const zhWeek = ['日', '一', '二', '三', '四', '五', '六'];
return Array.from({ length: daysInMonth }, (_, i) => {
const d = dayjs(new Date(year, monthIndex, i + 1));
return {
weekdayZh: zhWeek[d.day()],
dayOfMonth: i + 1,
date: d,
};
});
}
/** 获取“今天”在当月的索引0 基) */
export function getTodayIndexInMonth(date: Dayjs = dayjs()): number {
return date.date() - 1;
}