373 lines
11 KiB
JavaScript
373 lines
11 KiB
JavaScript
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
|
import {
|
|
View, Text, Image, TextInput, StyleSheet, FlatList,
|
|
Dimensions, TouchableOpacity, Animated,
|
|
KeyboardAvoidingView, Platform, Keyboard,
|
|
} from 'react-native';
|
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
import StarField from '../components/StarField';
|
|
import Button from '../components/Button';
|
|
import useAppStore from '../store/useAppStore';
|
|
import useAuthStore from '../store/useAuthStore';
|
|
import { colors, fonts, spacing, borderRadius } from '../utils/theme';
|
|
|
|
const { width } = Dimensions.get('window');
|
|
const ONBOARDING_KEY = 'onboarding_done';
|
|
|
|
const pages = [
|
|
{
|
|
id: '1',
|
|
title: 'Identity First',
|
|
text: 'Every transformation begins with identity.\nNot goals. Not habits. Identity.',
|
|
iconSize: 90,
|
|
glowIntensity: 0.3,
|
|
showOrbit: false,
|
|
},
|
|
{
|
|
id: '2',
|
|
title: '40 Days',
|
|
text: "In 40 days, you won't just change habits\n— you'll change who you are.",
|
|
iconSize: 110,
|
|
glowIntensity: 0.65,
|
|
showOrbit: false,
|
|
},
|
|
{
|
|
id: '3',
|
|
title: 'Your Nova Awaits',
|
|
text: 'Small actions. Massive transformation.',
|
|
iconSize: 110,
|
|
glowIntensity: 1,
|
|
showOrbit: true,
|
|
isLast: true,
|
|
},
|
|
];
|
|
|
|
function PageContent({ item, isActive }) {
|
|
const fadeAnim = useRef(new Animated.Value(0)).current;
|
|
const slideAnim = useRef(new Animated.Value(30)).current;
|
|
const pulse = useRef(new Animated.Value(1)).current;
|
|
|
|
useEffect(() => {
|
|
if (isActive) {
|
|
fadeAnim.setValue(0);
|
|
slideAnim.setValue(30);
|
|
Animated.parallel([
|
|
Animated.timing(fadeAnim, { toValue: 1, duration: 500, delay: 150, useNativeDriver: true }),
|
|
Animated.timing(slideAnim, { toValue: 0, duration: 500, delay: 150, useNativeDriver: true }),
|
|
]).start();
|
|
}
|
|
}, [isActive]);
|
|
|
|
useEffect(() => {
|
|
const anim = Animated.loop(
|
|
Animated.sequence([
|
|
Animated.timing(pulse, { toValue: 1.12, duration: 2200, useNativeDriver: true }),
|
|
Animated.timing(pulse, { toValue: 1, duration: 2200, useNativeDriver: true }),
|
|
])
|
|
);
|
|
anim.start();
|
|
return () => anim.stop();
|
|
}, []);
|
|
|
|
return (
|
|
<View style={styles.page}>
|
|
{/* Icon area */}
|
|
<View style={styles.iconArea}>
|
|
<Animated.View
|
|
style={[
|
|
styles.glow,
|
|
{
|
|
width: item.iconSize * 2.2,
|
|
height: item.iconSize * 2.2,
|
|
borderRadius: item.iconSize * 1.1,
|
|
opacity: item.glowIntensity * 0.4,
|
|
transform: [{ scale: pulse }],
|
|
},
|
|
]}
|
|
/>
|
|
{item.showOrbit && (
|
|
<View
|
|
style={[
|
|
styles.orbit,
|
|
{
|
|
width: item.iconSize * 2.6,
|
|
height: item.iconSize * 2.6,
|
|
borderRadius: item.iconSize * 1.3,
|
|
},
|
|
]}
|
|
/>
|
|
)}
|
|
<Image
|
|
source={require('../../assets/icon.png')}
|
|
style={{ width: item.iconSize * 1.2, height: item.iconSize * 1.2 }}
|
|
resizeMode="contain"
|
|
/>
|
|
</View>
|
|
|
|
{/* Text */}
|
|
<Animated.View style={{ opacity: fadeAnim, transform: [{ translateY: slideAnim }] }}>
|
|
<Text style={styles.pageTitle}>{item.title}</Text>
|
|
<Text style={styles.pageText}>{item.text}</Text>
|
|
</Animated.View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
export default function OnboardingScreen({ navigation }) {
|
|
const flatListRef = useRef(null);
|
|
const [currentIndex, setCurrentIndex] = useState(0);
|
|
const [identityInput, setIdentityInput] = useState('');
|
|
const [keyboardVisible, setKeyboardVisible] = useState(false);
|
|
const setInitialIdentity = useAppStore((s) => s.setInitialIdentity);
|
|
const buttonFade = useRef(new Animated.Value(0)).current;
|
|
const inputFade = useRef(new Animated.Value(0)).current;
|
|
|
|
const isLastPage = currentIndex === pages.length - 1;
|
|
|
|
useEffect(() => {
|
|
const showSub = Keyboard.addListener('keyboardWillShow', () => setKeyboardVisible(true));
|
|
const hideSub = Keyboard.addListener('keyboardWillHide', () => setKeyboardVisible(false));
|
|
// Android uses keyboardDidShow/keyboardDidHide
|
|
const showSubAnd = Keyboard.addListener('keyboardDidShow', () => setKeyboardVisible(true));
|
|
const hideSubAnd = Keyboard.addListener('keyboardDidHide', () => setKeyboardVisible(false));
|
|
return () => { showSub.remove(); hideSub.remove(); showSubAnd.remove(); hideSubAnd.remove(); };
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (isLastPage) {
|
|
Animated.stagger(200, [
|
|
Animated.timing(inputFade, { toValue: 1, duration: 400, useNativeDriver: true }),
|
|
Animated.timing(buttonFade, { toValue: 1, duration: 400, useNativeDriver: true }),
|
|
]).start();
|
|
} else {
|
|
buttonFade.setValue(0);
|
|
inputFade.setValue(0);
|
|
}
|
|
}, [isLastPage]);
|
|
|
|
const completeOnboarding = useCallback(async () => {
|
|
await AsyncStorage.setItem(ONBOARDING_KEY, 'true');
|
|
if (identityInput.trim()) {
|
|
setInitialIdentity(identityInput.trim());
|
|
}
|
|
|
|
// If user is already logged in (came from Register), go to story input
|
|
// Otherwise go to Login
|
|
const session = useAuthStore.getState().session;
|
|
if (session) {
|
|
navigation.reset({ index: 0, routes: [{ name: 'IdentityStory' }] });
|
|
} else {
|
|
navigation.reset({ index: 0, routes: [{ name: 'Login' }] });
|
|
}
|
|
}, [identityInput, navigation, setInitialIdentity]);
|
|
|
|
const onViewableItemsChanged = useRef(({ viewableItems }) => {
|
|
if (viewableItems.length > 0) {
|
|
setCurrentIndex(viewableItems[0].index ?? 0);
|
|
}
|
|
}).current;
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
<StarField count={60} />
|
|
|
|
{/* Skip button — hidden on last page */}
|
|
{!isLastPage && (
|
|
<TouchableOpacity style={styles.skip} onPress={completeOnboarding}>
|
|
<Text style={styles.skipText}>Skip</Text>
|
|
</TouchableOpacity>
|
|
)}
|
|
|
|
{/* Hide pager when keyboard is visible on last page to make room */}
|
|
{!(keyboardVisible && isLastPage) && (
|
|
<FlatList
|
|
ref={flatListRef}
|
|
data={pages}
|
|
horizontal
|
|
pagingEnabled
|
|
showsHorizontalScrollIndicator={false}
|
|
keyExtractor={(item) => item.id}
|
|
onViewableItemsChanged={onViewableItemsChanged}
|
|
viewabilityConfig={{ viewAreaCoveragePercentThreshold: 50 }}
|
|
renderItem={({ item, index }) => (
|
|
<PageContent item={item} isActive={index === currentIndex} />
|
|
)}
|
|
/>
|
|
)}
|
|
|
|
{/* Bottom section: input + button + dots */}
|
|
<KeyboardAvoidingView
|
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
|
style={styles.bottomKAV}
|
|
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
|
|
>
|
|
<View style={styles.bottom}>
|
|
{/* Show title when keyboard hides the pager */}
|
|
{keyboardVisible && isLastPage && (
|
|
<Text style={styles.keyboardTitle}>Who do you want to become?</Text>
|
|
)}
|
|
|
|
{/* Identity input on last page */}
|
|
{isLastPage && (
|
|
<Animated.View style={[styles.inputWrapper, { opacity: inputFade }]}>
|
|
{!keyboardVisible && (
|
|
<Text style={styles.inputLabel}>Who do you want to become?</Text>
|
|
)}
|
|
<TextInput
|
|
style={styles.input}
|
|
value={identityInput}
|
|
onChangeText={setIdentityInput}
|
|
placeholder="e.g., A disciplined, focused person"
|
|
placeholderTextColor={colors.textMuted}
|
|
selectionColor={colors.primary}
|
|
returnKeyType="done"
|
|
onSubmitEditing={() => Keyboard.dismiss()}
|
|
/>
|
|
</Animated.View>
|
|
)}
|
|
|
|
{/* CTA button on last page */}
|
|
{isLastPage && (
|
|
<Animated.View style={[styles.ctaWrapper, { opacity: buttonFade }]}>
|
|
<Button title="Get Started" onPress={completeOnboarding} />
|
|
</Animated.View>
|
|
)}
|
|
|
|
{/* Dots */}
|
|
{!keyboardVisible && (
|
|
<View style={styles.indicators}>
|
|
{pages.map((_, i) => (
|
|
<View
|
|
key={i}
|
|
style={[
|
|
styles.dot,
|
|
currentIndex === i && styles.dotActive,
|
|
]}
|
|
/>
|
|
))}
|
|
</View>
|
|
)}
|
|
</View>
|
|
</KeyboardAvoidingView>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: colors.background,
|
|
},
|
|
skip: {
|
|
position: 'absolute',
|
|
top: 58,
|
|
right: 24,
|
|
zIndex: 10,
|
|
paddingVertical: spacing.xs,
|
|
paddingHorizontal: spacing.sm,
|
|
},
|
|
skipText: {
|
|
color: colors.textSecondary,
|
|
fontSize: fonts.sizes.md,
|
|
fontWeight: fonts.weights.medium,
|
|
},
|
|
page: {
|
|
width,
|
|
flex: 1,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
paddingHorizontal: spacing.xl,
|
|
},
|
|
iconArea: {
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
width: 300,
|
|
height: 300,
|
|
marginBottom: spacing.xl,
|
|
},
|
|
glow: {
|
|
backgroundColor: colors.planetGlow,
|
|
position: 'absolute',
|
|
},
|
|
orbit: {
|
|
borderWidth: 1.5,
|
|
borderColor: colors.orbitLine,
|
|
borderStyle: 'dashed',
|
|
position: 'absolute',
|
|
},
|
|
pageTitle: {
|
|
color: colors.accent,
|
|
fontSize: fonts.sizes.xs,
|
|
fontWeight: fonts.weights.bold,
|
|
letterSpacing: 3,
|
|
textAlign: 'center',
|
|
textTransform: 'uppercase',
|
|
marginBottom: spacing.md,
|
|
},
|
|
pageText: {
|
|
color: colors.text,
|
|
fontSize: fonts.sizes.xl,
|
|
fontWeight: fonts.weights.semibold,
|
|
textAlign: 'center',
|
|
lineHeight: 34,
|
|
},
|
|
bottomKAV: {
|
|
position: 'absolute',
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
},
|
|
bottom: {
|
|
paddingHorizontal: spacing.lg,
|
|
paddingBottom: spacing.xl,
|
|
paddingTop: spacing.md,
|
|
},
|
|
keyboardTitle: {
|
|
color: colors.text,
|
|
fontSize: fonts.sizes.lg,
|
|
fontWeight: fonts.weights.semibold,
|
|
textAlign: 'center',
|
|
marginBottom: spacing.md,
|
|
},
|
|
inputWrapper: {
|
|
marginBottom: spacing.lg,
|
|
},
|
|
inputLabel: {
|
|
color: colors.textSecondary,
|
|
fontSize: fonts.sizes.sm,
|
|
fontWeight: fonts.weights.medium,
|
|
marginBottom: spacing.sm,
|
|
textAlign: 'center',
|
|
},
|
|
input: {
|
|
backgroundColor: colors.surface,
|
|
borderRadius: borderRadius.md,
|
|
paddingHorizontal: spacing.md,
|
|
paddingVertical: spacing.md,
|
|
color: colors.text,
|
|
fontSize: fonts.sizes.md,
|
|
borderWidth: 1,
|
|
borderColor: colors.border,
|
|
textAlign: 'center',
|
|
},
|
|
ctaWrapper: {
|
|
marginBottom: spacing.md,
|
|
},
|
|
indicators: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'center',
|
|
gap: spacing.sm,
|
|
paddingBottom: spacing.sm,
|
|
},
|
|
dot: {
|
|
width: 8,
|
|
height: 8,
|
|
borderRadius: 4,
|
|
backgroundColor: colors.textMuted,
|
|
},
|
|
dotActive: {
|
|
backgroundColor: colors.primary,
|
|
width: 24,
|
|
},
|
|
});
|