Files
digital-pilates/app/(tabs)/personal.tsx
richarjiang f3e6250505 feat: 添加训练计划和打卡功能
- 新增训练计划页面,允许用户制定个性化的训练计划
- 集成打卡功能,用户可以记录每日的训练情况
- 更新 Redux 状态管理,添加训练计划相关的 reducer
- 在首页中添加训练计划卡片,支持用户点击跳转
- 更新样式和布局,以适应新功能的展示和交互
- 添加日期选择器和相关依赖,支持用户选择训练日期
2025-08-13 09:10:00 +08:00

467 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Colors } from '@/constants/Colors';
import { getTabBarBottomPadding } from '@/constants/TabBar';
import { useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { Ionicons } from '@expo/vector-icons';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
import { useFocusEffect } from '@react-navigation/native';
import type { Href } from 'expo-router';
import { router } from 'expo-router';
import React, { useEffect, useMemo, useState } from 'react';
import { Alert, SafeAreaView, ScrollView, StatusBar, StyleSheet, Switch, Text, 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 theme = (colorScheme ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
type UserProfile = {
fullName?: string;
email?: string;
gender?: 'male' | 'female' | '';
age?: string;
weightKg?: number;
heightCm?: number;
avatarUri?: string | null;
};
const userProfileFromRedux = useAppSelector((s) => s.user.profile);
const [profile, setProfile] = useState<UserProfile>({});
const load = async () => {
try {
const [p, o] = await Promise.all([
AsyncStorage.getItem('@user_profile'),
AsyncStorage.getItem('@user_personal_info'),
]);
let next: UserProfile = {};
if (o) {
try {
const parsed = JSON.parse(o);
next = {
...next,
age: parsed?.age ? String(parsed.age) : undefined,
gender: parsed?.gender || '',
heightCm: parsed?.height ? parseFloat(parsed.height) : undefined,
weightKg: parsed?.weight ? parseFloat(parsed.weight) : undefined,
};
} catch { }
}
if (p) {
try { next = { ...next, ...JSON.parse(p) }; } catch { }
}
setProfile(next);
} catch (e) {
console.warn('加载用户资料失败', e);
}
};
useEffect(() => { load(); }, []);
useFocusEffect(React.useCallback(() => { load(); return () => { }; }, []));
useEffect(() => {
if (userProfileFromRedux) {
setProfile(userProfileFromRedux);
}
}, [userProfileFromRedux]);
const formatHeight = () => {
if (profile.heightCm == null) return '--';
return `${Math.round(profile.heightCm)}cm`;
};
const formatWeight = () => {
if (profile.weightKg == null) return '--';
return `${round(profile.weightKg, 1)}kg`;
};
const formatAge = () => (profile.age ? `${profile.age}` : '--');
const round = (n: number, d = 0) => {
const p = Math.pow(10, d); return Math.round(n * p) / p;
};
const handleResetOnboarding = () => {
Alert.alert(
'重置引导',
'确定要重置引导流程吗?下次启动应用时将重新显示引导页面。',
[
{
text: '取消',
style: 'cancel',
},
{
text: '确定',
style: 'destructive',
onPress: async () => {
try {
await AsyncStorage.multiRemove(['@onboarding_completed', '@user_personal_info']);
Alert.alert('成功', '引导状态已重置,请重启应用查看效果。');
} catch (error) {
console.error('重置引导状态失败:', error);
Alert.alert('错误', '重置失败,请稍后重试。');
}
},
},
]
);
};
const UserInfoSection = () => (
<View style={styles.userInfoCard}>
<View style={styles.userInfoContainer}>
{/* 头像 */}
<View style={styles.avatarContainer}>
<View style={styles.avatar}>
<View style={styles.avatarContent}>
{/* 简单的头像图标,您可以替换为实际图片 */}
<View style={styles.avatarIcon}>
<View style={styles.avatarFace} />
<View style={styles.avatarBody} />
</View>
</View>
</View>
</View>
{/* 用户信息 */}
<View style={styles.userDetails}>
<Text style={styles.userName}>{profile.fullName || '未设置姓名'}</Text>
</View>
{/* 编辑按钮 */}
<TouchableOpacity style={dynamicStyles.editButton} onPress={() => router.push('/profile/edit')}>
<Text style={dynamicStyles.editButtonText}></Text>
</TouchableOpacity>
</View>
</View>
);
const StatsSection = () => (
<View style={styles.statsContainer}>
<View style={styles.statItem}>
<Text style={dynamicStyles.statValue}>{formatHeight()}</Text>
<Text style={styles.statLabel}></Text>
</View>
<View style={styles.statItem}>
<Text style={dynamicStyles.statValue}>{formatWeight()}</Text>
<Text style={styles.statLabel}></Text>
</View>
<View style={styles.statItem}>
<Text style={dynamicStyles.statValue}>{formatAge()}</Text>
<Text style={styles.statLabel}></Text>
</View>
</View>
);
const MenuSection = ({ title, items }: { title: string; items: any[] }) => (
<View style={styles.menuSection}>
<Text style={styles.sectionTitle}>{title}</Text>
{items.map((item, index) => (
<TouchableOpacity
key={index}
style={styles.menuItem}
onPress={item.onPress}
>
<View style={styles.menuItemLeft}>
<View style={[styles.menuIcon]}>
<Ionicons name={item.icon} size={20} color={item.iconColor || colors.primary} />
</View>
<Text style={styles.menuItemText}>{item.title}</Text>
</View>
{item.type === 'switch' ? (
<Switch
value={notificationEnabled}
onValueChange={setNotificationEnabled}
trackColor={{ false: '#E5E5E5', true: colors.primary }}
thumbColor="#FFFFFF"
style={styles.switch}
/>
) : (
<Ionicons name="chevron-forward" size={20} color="#C4C4C4" />
)}
</TouchableOpacity>
))}
</View>
);
// 动态创建样式
const dynamicStyles = {
editButton: {
backgroundColor: colors.primary,
paddingHorizontal: 20,
paddingVertical: 10,
borderRadius: 20,
},
editButtonText: {
color: '#192126',
fontSize: 14,
fontWeight: '600' as const,
},
statValue: {
fontSize: 18,
fontWeight: 'bold' as const,
color: colors.primary,
marginBottom: 4,
},
floatingButton: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: colors.primary,
alignItems: 'center' as const,
justifyContent: 'center' as const,
shadowColor: colors.primary,
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
},
};
const accountItems = [
{
icon: 'flag-outline',
iconBg: '#E8F5E8',
iconColor: '#4ADE80',
title: '目标管理',
onPress: () => router.push('/profile/goals' as Href),
},
{
icon: 'stats-chart-outline',
iconBg: '#E8F5E8',
iconColor: '#4ADE80',
title: '训练进度',
},
];
const notificationItems = [
{
icon: 'notifications-outline',
iconBg: '#E8F5E8',
iconColor: '#4ADE80',
title: '消息推送',
type: 'switch',
},
];
const otherItems = [
{
icon: 'mail-outline',
iconBg: '#E8F5E8',
iconColor: '#4ADE80',
title: '联系我们',
},
{
icon: 'shield-checkmark-outline',
iconBg: '#E8F5E8',
iconColor: '#4ADE80',
title: '隐私政策',
},
{
icon: 'settings-outline',
iconBg: '#E8F5E8',
iconColor: '#4ADE80',
title: '设置',
},
];
const developerItems = [
{
icon: 'refresh-outline',
iconBg: '#FFE8E8',
iconColor: '#FF4444',
title: '重置引导流程',
onPress: handleResetOnboarding,
},
];
return (
<View style={[styles.container, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
<StatusBar barStyle="dark-content" backgroundColor="transparent" translucent />
<SafeAreaView style={[styles.safeArea, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
<ScrollView
style={[styles.scrollView, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}
contentContainerStyle={{ paddingBottom: bottomPadding }}
showsVerticalScrollIndicator={false}
>
<UserInfoSection />
<StatsSection />
<MenuSection title="账户" items={accountItems} />
<MenuSection title="通知" items={notificationItems} />
<MenuSection title="其他" items={otherItems} />
<MenuSection title="开发者" items={developerItems} />
{/* 底部浮动按钮 */}
<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>
</View>
</ScrollView>
</SafeAreaView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F5F5F5', // 浅灰色背景
},
safeArea: {
flex: 1,
},
scrollView: {
flex: 1,
paddingHorizontal: 20,
backgroundColor: '#F5F5F5',
},
// 用户信息区域
userInfoCard: {
borderRadius: 16,
marginBottom: 20,
},
userInfoContainer: {
flexDirection: 'row',
alignItems: 'center',
padding: 20,
},
avatarContainer: {
marginRight: 15,
},
avatar: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: '#E8D4F0',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
avatarContent: {
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: 'center',
},
avatarIcon: {
alignItems: 'center',
justifyContent: 'center',
},
avatarFace: {
width: 25,
height: 25,
borderRadius: 12.5,
backgroundColor: '#D4A574',
marginBottom: 5,
},
avatarBody: {
width: 30,
height: 20,
borderRadius: 15,
backgroundColor: '#F4C842',
},
userDetails: {
flex: 1,
},
userName: {
fontSize: 18,
fontWeight: 'bold',
color: '#000',
marginBottom: 4,
},
// 统计信息区域
statsContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 20,
marginBottom: 20,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
statItem: {
alignItems: 'center',
flex: 1,
},
statLabel: {
fontSize: 12,
color: '#888',
},
// 菜单区域
menuSection: {
marginBottom: 20,
backgroundColor: '#FFFFFF',
padding: 16,
},
sectionTitle: {
fontSize: 18,
fontWeight: 'bold',
color: '#000',
marginBottom: 12,
paddingHorizontal: 4,
},
menuItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 16,
paddingHorizontal: 16,
borderRadius: 12,
marginBottom: 8,
},
menuItemLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
menuIcon: {
width: 36,
height: 36,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
menuItemText: {
fontSize: 16,
color: '#000',
flex: 1,
},
switch: {
transform: [{ scaleX: 0.8 }, { scaleY: 0.8 }],
},
// 浮动按钮
floatingButtonContainer: {
position: 'absolute',
bottom: 30,
left: 0,
right: 0,
alignItems: 'center',
pointerEvents: 'box-none',
},
});