Files
digital-pilates/app/onboarding.tsx
richarjiang 8cffbb990a refactor(init): 优化应用初始化流程,将权限请求延迟到引导完成后
- 将服务初始化拆分为基础服务和权限相关服务两个阶段
- 基础服务(用户数据、HealthKit初始化、快捷动作等)在应用启动时立即执行
- 权限相关服务(通知、HealthKit权限请求)仅在用户完成引导流程后才执行
- 在Redux store中添加onboardingCompleted状态管理
- 引导页面完成时通过Redux更新状态而非直接操作AsyncStorage
- 启动页面从预加载数据中读取引导完成状态,避免重复读取存储
- 使用ref防止权限服务重复初始化
2025-11-14 14:10:52 +08:00

294 lines
8.3 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 { 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 { useAppDispatch } from '@/hooks/redux';
import { setOnboardingCompleted } from '@/store/userSlice';
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: 'medication',
title: '智能用药管理',
description: '个性化用药提醒,智能追踪服药记录,确保按时按量,守护您的健康安全。',
image: require('@/assets/images/onboarding/medicine.jpg'),
},
{
key: 'support',
title: '健康挑战赛',
description: '参与精选健康挑战,与好友一起打卡,保持每日运动动力。',
image: require('@/assets/images/onboarding/challange.jpg'),
},
];
export default function OnboardingScreen() {
const router = useRouter();
const dispatch = useAppDispatch();
const { width } = useWindowDimensions();
const [currentIndex, setCurrentIndex] = useState(0);
const listRef = useRef<FlatList<OnboardingSlide>>(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<NativeScrollEvent>) => {
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 () => {
// 通过 Redux 更新 onboarding 状态(会自动保存到 AsyncStorage
await dispatch(setOnboardingCompleted());
router.replace(ROUTES.TAB_STATISTICS);
}, [dispatch, 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 }) => (
<View style={[styles.slide, { width }]}>
<Image source={item.image} style={styles.slideImage} />
</View>
),
[width],
);
const currentSlide = SLIDES[currentIndex];
return (
<SafeAreaView style={styles.safeArea}>
<StatusBar style="dark" />
<View style={styles.header}>
<Pressable onPress={handleSkip} hitSlop={12}>
<Text style={styles.skipText}></Text>
</Pressable>
</View>
<View style={styles.carouselContainer}>
<FlatList
ref={listRef}
data={SLIDES}
horizontal
keyExtractor={(item) => item.key}
pagingEnabled
decelerationRate="fast"
bounces={false}
showsHorizontalScrollIndicator={false}
renderItem={renderSlide}
onMomentumScrollEnd={updateIndexFromScroll}
/>
</View>
<View style={styles.body}>
<View style={styles.indicatorContainer}>
{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 <Animated.View key={slide.key} style={[styles.indicatorDot, animatedStyle]} />;
})}
</View>
<View style={styles.textContainer}>
<Text style={styles.title}>{currentSlide.title}</Text>
<Text style={styles.description}>{currentSlide.description}</Text>
</View>
<TouchableOpacity style={styles.primaryButtonWrapper} onPress={handleNext} activeOpacity={0.9}>
{glassAvailable ? (
<GlassView
style={styles.primaryButtonGlass}
glassEffectStyle="regular"
tintColor="rgba(122, 90, 248, 0.25)"
isInteractive
>
<LinearGradient
colors={[palette.purple[600], palette.purple[400]]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.primaryButtonGradient}
/>
<Text style={styles.primaryButtonText}>
{currentIndex === SLIDES.length - 1 ? '开始使用' : '下一步'}
</Text>
</GlassView>
) : (
<LinearGradient
colors={[palette.purple[600], palette.purple[400]]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={[styles.primaryButtonGlass, styles.primaryButtonFallback]}
>
<Text style={styles.primaryButtonText}>{currentIndex === SLIDES.length - 1 ? '开始使用' : '下一步'}</Text>
</LinearGradient>
)}
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
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',
},
});