diff --git a/app/_layout.tsx b/app/_layout.tsx index 4fd73c3..81d744f 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -319,6 +319,7 @@ export default function RootLayout() { + diff --git a/app/developer.tsx b/app/developer.tsx index 4911da2..d136399 100644 --- a/app/developer.tsx +++ b/app/developer.tsx @@ -1,14 +1,26 @@ import { ROUTES } from '@/constants/Routes'; +import { STORAGE_KEYS } from '@/services/api'; +import AsyncStorage from '@/utils/kvStore'; +import { Ionicons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; import React from 'react'; -import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { Ionicons } from '@expo/vector-icons'; export default function DeveloperScreen() { const router = useRouter(); const insets = useSafeAreaInsets(); + const resetOnboardingStatus = async () => { + try { + await AsyncStorage.removeItem(STORAGE_KEYS.onboardingCompleted); + Alert.alert('成功', '引导状态已重置,下次启动应用将重新显示引导页面'); + } catch (error) { + console.error('重置引导状态失败:', error); + Alert.alert('错误', '重置引导状态失败,请重试'); + } + }; + const developerItems = [ { title: '日志', @@ -16,6 +28,12 @@ export default function DeveloperScreen() { icon: 'document-text-outline', onPress: () => router.push(ROUTES.DEVELOPER_LOGS), }, + { + title: '重置引导状态', + subtitle: '清除 onboarding 缓存,下次启动将重新显示引导页面', + icon: 'refresh-outline', + onPress: resetOnboardingStatus, + }, ]; return ( diff --git a/app/index.tsx b/app/index.tsx index dff440e..0ae6da5 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -6,8 +6,9 @@ import { preloadUserData } from '@/store/userSlice'; import { router } from 'expo-router'; import React, { useEffect, useState } from 'react'; import { ActivityIndicator, View } from 'react-native'; - -const ONBOARDING_COMPLETED_KEY = '@onboarding_completed'; +import AsyncStorage from '@/utils/kvStore'; +import { STORAGE_KEYS } from '@/services/api'; +const ONBOARDING_COMPLETED_KEY = STORAGE_KEYS.onboardingCompleted; export default function SplashScreen() { const backgroundColor = useThemeColor({}, 'background'); @@ -32,15 +33,13 @@ export default function SplashScreen() { console.warn('推送通知初始化失败,但不影响应用正常使用:', error); }); - // const onboardingCompleted = await AsyncStorage.getItem(ONBOARDING_COMPLETED_KEY); + const onboardingCompleted = await AsyncStorage.getItem(ONBOARDING_COMPLETED_KEY); - // if (onboardingCompleted === 'true') { - // router.replace('/(tabs)'); - // } else { - // router.replace('/onboarding'); - // } - // setIsLoading(false); - router.replace(ROUTES.TAB_STATISTICS); + if (onboardingCompleted === 'true') { + router.replace(ROUTES.TAB_STATISTICS); + } else { + router.replace(ROUTES.ONBOARDING); + } } catch (error) { console.error('检查引导状态或预加载用户数据失败:', error); // 如果出现错误,仍然进入应用,但可能会有状态更新 diff --git a/app/onboarding.tsx b/app/onboarding.tsx new file mode 100644 index 0000000..62c6c04 --- /dev/null +++ b/app/onboarding.tsx @@ -0,0 +1,287 @@ +import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; +import { LinearGradient } from 'expo-linear-gradient'; +import { useRouter } from 'expo-router'; +import { StatusBar } from 'expo-status-bar'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { + Animated, + Easing, + FlatList, + Image, + ImageSourcePropType, + NativeScrollEvent, + NativeSyntheticEvent, + Pressable, + StyleSheet, + Text, + TouchableOpacity, + View, + useWindowDimensions, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +import { palette } from '@/constants/Colors'; +import { ROUTES } from '@/constants/Routes'; +import { STORAGE_KEYS } from '@/services/api'; +import AsyncStorage from '@/utils/kvStore'; + +const ONBOARDING_COMPLETED_KEY = STORAGE_KEYS.onboardingCompleted; + +type OnboardingSlide = { + key: string; + title: string; + description: string; + image: ImageSourcePropType; +}; + +const SLIDES: OnboardingSlide[] = [ + { + key: 'statistics', + title: '全方位健康数据追踪', + description: '实时监测步数、心率、睡眠等多维度健康指标,助你全面了解身体状况。', + image: require('@/assets/images/onboarding/statistic.png'), + }, + { + key: 'insights', + title: '科学轻断食计划', + description: '个性化断食方案,智能提醒与进度追踪,助你改善代谢,科学控脂。', + image: require('@/assets/images/onboarding/fasting.jpg'), + }, + { + key: 'support', + title: '健康挑战赛', + description: '参与精选健康挑战,与好友一起打卡,保持每日运动动力。', + image: require('@/assets/images/onboarding/challange.jpg'), + }, +]; + +export default function OnboardingScreen() { + const router = useRouter(); + const { width } = useWindowDimensions(); + const [currentIndex, setCurrentIndex] = useState(0); + const listRef = useRef>(null); + const indicatorAnim = useRef(SLIDES.map((_, index) => new Animated.Value(index === 0 ? 1 : 0))).current; + const glassAvailable = isLiquidGlassAvailable(); + + useEffect(() => { + indicatorAnim.forEach((anim, index) => { + Animated.timing(anim, { + toValue: index === currentIndex ? 1 : 0, + duration: 250, + easing: Easing.out(Easing.quad), + useNativeDriver: false, + }).start(); + }); + }, [currentIndex, indicatorAnim]); + + const updateIndexFromScroll = useCallback( + (event: NativeSyntheticEvent) => { + const offsetX = event.nativeEvent.contentOffset.x; + const nextIndex = Math.round(offsetX / width); + if (!Number.isNaN(nextIndex) && nextIndex !== currentIndex) { + setCurrentIndex(nextIndex); + } + }, + [currentIndex, width], + ); + + const completeOnboarding = useCallback(async () => { + await AsyncStorage.setItem(ONBOARDING_COMPLETED_KEY, 'true'); + router.replace(ROUTES.TAB_STATISTICS); + }, [router]); + + const handleSkip = useCallback(() => { + completeOnboarding(); + }, [completeOnboarding]); + + const handleNext = useCallback(() => { + if (currentIndex < SLIDES.length - 1) { + const nextIndex = currentIndex + 1; + listRef.current?.scrollToOffset({ offset: nextIndex * width, animated: true }); + setCurrentIndex(nextIndex); + return; + } + completeOnboarding(); + }, [completeOnboarding, currentIndex, width]); + + const renderSlide = useCallback( + ({ item }: { item: OnboardingSlide }) => ( + + + + ), + [width], + ); + + const currentSlide = SLIDES[currentIndex]; + + return ( + + + + + 跳过 + + + + + item.key} + pagingEnabled + decelerationRate="fast" + bounces={false} + showsHorizontalScrollIndicator={false} + renderItem={renderSlide} + onMomentumScrollEnd={updateIndexFromScroll} + /> + + + + {SLIDES.map((slide, index) => { + const animatedStyle = { + width: indicatorAnim[index].interpolate({ + inputRange: [0, 1], + outputRange: [8, 24], + }), + backgroundColor: indicatorAnim[index].interpolate({ + inputRange: [0, 1], + outputRange: ['#D8D8D8', '#0066FF'], + }), + }; + return ; + })} + + + + {currentSlide.title} + {currentSlide.description} + + + + {glassAvailable ? ( + + + + {currentIndex === SLIDES.length - 1 ? '开始使用' : '下一步'} + + + ) : ( + + {currentIndex === SLIDES.length - 1 ? '开始使用' : '下一步'} + + )} + + + + ); +} + +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: '#FFFFFF', + }, + header: { + alignItems: 'flex-end', + paddingHorizontal: 24, + paddingTop: 12, + }, + skipText: { + fontSize: 16, + color: '#666C7A', + fontWeight: '500', + }, + carouselContainer: { + flex: 1, + marginTop: 20, + alignItems: 'center', + justifyContent: 'center', + }, + slide: { + height: 'auto', + justifyContent: 'center', + alignItems: 'center', + }, + slideImage: { + width: '85%', + height: '100%', + resizeMode: 'cover', + }, + body: { + paddingHorizontal: 24, + paddingBottom: 24, + }, + indicatorContainer: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + marginVertical: 24, + gap: 10, + }, + indicatorDot: { + height: 8, + borderRadius: 4, + }, + textContainer: { + alignItems: 'center', + marginBottom: 32, + }, + title: { + fontSize: 24, + color: '#222532', + fontWeight: '700', + textAlign: 'center', + marginBottom: 12, + }, + description: { + fontSize: 16, + color: '#5C6373', + textAlign: 'center', + lineHeight: 22, + paddingHorizontal: 8, + }, + primaryButtonWrapper: { + marginTop: 16, + }, + primaryButtonGlass: { + borderRadius: 24, + paddingVertical: 16, + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + shadowColor: palette.purple[600], + shadowOffset: { width: 0, height: 8 }, + shadowOpacity: 0.35, + shadowRadius: 16, + elevation: 6, + }, + primaryButtonFallback: { + borderRadius: 24, + }, + primaryButtonGradient: { + ...StyleSheet.absoluteFillObject, + }, + primaryButtonText: { + color: '#FFFFFF', + fontSize: 18, + fontWeight: '600', + }, +}); diff --git a/assets/images/onboarding/challange.jpg b/assets/images/onboarding/challange.jpg new file mode 100644 index 0000000..e29448a Binary files /dev/null and b/assets/images/onboarding/challange.jpg differ diff --git a/assets/images/onboarding/fasting.jpg b/assets/images/onboarding/fasting.jpg new file mode 100644 index 0000000..c8b212d Binary files /dev/null and b/assets/images/onboarding/fasting.jpg differ diff --git a/assets/images/onboarding/statistic.png b/assets/images/onboarding/statistic.png new file mode 100644 index 0000000..fb2f4be Binary files /dev/null and b/assets/images/onboarding/statistic.png differ diff --git a/constants/Routes.ts b/constants/Routes.ts index 5a244d4..9898ef5 100644 --- a/constants/Routes.ts +++ b/constants/Routes.ts @@ -57,6 +57,9 @@ export const ROUTES = { // 轻断食相关 FASTING_PLAN_DETAIL: '/fasting', + // 新用户引导 + ONBOARDING: '/onboarding', + // 目标管理路由 (已移至tab中) // GOAL_MANAGEMENT: '/goal-management', diff --git a/hooks/useAuthGuard.ts b/hooks/useAuthGuard.ts index ccd253f..bbff00c 100644 --- a/hooks/useAuthGuard.ts +++ b/hooks/useAuthGuard.ts @@ -5,7 +5,7 @@ import { Alert } from 'react-native'; import { ROUTES } from '@/constants/Routes'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; -import { api } from '@/services/api'; +import { STORAGE_KEYS, api } from '@/services/api'; import { logout as logoutAction } from '@/store/userSlice'; type RedirectParams = Record; @@ -99,7 +99,7 @@ export function useAuthGuard() { await api.delete('/api/users/delete-account'); // 清除额外的本地数据 - await AsyncStorage.multiRemove(['@user_personal_info', '@onboarding_completed']); + await AsyncStorage.multiRemove(['@user_personal_info', STORAGE_KEYS.onboardingCompleted]); // 执行退出登录逻辑 await dispatch(logoutAction()).unwrap(); @@ -149,4 +149,3 @@ export function useAuthGuard() { } as const; } - diff --git a/services/api.ts b/services/api.ts index 61752d4..74316c7 100644 --- a/services/api.ts +++ b/services/api.ts @@ -84,6 +84,7 @@ export const STORAGE_KEYS = { authToken: '@auth_token', userProfile: '@user_profile', privacyAgreed: '@privacy_agreed', + onboardingCompleted: '@onboarding_completed', } as const; @@ -231,4 +232,3 @@ export async function postTextStream(path: string, body: any, callbacks: TextStr return { abort, requestId }; } -