feat: 添加日历功能和进度条组件
- 在项目中引入 dayjs 库以处理日期 - 新增 PlanCard 和 ProgressBar 组件,分别用于展示训练计划和进度条 - 更新首页以显示推荐的训练计划 - 优化个人中心页面的底部留白处理 - 本地化界面文本为中文
This commit is contained in:
@@ -4,6 +4,7 @@ import React from 'react';
|
||||
import { Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
import { IconSymbol } from '@/components/ui/IconSymbol';
|
||||
import { TAB_BAR_BOTTOM_OFFSET, TAB_BAR_HEIGHT } from '@/constants/TabBar';
|
||||
|
||||
export default function TabLayout() {
|
||||
const pathname = usePathname();
|
||||
@@ -51,8 +52,8 @@ export default function TabLayout() {
|
||||
},
|
||||
tabBarStyle: {
|
||||
position: 'absolute',
|
||||
bottom: 20,
|
||||
height: 68,
|
||||
bottom: TAB_BAR_BOTTOM_OFFSET,
|
||||
height: TAB_BAR_HEIGHT,
|
||||
borderRadius: 34,
|
||||
backgroundColor: '#192126',
|
||||
shadowColor: '#000',
|
||||
@@ -69,7 +70,7 @@ export default function TabLayout() {
|
||||
},
|
||||
tabBarItemStyle: {
|
||||
backgroundColor: 'transparent',
|
||||
height: 68,
|
||||
height: TAB_BAR_HEIGHT,
|
||||
marginTop: 0,
|
||||
marginBottom: 0,
|
||||
paddingTop: 0,
|
||||
|
||||
@@ -1,110 +1,362 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { Platform, StyleSheet } from 'react-native';
|
||||
import { ProgressBar } from '@/components/ProgressBar';
|
||||
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';
|
||||
import { ExternalLink } from '@/components/ExternalLink';
|
||||
import ParallaxScrollView from '@/components/ParallaxScrollView';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { IconSymbol } from '@/components/ui/IconSymbol';
|
||||
export default function ExploreScreen() {
|
||||
// 使用 dayjs:当月日期与默认选中“今天”
|
||||
const days = getMonthDaysZh();
|
||||
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
|
||||
const tabBarHeight = useBottomTabBarHeight();
|
||||
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 (
|
||||
<ParallaxScrollView
|
||||
headerBackgroundColor={{ light: '#D0D0D0', dark: '#353636' }}
|
||||
headerImage={
|
||||
<IconSymbol
|
||||
size={310}
|
||||
color="#808080"
|
||||
name="chevron.left.forwardslash.chevron.right"
|
||||
style={styles.headerImage}
|
||||
/>
|
||||
}>
|
||||
<ThemedView style={styles.titleContainer}>
|
||||
<ThemedText type="title">Explore</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedText>This app includes example code to help you get started.</ThemedText>
|
||||
<Collapsible title="File-based routing">
|
||||
<ThemedText>
|
||||
This app has two screens:{' '}
|
||||
<ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> and{' '}
|
||||
<ThemedText type="defaultSemiBold">app/(tabs)/explore.tsx</ThemedText>
|
||||
</ThemedText>
|
||||
<ThemedText>
|
||||
The layout file in <ThemedText type="defaultSemiBold">app/(tabs)/_layout.tsx</ThemedText>{' '}
|
||||
sets up the tab navigator.
|
||||
</ThemedText>
|
||||
<ExternalLink href="https://docs.expo.dev/router/introduction">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title="Android, iOS, and web support">
|
||||
<ThemedText>
|
||||
You can open this project on Android, iOS, and the web. To open the web version, press{' '}
|
||||
<ThemedText type="defaultSemiBold">w</ThemedText> in the terminal running this project.
|
||||
</ThemedText>
|
||||
</Collapsible>
|
||||
<Collapsible title="Images">
|
||||
<ThemedText>
|
||||
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
|
||||
</ThemedText>
|
||||
<Image source={require('@/assets/images/react-logo.png')} style={{ alignSelf: 'center' }} />
|
||||
<ExternalLink href="https://reactnative.dev/docs/images">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title="Custom fonts">
|
||||
<ThemedText>
|
||||
Open <ThemedText type="defaultSemiBold">app/_layout.tsx</ThemedText> to see how to load{' '}
|
||||
<ThemedText style={{ fontFamily: 'SpaceMono' }}>
|
||||
custom fonts such as this one.
|
||||
</ThemedText>
|
||||
</ThemedText>
|
||||
<ExternalLink href="https://docs.expo.dev/versions/latest/sdk/font">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title="Light and dark mode components">
|
||||
<ThemedText>
|
||||
This template has light and dark mode support. The{' '}
|
||||
<ThemedText type="defaultSemiBold">useColorScheme()</ThemedText> hook lets you inspect
|
||||
what the user's current color scheme is, and so you can adjust UI colors accordingly.
|
||||
</ThemedText>
|
||||
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title="Animations">
|
||||
<ThemedText>
|
||||
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>
|
||||
<View style={styles.container}>
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={{ paddingBottom: bottomPadding }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* 标题与日期选择 */}
|
||||
<Text style={styles.monthTitle}>{monthTitle}</Text>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.daysContainer}
|
||||
ref={daysScrollRef}
|
||||
onLayout={(e) => setScrollWidth(e.nativeEvent.layout.width)}
|
||||
>
|
||||
{days.map((d, i) => {
|
||||
const selected = i === selectedIndex;
|
||||
return (
|
||||
<View key={`${d.dayOfMonth}`} style={styles.dayItemWrapper}>
|
||||
<TouchableOpacity
|
||||
style={[styles.dayPill, selected ? styles.dayPillSelected : styles.dayPillNormal]}
|
||||
onPress={() => {
|
||||
setSelectedIndex(i);
|
||||
scrollToIndex(i);
|
||||
}}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={[styles.dayLabel, selected && styles.dayLabelSelected]}> {d.weekdayZh} </Text>
|
||||
<Text style={[styles.dayDate, selected && styles.dayDateSelected]}>{d.dayOfMonth}</Text>
|
||||
</TouchableOpacity>
|
||||
{selected && <View style={styles.selectedDot} />}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
|
||||
{/* 今日报告 标题 */}
|
||||
<Text style={styles.sectionTitle}>今日报告</Text>
|
||||
|
||||
{/* 指标行:左大卡(训练时间),右两小卡(消耗卡路里、步数) */}
|
||||
<View style={styles.metricsRow}>
|
||||
<View style={[styles.trainingCard, styles.metricsLeft]}>
|
||||
<Text style={styles.cardTitleSecondary}>训练时间</Text>
|
||||
<View style={styles.trainingContent}>
|
||||
<View style={styles.trainingRingTrack} />
|
||||
<View style={styles.trainingRingProgress} />
|
||||
<Text style={styles.trainingPercent}>80%</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.metricsRight}>
|
||||
<View style={[styles.metricsRightCard, styles.caloriesCard, { minHeight: 88 }]}>
|
||||
<Text style={styles.cardTitleSecondary}>消耗卡路里</Text>
|
||||
<Text style={styles.caloriesValue}>645 千卡</Text>
|
||||
</View>
|
||||
<View style={[styles.metricsRightCard, styles.stepsCard, { minHeight: 88 }]}>
|
||||
<View style={styles.cardHeaderRow}>
|
||||
<View style={styles.iconSquare}><Ionicons name="footsteps-outline" size={18} color="#192126" /></View>
|
||||
<Text style={styles.cardTitle}>步数</Text>
|
||||
</View>
|
||||
<Text style={styles.stepsValue}>999/2000</Text>
|
||||
<ProgressBar progress={0.5} height={12} trackColor="#FFEBCB" fillColor="#FFC365" />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const primary = Colors.light.primary;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
headerImage: {
|
||||
color: '#808080',
|
||||
bottom: -90,
|
||||
left: -35,
|
||||
position: 'absolute',
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F6F7F8',
|
||||
},
|
||||
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',
|
||||
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,
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,31 +1,24 @@
|
||||
import { PlanCard } from '@/components/PlanCard';
|
||||
import { SearchBox } from '@/components/SearchBox';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { WorkoutCard } from '@/components/WorkoutCard';
|
||||
import { getChineseGreeting } from '@/utils/date';
|
||||
import React from 'react';
|
||||
import { SafeAreaView, ScrollView, StyleSheet, View } from 'react-native';
|
||||
|
||||
const workoutData = [
|
||||
{
|
||||
id: 1,
|
||||
title: '体态评估',
|
||||
duration: 50,
|
||||
imageSource: require('@/assets/images/react-logo.png'),
|
||||
title: 'AI体态评估',
|
||||
duration: 5,
|
||||
imageSource: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/Imagettpg.png',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Hand\nTraining',
|
||||
calories: 600,
|
||||
duration: 40,
|
||||
title: '认证教练',
|
||||
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() {
|
||||
@@ -35,16 +28,16 @@ export default function HomeScreen() {
|
||||
<ScrollView showsVerticalScrollIndicator={false}>
|
||||
{/* Header Section */}
|
||||
<View style={styles.header}>
|
||||
<ThemedText style={styles.greeting}>Good Morning 🔥</ThemedText>
|
||||
<ThemedText style={styles.userName}>Pramuditya Uzumaki</ThemedText>
|
||||
<ThemedText style={styles.greeting}>{getChineseGreeting()} 🔥</ThemedText>
|
||||
<ThemedText style={styles.userName}>新学员,欢迎你</ThemedText>
|
||||
</View>
|
||||
|
||||
{/* Search Box */}
|
||||
<SearchBox placeholder="Search" />
|
||||
<SearchBox placeholder="搜索" />
|
||||
|
||||
{/* Popular Workouts Section */}
|
||||
<View style={styles.sectionContainer}>
|
||||
<ThemedText style={styles.sectionTitle}>Popular Workouts</ThemedText>
|
||||
<ThemedText style={styles.sectionTitle}>热门活动</ThemedText>
|
||||
|
||||
<ScrollView
|
||||
horizontal
|
||||
@@ -56,7 +49,6 @@ export default function HomeScreen() {
|
||||
<WorkoutCard
|
||||
key={workout.id}
|
||||
title={workout.title}
|
||||
calories={workout.calories}
|
||||
duration={workout.duration}
|
||||
imageSource={workout.imageSource}
|
||||
onPress={() => console.log(`Pressed ${workout.title}`)}
|
||||
@@ -65,6 +57,30 @@ export default function HomeScreen() {
|
||||
</ScrollView>
|
||||
</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 */}
|
||||
<View style={styles.bottomSpacing} />
|
||||
</ScrollView>
|
||||
@@ -109,6 +125,9 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: 24,
|
||||
marginBottom: 18,
|
||||
},
|
||||
planList: {
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
workoutScroll: {
|
||||
paddingLeft: 24,
|
||||
},
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
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 {
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
@@ -12,18 +14,19 @@ import {
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
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 colorScheme = useColorScheme();
|
||||
const colors = Colors[colorScheme ?? 'light'];
|
||||
|
||||
const ProfileHeader = () => (
|
||||
<View style={styles.headerContainer}>
|
||||
{/* 标题 */}
|
||||
<Text style={styles.headerTitle}>Profile</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
const UserInfoSection = () => (
|
||||
<View style={styles.userInfoCard}>
|
||||
@@ -47,10 +50,10 @@ export default function PersonalScreen() {
|
||||
<Text style={styles.userProgram}>Lose a Fat Program</Text>
|
||||
</View>
|
||||
|
||||
{/* 编辑按钮 */}
|
||||
<TouchableOpacity style={dynamicStyles.editButton}>
|
||||
<Text style={dynamicStyles.editButtonText}>Edit</Text>
|
||||
</TouchableOpacity>
|
||||
{/* 编辑按钮 */}
|
||||
<TouchableOpacity style={dynamicStyles.editButton}>
|
||||
<Text style={dynamicStyles.editButtonText}>Edit</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -198,8 +201,11 @@ export default function PersonalScreen() {
|
||||
<View style={styles.container}>
|
||||
<StatusBar barStyle="dark-content" backgroundColor="transparent" translucent />
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<ScrollView style={styles.scrollView} showsVerticalScrollIndicator={false}>
|
||||
<ProfileHeader />
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={{ paddingBottom: bottomPadding }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<UserInfoSection />
|
||||
<StatsSection />
|
||||
<MenuSection title="Account" items={accountItems} />
|
||||
@@ -207,7 +213,7 @@ export default function PersonalScreen() {
|
||||
<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}>
|
||||
<Ionicons name="search" size={24} color="#192126" />
|
||||
</TouchableOpacity>
|
||||
@@ -231,21 +237,6 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: 20,
|
||||
backgroundColor: '#F5F5F5',
|
||||
},
|
||||
// 头部导航
|
||||
headerContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingTop: 10,
|
||||
paddingBottom: 20,
|
||||
},
|
||||
|
||||
headerTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: '#000',
|
||||
},
|
||||
|
||||
// 用户信息区域
|
||||
userInfoCard: {
|
||||
borderRadius: 16,
|
||||
|
||||
92
components/PlanCard.tsx
Normal file
92
components/PlanCard.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
87
components/ProgressBar.tsx
Normal file
87
components/ProgressBar.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
10
constants/TabBar.ts
Normal file
10
constants/TabBar.ts
Normal 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
7
package-lock.json
generated
@@ -12,6 +12,7 @@
|
||||
"@react-navigation/bottom-tabs": "^7.3.10",
|
||||
"@react-navigation/elements": "^2.3.8",
|
||||
"@react-navigation/native": "^7.1.6",
|
||||
"dayjs": "^1.11.13",
|
||||
"expo": "~53.0.20",
|
||||
"expo-blur": "~14.1.5",
|
||||
"expo-constants": "~17.1.7",
|
||||
@@ -5242,6 +5243,12 @@
|
||||
"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": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"@react-navigation/bottom-tabs": "^7.3.10",
|
||||
"@react-navigation/elements": "^2.3.8",
|
||||
"@react-navigation/native": "^7.1.6",
|
||||
"dayjs": "^1.11.13",
|
||||
"expo": "~53.0.20",
|
||||
"expo-blur": "~14.1.5",
|
||||
"expo-constants": "~17.1.7",
|
||||
@@ -41,9 +42,9 @@
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@types/react": "~19.0.10",
|
||||
"typescript": "~5.8.3",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-config-expo": "~9.2.0"
|
||||
"eslint-config-expo": "~9.2.0",
|
||||
"typescript": "~5.8.3"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
|
||||
59
utils/date.ts
Normal file
59
utils/date.ts
Normal 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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user