init
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user