feat: 添加引导流程和个人信息收集功能

- 在应用中集成引导流程,用户首次启动时显示欢迎页面和个人信息收集页面
- 使用 AsyncStorage 存储用户的引导状态和个人信息
- 在个人页面中添加重置引导流程的功能
- 更新依赖项,添加 @react-native-async-storage/async-storage 库以支持数据存储
- 修改布局以支持新页面的导航和显示
This commit is contained in:
richarjiang
2025-08-12 14:12:59 +08:00
parent 70fea4defd
commit ddcc1320a4
10 changed files with 859 additions and 4 deletions

View File

@@ -2,9 +2,11 @@ import { Colors } from '@/constants/Colors';
import { getTabBarBottomPadding } from '@/constants/TabBar';
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 React, { useMemo, useState } from 'react';
import {
Alert,
SafeAreaView,
ScrollView,
StatusBar,
@@ -27,6 +29,32 @@ export default function PersonalScreen() {
const colorScheme = useColorScheme();
const colors = Colors[colorScheme ?? 'light'];
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}>
@@ -79,10 +107,14 @@ export default function PersonalScreen() {
<View style={styles.menuSection}>
<Text style={styles.sectionTitle}>{title}</Text>
{items.map((item, index) => (
<TouchableOpacity key={index} style={styles.menuItem}>
<TouchableOpacity
key={index}
style={styles.menuItem}
onPress={item.onPress}
>
<View style={styles.menuItemLeft}>
<View style={[styles.menuIcon]}>
<Ionicons name={item.icon} size={20} color={colors.primary} />
<Ionicons name={item.icon} size={20} color={item.iconColor || colors.primary} />
</View>
<Text style={styles.menuItemText}>{item.title}</Text>
</View>
@@ -197,6 +229,16 @@ export default function PersonalScreen() {
},
];
const developerItems = [
{
icon: 'refresh-outline',
iconBg: '#FFE8E8',
iconColor: '#FF4444',
title: '重置引导流程',
onPress: handleResetOnboarding,
},
];
return (
<View style={styles.container}>
<StatusBar barStyle="dark-content" backgroundColor="transparent" translucent />
@@ -211,6 +253,7 @@ export default function PersonalScreen() {
<MenuSection title="Account" items={accountItems} />
<MenuSection title="Notification" items={notificationItems} />
<MenuSection title="Other" items={otherItems} />
<MenuSection title="Developer" items={developerItems} />
{/* 底部浮动按钮 */}
<View style={[styles.floatingButtonContainer, { bottom: Math.max(30, tabBarHeight / 2) + (insets?.bottom ?? 0) }]}>

View File

@@ -20,6 +20,7 @@ export default function RootLayout() {
return (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack>
<Stack.Screen name="onboarding" options={{ headerShown: false }} />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="ai-posture-assessment" options={{ headerShown: false }} />
<Stack.Screen name="+not-found" />

71
app/index.tsx Normal file
View File

@@ -0,0 +1,71 @@
import { ThemedView } from '@/components/ThemedView';
import { useThemeColor } from '@/hooks/useThemeColor';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { router } from 'expo-router';
import React, { useEffect, useState } from 'react';
import { ActivityIndicator, Text, View } from 'react-native';
const ONBOARDING_COMPLETED_KEY = '@onboarding_completed';
export default function SplashScreen() {
const backgroundColor = useThemeColor({}, 'background');
const primaryColor = useThemeColor({}, 'primary');
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
checkOnboardingStatus();
}, []);
const checkOnboardingStatus = async () => {
try {
const onboardingCompleted = await AsyncStorage.getItem(ONBOARDING_COMPLETED_KEY);
// 添加一个短暂的延迟以显示启动画面
setTimeout(() => {
if (onboardingCompleted === 'true') {
router.replace('/(tabs)');
} else {
router.replace('/onboarding');
}
setIsLoading(false);
}, 1000);
} catch (error) {
console.error('检查引导状态失败:', error);
// 如果出现错误,默认显示引导页面
setTimeout(() => {
router.replace('/onboarding');
setIsLoading(false);
}, 1000);
}
};
if (!isLoading) {
return null;
}
return (
<ThemedView style={{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor
}}>
<View style={{
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: primaryColor,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 20,
}}>
<Text style={{
fontSize: 32,
}}>
🧘
</Text>
</View>
<ActivityIndicator size="large" color={primaryColor} />
</ThemedView>
);
}

View File

@@ -0,0 +1,10 @@
import { Stack } from 'expo-router';
export default function OnboardingLayout() {
return (
<Stack>
<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="personal-info" options={{ headerShown: false }} />
</Stack>
);
}

233
app/onboarding/index.tsx Normal file
View File

@@ -0,0 +1,233 @@
import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useThemeColor } from '@/hooks/useThemeColor';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { router } from 'expo-router';
import React from 'react';
import {
Dimensions,
StatusBar,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
const { width, height } = Dimensions.get('window');
export default function WelcomeScreen() {
const colorScheme = useColorScheme();
const backgroundColor = useThemeColor({}, 'background');
const primaryColor = useThemeColor({}, 'primary');
const textColor = useThemeColor({}, 'text');
const handleGetStarted = () => {
router.push('/onboarding/personal-info');
};
const handleSkip = async () => {
try {
await AsyncStorage.setItem('@onboarding_completed', 'true');
router.replace('/(tabs)');
} catch (error) {
console.error('保存引导状态失败:', error);
router.replace('/(tabs)');
}
};
return (
<ThemedView style={[styles.container, { backgroundColor }]}>
<StatusBar
barStyle={colorScheme === 'dark' ? 'light-content' : 'dark-content'}
backgroundColor={backgroundColor}
/>
{/* 跳过按钮 */}
<TouchableOpacity style={styles.skipButton} onPress={handleSkip}>
<ThemedText style={[styles.skipText, { color: textColor }]}>
</ThemedText>
</TouchableOpacity>
{/* 主要内容区域 */}
<View style={styles.contentContainer}>
{/* Logo 或插图区域 */}
<View style={styles.imageContainer}>
<View style={[styles.logoPlaceholder, { backgroundColor: primaryColor }]}>
<Text style={styles.logoText}>🧘</Text>
</View>
</View>
{/* 标题和描述 */}
<View style={styles.textContainer}>
<ThemedText type="title" style={styles.title}>
</ThemedText>
<ThemedText style={[styles.subtitle, { color: textColor + '90' }]}>
{'\n'}
</ThemedText>
</View>
{/* 特色功能点 */}
<View style={styles.featuresContainer}>
{[
{ icon: '📊', title: '个性化训练', desc: '根据您的身体状况定制训练计划' },
{ icon: '🤖', title: 'AI 姿态分析', desc: '实时纠正您的动作姿态' },
{ icon: '📈', title: '进度追踪', desc: '记录您的每一次进步' },
].map((feature, index) => (
<View key={index} style={styles.featureItem}>
<Text style={styles.featureIcon}>{feature.icon}</Text>
<View style={styles.featureTextContainer}>
<ThemedText style={[styles.featureTitle, { color: textColor }]}>
{feature.title}
</ThemedText>
<ThemedText style={[styles.featureDesc, { color: textColor + '70' }]}>
{feature.desc}
</ThemedText>
</View>
</View>
))}
</View>
</View>
{/* 底部按钮 */}
<View style={styles.buttonContainer}>
<TouchableOpacity
style={[styles.getStartedButton, { backgroundColor: primaryColor }]}
onPress={handleGetStarted}
activeOpacity={0.8}
>
<Text style={styles.getStartedButtonText}></Text>
</TouchableOpacity>
<TouchableOpacity style={styles.laterButton} onPress={handleSkip}>
<ThemedText style={[styles.laterButtonText, { color: textColor + '70' }]}>
</ThemedText>
</TouchableOpacity>
</View>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingTop: StatusBar.currentHeight || 44,
},
skipButton: {
position: 'absolute',
top: StatusBar.currentHeight ? StatusBar.currentHeight + 16 : 60,
right: 20,
zIndex: 10,
padding: 8,
},
skipText: {
fontSize: 16,
fontWeight: '500',
},
contentContainer: {
flex: 1,
paddingHorizontal: 24,
justifyContent: 'center',
},
imageContainer: {
alignItems: 'center',
marginBottom: 40,
},
logoPlaceholder: {
width: 120,
height: 120,
borderRadius: 60,
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 8,
},
logoText: {
fontSize: 48,
},
textContainer: {
alignItems: 'center',
marginBottom: 48,
},
title: {
textAlign: 'center',
marginBottom: 16,
fontWeight: '700',
},
subtitle: {
fontSize: 16,
textAlign: 'center',
lineHeight: 24,
paddingHorizontal: 12,
},
featuresContainer: {
marginBottom: 40,
},
featureItem: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 24,
paddingHorizontal: 8,
},
featureIcon: {
fontSize: 32,
marginRight: 16,
width: 40,
textAlign: 'center',
},
featureTextContainer: {
flex: 1,
},
featureTitle: {
fontSize: 18,
fontWeight: '600',
marginBottom: 4,
},
featureDesc: {
fontSize: 14,
lineHeight: 20,
},
buttonContainer: {
paddingHorizontal: 24,
paddingBottom: 48,
},
getStartedButton: {
height: 56,
borderRadius: 16,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 16,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 4,
},
getStartedButtonText: {
color: '#192126',
fontSize: 18,
fontWeight: '600',
},
laterButton: {
height: 48,
justifyContent: 'center',
alignItems: 'center',
},
laterButtonText: {
fontSize: 16,
fontWeight: '500',
},
});

View File

@@ -0,0 +1,426 @@
import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useThemeColor } from '@/hooks/useThemeColor';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { router } from 'expo-router';
import React, { useState } from 'react';
import {
Alert,
Dimensions,
ScrollView,
StatusBar,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
const { width } = Dimensions.get('window');
interface PersonalInfo {
gender: 'male' | 'female' | '';
age: string;
height: string;
weight: string;
}
export default function PersonalInfoScreen() {
const colorScheme = useColorScheme();
const backgroundColor = useThemeColor({}, 'background');
const primaryColor = useThemeColor({}, 'primary');
const textColor = useThemeColor({}, 'text');
const iconColor = useThemeColor({}, 'icon');
const [personalInfo, setPersonalInfo] = useState<PersonalInfo>({
gender: '',
age: '',
height: '',
weight: '',
});
const [currentStep, setCurrentStep] = useState(0);
const steps = [
{
title: '请选择您的性别',
subtitle: '这将帮助我们为您制定更合适的训练计划',
type: 'gender' as const,
},
{
title: '请输入您的年龄',
subtitle: '年龄信息有助于调整训练强度',
type: 'age' as const,
},
{
title: '请输入您的身高',
subtitle: '身高信息用于计算身体比例',
type: 'height' as const,
},
{
title: '请输入您的体重',
subtitle: '体重信息用于个性化训练方案',
type: 'weight' as const,
},
];
const handleGenderSelect = (gender: 'male' | 'female') => {
setPersonalInfo(prev => ({ ...prev, gender }));
};
const handleInputChange = (field: keyof PersonalInfo, value: string) => {
setPersonalInfo(prev => ({ ...prev, [field]: value }));
};
const handleNext = () => {
const currentStepType = steps[currentStep].type;
// 验证当前步骤是否已填写
if (currentStepType === 'gender' && !personalInfo.gender) {
Alert.alert('提示', '请选择您的性别');
return;
}
if (currentStepType === 'age' && !personalInfo.age) {
Alert.alert('提示', '请输入您的年龄');
return;
}
if (currentStepType === 'height' && !personalInfo.height) {
Alert.alert('提示', '请输入您的身高');
return;
}
if (currentStepType === 'weight' && !personalInfo.weight) {
Alert.alert('提示', '请输入您的体重');
return;
}
if (currentStep < steps.length - 1) {
setCurrentStep(currentStep + 1);
} else {
handleComplete();
}
};
const handlePrevious = () => {
if (currentStep > 0) {
setCurrentStep(currentStep - 1);
}
};
const handleSkip = async () => {
try {
await AsyncStorage.setItem('@onboarding_completed', 'true');
router.replace('/(tabs)');
} catch (error) {
console.error('保存引导状态失败:', error);
router.replace('/(tabs)');
}
};
const handleComplete = async () => {
try {
// 保存用户信息和引导完成状态
await AsyncStorage.multiSet([
['@onboarding_completed', 'true'],
['@user_personal_info', JSON.stringify(personalInfo)],
]);
console.log('用户信息:', personalInfo);
router.replace('/(tabs)');
} catch (error) {
console.error('保存用户信息失败:', error);
router.replace('/(tabs)');
}
};
const renderGenderSelection = () => (
<View style={styles.optionsContainer}>
<TouchableOpacity
style={[
styles.genderOption,
{ borderColor: primaryColor },
personalInfo.gender === 'female' && { backgroundColor: primaryColor + '20', borderWidth: 2 }
]}
onPress={() => handleGenderSelect('female')}
>
<Text style={styles.genderIcon}>👩</Text>
<ThemedText style={[styles.genderText, { color: textColor }]}></ThemedText>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.genderOption,
{ borderColor: primaryColor },
personalInfo.gender === 'male' && { backgroundColor: primaryColor + '20', borderWidth: 2 }
]}
onPress={() => handleGenderSelect('male')}
>
<Text style={styles.genderIcon}>👨</Text>
<ThemedText style={[styles.genderText, { color: textColor }]}></ThemedText>
</TouchableOpacity>
</View>
);
const renderNumberInput = (
field: 'age' | 'height' | 'weight',
placeholder: string,
unit: string
) => (
<View style={styles.inputContainer}>
<View style={[styles.inputWrapper, { borderColor: iconColor + '30' }]}>
<TextInput
style={[styles.numberInput, { color: textColor }]}
value={personalInfo[field]}
onChangeText={(value) => handleInputChange(field, value)}
placeholder={placeholder}
placeholderTextColor={iconColor}
keyboardType="numeric"
maxLength={field === 'age' ? 3 : 4}
/>
<ThemedText style={[styles.unitText, { color: iconColor }]}>{unit}</ThemedText>
</View>
</View>
);
const renderStepContent = () => {
const step = steps[currentStep];
switch (step.type) {
case 'gender':
return renderGenderSelection();
case 'age':
return renderNumberInput('age', '请输入年龄', '岁');
case 'height':
return renderNumberInput('height', '请输入身高', 'cm');
case 'weight':
return renderNumberInput('weight', '请输入体重', 'kg');
default:
return null;
}
};
const isStepCompleted = () => {
const currentStepType = steps[currentStep].type;
switch (currentStepType) {
case 'gender':
return !!personalInfo.gender;
case 'age':
return !!personalInfo.age;
case 'height':
return !!personalInfo.height;
case 'weight':
return !!personalInfo.weight;
default:
return false;
}
};
return (
<ThemedView style={[styles.container, { backgroundColor }]}>
<StatusBar
barStyle={colorScheme === 'dark' ? 'light-content' : 'dark-content'}
backgroundColor={backgroundColor}
/>
{/* 顶部导航 */}
<View style={styles.header}>
{currentStep > 0 && (
<TouchableOpacity style={styles.backButton} onPress={handlePrevious}>
<ThemedText style={[styles.backText, { color: textColor }]}> </ThemedText>
</TouchableOpacity>
)}
<TouchableOpacity style={styles.skipButton} onPress={handleSkip}>
<ThemedText style={[styles.skipText, { color: iconColor }]}></ThemedText>
</TouchableOpacity>
</View>
{/* 进度条 */}
<View style={styles.progressContainer}>
<View style={[styles.progressBackground, { backgroundColor: iconColor + '20' }]}>
<View
style={[
styles.progressBar,
{
backgroundColor: primaryColor,
width: `${((currentStep + 1) / steps.length) * 100}%`
}
]}
/>
</View>
<ThemedText style={[styles.progressText, { color: iconColor }]}>
{currentStep + 1} / {steps.length}
</ThemedText>
</View>
<ScrollView
style={styles.content}
contentContainerStyle={styles.contentContainer}
showsVerticalScrollIndicator={false}
>
{/* 标题区域 */}
<View style={styles.titleContainer}>
<ThemedText type="title" style={styles.title}>
{steps[currentStep].title}
</ThemedText>
<ThemedText style={[styles.subtitle, { color: textColor + '80' }]}>
{steps[currentStep].subtitle}
</ThemedText>
</View>
{/* 内容区域 */}
{renderStepContent()}
</ScrollView>
{/* 底部按钮 */}
<View style={styles.buttonContainer}>
<TouchableOpacity
style={[
styles.nextButton,
{ backgroundColor: isStepCompleted() ? primaryColor : iconColor + '30' }
]}
onPress={handleNext}
disabled={!isStepCompleted()}
activeOpacity={0.8}
>
<Text style={[
styles.nextButtonText,
{ color: isStepCompleted() ? '#192126' : iconColor }
]}>
{currentStep === steps.length - 1 ? '完成' : '下一步'}
</Text>
</TouchableOpacity>
</View>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingTop: StatusBar.currentHeight || 44,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 20,
paddingVertical: 16,
},
backButton: {
padding: 8,
},
backText: {
fontSize: 16,
fontWeight: '500',
},
skipButton: {
padding: 8,
},
skipText: {
fontSize: 16,
fontWeight: '500',
},
progressContainer: {
paddingHorizontal: 20,
marginBottom: 32,
},
progressBackground: {
height: 4,
borderRadius: 2,
marginBottom: 8,
},
progressBar: {
height: '100%',
borderRadius: 2,
},
progressText: {
fontSize: 12,
textAlign: 'right',
},
content: {
flex: 1,
},
contentContainer: {
paddingHorizontal: 24,
paddingBottom: 24,
},
titleContainer: {
marginBottom: 48,
},
title: {
textAlign: 'center',
marginBottom: 16,
fontWeight: '700',
},
subtitle: {
fontSize: 16,
textAlign: 'center',
lineHeight: 24,
},
optionsContainer: {
flexDirection: 'row',
justifyContent: 'space-around',
paddingHorizontal: 20,
},
genderOption: {
width: width * 0.35,
height: 120,
borderRadius: 16,
borderWidth: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 16,
},
genderIcon: {
fontSize: 48,
marginBottom: 8,
},
genderText: {
fontSize: 16,
fontWeight: '600',
},
inputContainer: {
alignItems: 'center',
},
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: 16,
width: width * 0.6,
height: 56,
},
numberInput: {
flex: 1,
fontSize: 18,
fontWeight: '600',
textAlign: 'center',
},
unitText: {
fontSize: 16,
fontWeight: '500',
marginLeft: 8,
},
buttonContainer: {
paddingHorizontal: 24,
paddingBottom: 48,
},
nextButton: {
height: 56,
borderRadius: 16,
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 4,
},
nextButtonText: {
fontSize: 18,
fontWeight: '600',
},
});