init
@@ -1,20 +1,21 @@
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import 'react-native-gesture-handler';
|
||||
import React from 'react';
|
||||
import { LogBox } from 'react-native';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import NovaAlertProvider from './src/components/NovaAlert';
|
||||
import AppNavigator from './src/navigation/AppNavigator';
|
||||
|
||||
LogBox.ignoreLogs(['TypeError: Network request failed']);
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text>Open up App.js to start working on your app!</Text>
|
||||
<StatusBar style="auto" />
|
||||
</View>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<SafeAreaProvider>
|
||||
<NovaAlertProvider>
|
||||
<AppNavigator />
|
||||
</NovaAlertProvider>
|
||||
</SafeAreaProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#fff',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5,25 +5,37 @@
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"userInterfaceStyle": "dark",
|
||||
"newArchEnabled": true,
|
||||
"splash": {
|
||||
"image": "./assets/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
"backgroundColor": "#0A0E1A"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.nova40.app"
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
"backgroundColor": "#0A0E1A"
|
||||
},
|
||||
"edgeToEdgeEnabled": true
|
||||
"edgeToEdgeEnabled": true,
|
||||
"package": "com.nova40.app",
|
||||
"permissions": [
|
||||
"WRITE_EXTERNAL_STORAGE",
|
||||
"READ_EXTERNAL_STORAGE"
|
||||
]
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
}
|
||||
},
|
||||
"extra": {
|
||||
"eas": {
|
||||
"projectId": "f0ecc895-4610-481d-96db-73a121e78254"
|
||||
}
|
||||
},
|
||||
"owner": "heyaciell"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 731 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 731 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 731 KiB |
@@ -0,0 +1,6 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 3.0.0"
|
||||
},
|
||||
"build": {
|
||||
"preview": {
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"android": {
|
||||
"buildType": "app-bundle"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,32 @@
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-navigation/bottom-tabs": "^7.15.9",
|
||||
"@react-navigation/native": "^7.2.2",
|
||||
"@react-navigation/native-stack": "^7.14.11",
|
||||
"@supabase/ssr": "^0.10.2",
|
||||
"@supabase/supabase-js": "^2.103.0",
|
||||
"babel-preset-expo": "~54.0.10",
|
||||
"expo": "~54.0.33",
|
||||
"expo-linear-gradient": "~15.0.8",
|
||||
"expo-media-library": "~18.2.1",
|
||||
"expo-print": "~15.0.8",
|
||||
"expo-sharing": "~14.0.8",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5"
|
||||
"react-native": "0.81.5",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-url-polyfill": "^3.0.0",
|
||||
"react-native-view-shot": "4.0.3",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"private": true
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.1.10",
|
||||
"typescript": "~5.9.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { Animated, Text, StyleSheet, ActivityIndicator, Pressable } from 'react-native';
|
||||
import { colors, fonts, borderRadius, spacing } from '../utils/theme';
|
||||
|
||||
export default function Button({
|
||||
title,
|
||||
onPress,
|
||||
variant = 'primary',
|
||||
loading = false,
|
||||
disabled = false,
|
||||
style,
|
||||
}) {
|
||||
const isPrimary = variant === 'primary';
|
||||
const scale = useRef(new Animated.Value(1)).current;
|
||||
|
||||
const onPressIn = () => {
|
||||
Animated.spring(scale, {
|
||||
toValue: 0.93,
|
||||
useNativeDriver: true,
|
||||
speed: 50,
|
||||
bounciness: 4,
|
||||
}).start();
|
||||
};
|
||||
|
||||
const onPressOut = () => {
|
||||
Animated.spring(scale, {
|
||||
toValue: 1,
|
||||
useNativeDriver: true,
|
||||
speed: 12,
|
||||
bounciness: 8,
|
||||
}).start();
|
||||
};
|
||||
|
||||
return (
|
||||
<Animated.View style={[{ transform: [{ scale }] }, style]}>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.button,
|
||||
isPrimary ? styles.primary : styles.secondary,
|
||||
disabled && styles.disabled,
|
||||
]}
|
||||
onPress={onPress}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={onPressOut}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color={colors.text} />
|
||||
) : (
|
||||
<Text style={[styles.text, !isPrimary && styles.secondaryText]}>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.xl,
|
||||
borderRadius: borderRadius.lg,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: 52,
|
||||
},
|
||||
primary: {
|
||||
backgroundColor: colors.primary,
|
||||
shadowColor: colors.primary,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 12,
|
||||
elevation: 6,
|
||||
},
|
||||
secondary: {
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1.5,
|
||||
borderColor: colors.primary,
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
text: {
|
||||
color: colors.text,
|
||||
fontSize: fonts.sizes.md,
|
||||
fontWeight: fonts.weights.semibold,
|
||||
textAlign: 'center',
|
||||
},
|
||||
secondaryText: {
|
||||
color: colors.primary,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { View, TextInput, Pressable, Text, StyleSheet, Animated } from 'react-native';
|
||||
import { colors, fonts, spacing, borderRadius } from '../utils/theme';
|
||||
|
||||
export default function HabitInput({ value, onChangeText, onDelete, index, autoFocus }) {
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||
const slideAnim = useRef(new Animated.Value(-10)).current;
|
||||
|
||||
useEffect(() => {
|
||||
Animated.parallel([
|
||||
Animated.timing(fadeAnim, { toValue: 1, duration: 300, useNativeDriver: true }),
|
||||
Animated.timing(slideAnim, { toValue: 0, duration: 300, useNativeDriver: true }),
|
||||
]).start();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.container, { opacity: fadeAnim, transform: [{ translateX: slideAnim }] }]}>
|
||||
<View style={styles.indexBadge}>
|
||||
<Text style={styles.indexText}>{index + 1}</Text>
|
||||
</View>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={`Habit ${index + 1}`}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
selectionColor={colors.primary}
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
<Pressable style={styles.deleteBtn} onPress={onDelete} hitSlop={8}>
|
||||
<Text style={styles.deleteIcon}>✕</Text>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
marginBottom: spacing.sm,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
indexBadge: {
|
||||
width: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
backgroundColor: 'rgba(108, 99, 255, 0.08)',
|
||||
},
|
||||
indexText: {
|
||||
color: colors.primary,
|
||||
fontSize: fonts.sizes.sm,
|
||||
fontWeight: fonts.weights.bold,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
color: colors.text,
|
||||
fontSize: fonts.sizes.md,
|
||||
},
|
||||
deleteBtn: {
|
||||
width: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
deleteIcon: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import React from 'react';
|
||||
import { TextInput, View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { colors, fonts, borderRadius, spacing } from '../utils/theme';
|
||||
|
||||
export default function Input({
|
||||
label,
|
||||
value,
|
||||
onChangeText,
|
||||
placeholder,
|
||||
secureTextEntry = false,
|
||||
autoCapitalize = 'none',
|
||||
keyboardType = 'default',
|
||||
multiline = false,
|
||||
rightIcon,
|
||||
onRightIconPress,
|
||||
style,
|
||||
}) {
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
{label && <Text style={styles.label}>{label}</Text>}
|
||||
<View style={styles.inputRow}>
|
||||
<TextInput
|
||||
style={[styles.input, multiline && styles.multiline, rightIcon && styles.inputWithIcon]}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
secureTextEntry={secureTextEntry}
|
||||
autoCapitalize={autoCapitalize}
|
||||
keyboardType={keyboardType}
|
||||
multiline={multiline}
|
||||
selectionColor={colors.primary}
|
||||
/>
|
||||
{rightIcon && (
|
||||
<Pressable style={styles.iconBtn} onPress={onRightIconPress} hitSlop={8}>
|
||||
<Text style={styles.iconText}>{rightIcon}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
label: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: fonts.sizes.sm,
|
||||
fontWeight: fonts.weights.medium,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
inputRow: {
|
||||
position: 'relative',
|
||||
},
|
||||
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,
|
||||
},
|
||||
inputWithIcon: {
|
||||
paddingRight: 48,
|
||||
},
|
||||
multiline: {
|
||||
minHeight: 100,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
iconBtn: {
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 48,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
iconText: {
|
||||
fontSize: 18,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { View, Text, StyleSheet, Modal, Animated, Pressable } from 'react-native';
|
||||
import { colors, fonts, spacing, borderRadius } from '../utils/theme';
|
||||
|
||||
let _showAlert = null;
|
||||
|
||||
/**
|
||||
* Show a themed alert. Call from anywhere:
|
||||
* import { showAlert } from '../components/NovaAlert';
|
||||
* showAlert('Title', 'Message', [{ text: 'OK' }]);
|
||||
*/
|
||||
export function showAlert(title, message, buttons = [{ text: 'OK' }]) {
|
||||
if (_showAlert) _showAlert({ title, message, buttons });
|
||||
}
|
||||
|
||||
export default function NovaAlertProvider({ children }) {
|
||||
const [alert, setAlert] = React.useState(null);
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||
const scaleAnim = useRef(new Animated.Value(0.85)).current;
|
||||
|
||||
useEffect(() => {
|
||||
_showAlert = (data) => setAlert(data);
|
||||
return () => { _showAlert = null; };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (alert) {
|
||||
fadeAnim.setValue(0);
|
||||
scaleAnim.setValue(0.85);
|
||||
Animated.parallel([
|
||||
Animated.timing(fadeAnim, { toValue: 1, duration: 200, useNativeDriver: true }),
|
||||
Animated.spring(scaleAnim, { toValue: 1, useNativeDriver: true, speed: 20, bounciness: 8 }),
|
||||
]).start();
|
||||
}
|
||||
}, [alert]);
|
||||
|
||||
const dismiss = (callback) => {
|
||||
Animated.timing(fadeAnim, { toValue: 0, duration: 150, useNativeDriver: true }).start(() => {
|
||||
setAlert(null);
|
||||
if (callback) callback();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<Modal visible={!!alert} transparent animationType="none" statusBarTranslucent>
|
||||
<Animated.View style={[styles.overlay, { opacity: fadeAnim }]}>
|
||||
<Pressable style={styles.overlayPress} onPress={() => dismiss()} />
|
||||
<Animated.View style={[styles.card, { transform: [{ scale: scaleAnim }] }]}>
|
||||
{/* Accent line */}
|
||||
<View style={styles.accentLine} />
|
||||
|
||||
{alert?.title && (
|
||||
<Text style={styles.title}>{alert.title}</Text>
|
||||
)}
|
||||
{alert?.message && (
|
||||
<Text style={styles.message}>{alert.message}</Text>
|
||||
)}
|
||||
|
||||
<View style={styles.buttons}>
|
||||
{(alert?.buttons || []).map((btn, i) => {
|
||||
const isDestructive = btn.style === 'destructive';
|
||||
const isCancel = btn.style === 'cancel';
|
||||
const isLast = i === (alert?.buttons || []).length - 1;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={i}
|
||||
style={({ pressed }) => [
|
||||
styles.button,
|
||||
isCancel && styles.buttonCancel,
|
||||
isDestructive && styles.buttonDestructive,
|
||||
!isCancel && !isDestructive && styles.buttonPrimary,
|
||||
pressed && styles.buttonPressed,
|
||||
i > 0 && { marginLeft: spacing.sm },
|
||||
]}
|
||||
onPress={() => dismiss(btn.onPress)}
|
||||
>
|
||||
<Text style={[
|
||||
styles.buttonText,
|
||||
isCancel && styles.buttonTextCancel,
|
||||
isDestructive && styles.buttonTextDestructive,
|
||||
]}>
|
||||
{btn.text}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.xl,
|
||||
},
|
||||
overlayPress: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: borderRadius.xl,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
width: '100%',
|
||||
maxWidth: 340,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
accentLine: {
|
||||
height: 3,
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
title: {
|
||||
color: colors.text,
|
||||
fontSize: fonts.sizes.lg,
|
||||
fontWeight: fonts.weights.bold,
|
||||
textAlign: 'center',
|
||||
paddingTop: spacing.xl,
|
||||
paddingHorizontal: spacing.lg,
|
||||
},
|
||||
message: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: fonts.sizes.md,
|
||||
textAlign: 'center',
|
||||
lineHeight: 22,
|
||||
paddingTop: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingBottom: spacing.lg,
|
||||
},
|
||||
buttons: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingBottom: spacing.lg,
|
||||
},
|
||||
button: {
|
||||
flex: 1,
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: borderRadius.md,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: 44,
|
||||
},
|
||||
buttonPrimary: {
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
buttonCancel: {
|
||||
backgroundColor: 'rgba(138, 143, 181, 0.1)',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
buttonDestructive: {
|
||||
backgroundColor: 'rgba(255, 82, 82, 0.12)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 82, 82, 0.3)',
|
||||
},
|
||||
buttonPressed: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
buttonText: {
|
||||
color: colors.text,
|
||||
fontSize: fonts.sizes.md,
|
||||
fontWeight: fonts.weights.semibold,
|
||||
},
|
||||
buttonTextCancel: {
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
buttonTextDestructive: {
|
||||
color: colors.error,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import React, { useMemo, useEffect, useRef } from 'react';
|
||||
import { View, StyleSheet, Animated } from 'react-native';
|
||||
import OrbitDot from './OrbitDot';
|
||||
import PlanetCore from './PlanetCore';
|
||||
import { colors } from '../utils/theme';
|
||||
|
||||
const TOTAL_DAYS = 40;
|
||||
|
||||
export default function OrbitContainer({
|
||||
size = 280,
|
||||
currentDay = 1,
|
||||
planetSize = 80,
|
||||
glowIntensity = 0.5,
|
||||
onDotPress,
|
||||
}) {
|
||||
const radius = size / 2;
|
||||
const center = size / 2;
|
||||
const rotateAnim = useRef(new Animated.Value(0)).current;
|
||||
|
||||
// Slow subtle orbit rotation
|
||||
useEffect(() => {
|
||||
const anim = Animated.loop(
|
||||
Animated.timing(rotateAnim, {
|
||||
toValue: 1,
|
||||
duration: 120000, // 2 minutes per rotation
|
||||
useNativeDriver: true,
|
||||
})
|
||||
);
|
||||
anim.start();
|
||||
return () => anim.stop();
|
||||
}, []);
|
||||
|
||||
const rotation = rotateAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ['0deg', '360deg'],
|
||||
});
|
||||
|
||||
// Calculate dot positions
|
||||
const dots = useMemo(() => {
|
||||
const angleOffset = -Math.PI / 2; // Start from top
|
||||
return Array.from({ length: TOTAL_DAYS }, (_, i) => {
|
||||
const angle = angleOffset + (i / TOTAL_DAYS) * 2 * Math.PI;
|
||||
const x = center + radius * 0.88 * Math.cos(angle);
|
||||
const y = center + radius * 0.88 * Math.sin(angle);
|
||||
const dayNum = i + 1;
|
||||
|
||||
let status = 'future';
|
||||
if (dayNum < currentDay) status = 'completed';
|
||||
else if (dayNum === currentDay) status = 'current';
|
||||
|
||||
return { index: dayNum, x, y, status };
|
||||
});
|
||||
}, [currentDay, size]);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { width: size, height: size }]}>
|
||||
{/* Orbit ring */}
|
||||
<View
|
||||
style={[
|
||||
styles.orbitRing,
|
||||
{
|
||||
width: size * 0.88 * 2,
|
||||
height: size * 0.88 * 2,
|
||||
borderRadius: size * 0.88,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Gradient trail for completed portion */}
|
||||
{currentDay > 1 && (
|
||||
<View
|
||||
style={[
|
||||
styles.orbitTrail,
|
||||
{
|
||||
width: size * 0.88 * 2,
|
||||
height: size * 0.88 * 2,
|
||||
borderRadius: size * 0.88,
|
||||
borderColor: 'rgba(108, 99, 255, 0.12)',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Rotating dot layer */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.dotsLayer,
|
||||
{ width: size, height: size, transform: [{ rotate: rotation }] },
|
||||
]}
|
||||
>
|
||||
{dots.map((dot) => (
|
||||
<OrbitDot
|
||||
key={dot.index}
|
||||
index={dot.index}
|
||||
x={dot.x}
|
||||
y={dot.y}
|
||||
status={dot.status}
|
||||
onPress={onDotPress}
|
||||
/>
|
||||
))}
|
||||
</Animated.View>
|
||||
|
||||
{/* Planet center (does not rotate) */}
|
||||
<View style={styles.planetWrapper}>
|
||||
<PlanetCore size={planetSize} glowIntensity={glowIntensity} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
orbitRing: {
|
||||
position: 'absolute',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.orbitLine,
|
||||
borderStyle: 'dashed',
|
||||
},
|
||||
orbitTrail: {
|
||||
position: 'absolute',
|
||||
borderWidth: 1.5,
|
||||
},
|
||||
dotsLayer: {
|
||||
position: 'absolute',
|
||||
},
|
||||
planetWrapper: {
|
||||
position: 'absolute',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import React, { useEffect, useRef, memo } from 'react';
|
||||
import { Animated, StyleSheet, Pressable } from 'react-native';
|
||||
import { colors } from '../utils/theme';
|
||||
|
||||
const DOT_SIZE_DEFAULT = 6;
|
||||
const DOT_SIZE_CURRENT = 12;
|
||||
const DOT_SIZE_COMPLETED = 7;
|
||||
|
||||
function OrbitDot({ index, x, y, status, onPress }) {
|
||||
// status: 'completed' | 'current' | 'future'
|
||||
const pulseAnim = useRef(new Animated.Value(1)).current;
|
||||
const glowOpacity = useRef(new Animated.Value(0)).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'current') {
|
||||
const pulse = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulseAnim, { toValue: 1.6, duration: 1200, useNativeDriver: true }),
|
||||
Animated.timing(pulseAnim, { toValue: 1, duration: 1200, useNativeDriver: true }),
|
||||
])
|
||||
);
|
||||
const glow = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(glowOpacity, { toValue: 0.8, duration: 1200, useNativeDriver: true }),
|
||||
Animated.timing(glowOpacity, { toValue: 0.2, duration: 1200, useNativeDriver: true }),
|
||||
])
|
||||
);
|
||||
pulse.start();
|
||||
glow.start();
|
||||
return () => { pulse.stop(); glow.stop(); };
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
const isCurrent = status === 'current';
|
||||
const isCompleted = status === 'completed';
|
||||
const isFuture = status === 'future';
|
||||
|
||||
const dotSize = isCurrent ? DOT_SIZE_CURRENT : isCompleted ? DOT_SIZE_COMPLETED : DOT_SIZE_DEFAULT;
|
||||
|
||||
const dotColor = isCurrent
|
||||
? colors.orbitActive
|
||||
: isCompleted
|
||||
? colors.primary
|
||||
: 'rgba(108, 99, 255, 0.15)';
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{/* Glow ring for current day */}
|
||||
{isCurrent && (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.glowRing,
|
||||
{
|
||||
width: DOT_SIZE_CURRENT * 2.5,
|
||||
height: DOT_SIZE_CURRENT * 2.5,
|
||||
borderRadius: DOT_SIZE_CURRENT * 1.25,
|
||||
opacity: glowOpacity,
|
||||
transform: [{ scale: pulseAnim }],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Completed glow */}
|
||||
{isCompleted && (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.completedGlow,
|
||||
{
|
||||
width: dotSize * 2.2,
|
||||
height: dotSize * 2.2,
|
||||
borderRadius: dotSize * 1.1,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Dot */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.dot,
|
||||
{
|
||||
width: dotSize,
|
||||
height: dotSize,
|
||||
borderRadius: dotSize / 2,
|
||||
backgroundColor: dotColor,
|
||||
...(isCurrent && {
|
||||
shadowColor: colors.orbitActive,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 1,
|
||||
shadowRadius: 10,
|
||||
elevation: 8,
|
||||
}),
|
||||
...(isCompleted && {
|
||||
shadowColor: colors.primary,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.6,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
const containerStyle = [
|
||||
styles.container,
|
||||
{
|
||||
left: x - DOT_SIZE_CURRENT * 1.25,
|
||||
top: y - DOT_SIZE_CURRENT * 1.25,
|
||||
width: DOT_SIZE_CURRENT * 2.5,
|
||||
height: DOT_SIZE_CURRENT * 2.5,
|
||||
},
|
||||
];
|
||||
|
||||
if (onPress && (isCurrent || isCompleted)) {
|
||||
return (
|
||||
<Pressable style={containerStyle} onPress={() => onPress(index, status)}>
|
||||
{content}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
return <Animated.View style={containerStyle}>{content}</Animated.View>;
|
||||
}
|
||||
|
||||
export default memo(OrbitDot);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'absolute',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
dot: {},
|
||||
glowRing: {
|
||||
position: 'absolute',
|
||||
backgroundColor: 'rgba(0, 229, 255, 0.2)',
|
||||
},
|
||||
completedGlow: {
|
||||
position: 'absolute',
|
||||
backgroundColor: 'rgba(108, 99, 255, 0.15)',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { View, StyleSheet, Animated } from 'react-native';
|
||||
import { colors } from '../utils/theme';
|
||||
|
||||
export default function Planet({ size = 120, glowIntensity = 1, showOrbit = false, progress = 0 }) {
|
||||
const pulse = useRef(new Animated.Value(1)).current;
|
||||
|
||||
useEffect(() => {
|
||||
const animation = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulse, { toValue: 1.1, duration: 2000, useNativeDriver: true }),
|
||||
Animated.timing(pulse, { toValue: 1, duration: 2000, useNativeDriver: true }),
|
||||
])
|
||||
);
|
||||
animation.start();
|
||||
return () => animation.stop();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { width: size * 2.5, height: size * 2.5 }]}>
|
||||
{showOrbit && (
|
||||
<View
|
||||
style={[
|
||||
styles.orbit,
|
||||
{
|
||||
width: size * 2,
|
||||
height: size * 2,
|
||||
borderRadius: size,
|
||||
borderColor: colors.orbitLine,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.orbitDot,
|
||||
{
|
||||
transform: [
|
||||
{ rotate: `${(progress / 40) * 360}deg` },
|
||||
{ translateY: -size },
|
||||
],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.dot} />
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.glow,
|
||||
{
|
||||
width: size * 1.6,
|
||||
height: size * 1.6,
|
||||
borderRadius: size * 0.8,
|
||||
opacity: 0.3 * glowIntensity,
|
||||
transform: [{ scale: pulse }],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<View
|
||||
style={[
|
||||
styles.planet,
|
||||
{ width: size, height: size, borderRadius: size / 2 },
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.highlight,
|
||||
{
|
||||
width: size * 0.3,
|
||||
height: size * 0.3,
|
||||
borderRadius: size * 0.15,
|
||||
top: size * 0.15,
|
||||
left: size * 0.15,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
planet: {
|
||||
backgroundColor: colors.planetCore,
|
||||
position: 'absolute',
|
||||
shadowColor: colors.primary,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.8,
|
||||
shadowRadius: 20,
|
||||
elevation: 10,
|
||||
},
|
||||
highlight: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.15)',
|
||||
position: 'absolute',
|
||||
},
|
||||
glow: {
|
||||
backgroundColor: colors.planetGlow,
|
||||
position: 'absolute',
|
||||
},
|
||||
orbit: {
|
||||
borderWidth: 1.5,
|
||||
position: 'absolute',
|
||||
borderStyle: 'dashed',
|
||||
},
|
||||
orbitDot: {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
marginLeft: -6,
|
||||
marginTop: -6,
|
||||
},
|
||||
dot: {
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
backgroundColor: colors.orbitActive,
|
||||
shadowColor: colors.accent,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 1,
|
||||
shadowRadius: 8,
|
||||
elevation: 5,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { View, Image, StyleSheet, Animated } from 'react-native';
|
||||
import { colors } from '../utils/theme';
|
||||
|
||||
export default function PlanetCore({ size = 90, glowIntensity = 0.5 }) {
|
||||
const breathe = useRef(new Animated.Value(0.3)).current;
|
||||
const pulseScale = useRef(new Animated.Value(1)).current;
|
||||
|
||||
useEffect(() => {
|
||||
const opacityAnim = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(breathe, {
|
||||
toValue: 0.6 * glowIntensity,
|
||||
duration: 3000,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(breathe, {
|
||||
toValue: 0.2 * glowIntensity,
|
||||
duration: 3000,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
);
|
||||
const scaleAnim = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulseScale, { toValue: 1.08, duration: 3000, useNativeDriver: true }),
|
||||
Animated.timing(pulseScale, { toValue: 1, duration: 3000, useNativeDriver: true }),
|
||||
])
|
||||
);
|
||||
opacityAnim.start();
|
||||
scaleAnim.start();
|
||||
return () => { opacityAnim.stop(); scaleAnim.stop(); };
|
||||
}, [glowIntensity]);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { width: size * 2.2, height: size * 2.2 }]}>
|
||||
{/* Outer glow */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.outerGlow,
|
||||
{
|
||||
width: size * 2,
|
||||
height: size * 2,
|
||||
borderRadius: size,
|
||||
opacity: breathe,
|
||||
transform: [{ scale: pulseScale }],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Inner glow */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.innerGlow,
|
||||
{
|
||||
width: size * 1.4,
|
||||
height: size * 1.4,
|
||||
borderRadius: size * 0.7,
|
||||
opacity: breathe,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Planet image */}
|
||||
<Image
|
||||
source={require('../../assets/icon.png')}
|
||||
style={{ width: size, height: size }}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
outerGlow: {
|
||||
position: 'absolute',
|
||||
backgroundColor: colors.planetGlow,
|
||||
},
|
||||
innerGlow: {
|
||||
position: 'absolute',
|
||||
backgroundColor: 'rgba(108, 99, 255, 0.35)',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, StatusBar } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import StarField from './StarField';
|
||||
import { colors } from '../utils/theme';
|
||||
|
||||
export default function ScreenWrapper({ children, showStars = true, style }) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<StatusBar barStyle="light-content" backgroundColor={colors.background} />
|
||||
{showStars && <StarField />}
|
||||
<SafeAreaView style={[styles.safe, style]}>{children}</SafeAreaView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
safe: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import React, { useMemo, useEffect, useRef } from 'react';
|
||||
import { View, StyleSheet, Dimensions, Animated } from 'react-native';
|
||||
import { colors } from '../utils/theme';
|
||||
|
||||
const { width, height } = Dimensions.get('window');
|
||||
|
||||
function Star({ x, y, size, delay, glow }) {
|
||||
const opacity = useRef(new Animated.Value(0.15)).current;
|
||||
|
||||
useEffect(() => {
|
||||
const duration = 2000 + Math.random() * 2500;
|
||||
const animation = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(opacity, {
|
||||
toValue: glow ? 0.9 : 1,
|
||||
duration,
|
||||
delay,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(opacity, {
|
||||
toValue: glow ? 0.2 : 0.15,
|
||||
duration,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
);
|
||||
animation.start();
|
||||
return () => animation.stop();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={{ position: 'absolute', left: x - (glow ? size : 0), top: y - (glow ? size : 0) }}>
|
||||
{/* Glow halo for larger stars */}
|
||||
{glow && (
|
||||
<Animated.View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: size * 3,
|
||||
height: size * 3,
|
||||
borderRadius: size * 1.5,
|
||||
backgroundColor: 'rgba(108, 99, 255, 0.15)',
|
||||
opacity,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.star,
|
||||
{
|
||||
left: glow ? size : 0,
|
||||
top: glow ? size : 0,
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
opacity,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StarField({ count = 50 }) {
|
||||
const stars = useMemo(() => {
|
||||
const items = [];
|
||||
|
||||
// Regular small stars
|
||||
const smallCount = Math.floor(count * 0.6);
|
||||
for (let i = 0; i < smallCount; i++) {
|
||||
items.push({
|
||||
id: i,
|
||||
x: Math.random() * width,
|
||||
y: Math.random() * height,
|
||||
size: Math.random() * 3 + 1.5, // 1.5–4.5px
|
||||
delay: Math.random() * 3000,
|
||||
glow: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Medium stars
|
||||
const medCount = Math.floor(count * 0.25);
|
||||
for (let i = 0; i < medCount; i++) {
|
||||
items.push({
|
||||
id: smallCount + i,
|
||||
x: Math.random() * width,
|
||||
y: Math.random() * height,
|
||||
size: Math.random() * 3 + 4, // 4–7px
|
||||
delay: Math.random() * 3000,
|
||||
glow: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Large glowing stars
|
||||
const glowCount = count - smallCount - medCount;
|
||||
for (let i = 0; i < glowCount; i++) {
|
||||
items.push({
|
||||
id: smallCount + medCount + i,
|
||||
x: Math.random() * width,
|
||||
y: Math.random() * height,
|
||||
size: Math.random() * 3 + 5, // 5–8px with glow halo
|
||||
delay: Math.random() * 4000,
|
||||
glow: true,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [count]);
|
||||
|
||||
return (
|
||||
<View style={styles.container} pointerEvents="none">
|
||||
{stars.map((star) => (
|
||||
<Star key={star.id} {...star} />
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
star: {
|
||||
position: 'absolute',
|
||||
backgroundColor: colors.star,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { View, Text, StyleSheet, Animated, PanResponder, Dimensions } from 'react-native';
|
||||
import { colors, fonts, spacing, borderRadius } from '../../utils/theme';
|
||||
import { getDifficulty } from '../../services/gameService';
|
||||
|
||||
const TOTAL_ROUNDS = 5;
|
||||
|
||||
export default function FocusHold({ currentDay, onComplete }) {
|
||||
const difficulty = getDifficulty(currentDay);
|
||||
const holdDuration = 3000 + difficulty * 500; // 3.5s to 5s
|
||||
const circleSize = Math.max(120 - difficulty * 8, 88);
|
||||
|
||||
const [round, setRound] = useState(0);
|
||||
const [holding, setHolding] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [successes, setSuccesses] = useState(0);
|
||||
const [message, setMessage] = useState('Press and hold');
|
||||
|
||||
const progress = useRef(new Animated.Value(0)).current;
|
||||
const ringOpacity = useRef(new Animated.Value(0.4)).current;
|
||||
const holdTimer = useRef(null);
|
||||
const animRef = useRef(null);
|
||||
|
||||
const resetRound = useCallback(() => {
|
||||
progress.setValue(0);
|
||||
ringOpacity.setValue(0.4);
|
||||
setHolding(false);
|
||||
setFailed(false);
|
||||
setMessage('Press and hold');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (round >= TOTAL_ROUNDS && round > 0) {
|
||||
const score = successes; // 0-5
|
||||
onComplete(score, { rounds: TOTAL_ROUNDS, successes });
|
||||
}
|
||||
}, [round]);
|
||||
|
||||
const startHold = () => {
|
||||
if (round >= TOTAL_ROUNDS) return;
|
||||
setHolding(true);
|
||||
setFailed(false);
|
||||
setMessage('Hold steady...');
|
||||
|
||||
animRef.current = Animated.timing(progress, {
|
||||
toValue: 1,
|
||||
duration: holdDuration,
|
||||
useNativeDriver: false,
|
||||
});
|
||||
|
||||
Animated.timing(ringOpacity, { toValue: 1, duration: 300, useNativeDriver: true }).start();
|
||||
|
||||
animRef.current.start(({ finished }) => {
|
||||
if (finished) {
|
||||
// Success
|
||||
setSuccesses((prev) => prev + 1);
|
||||
setMessage('Perfect!');
|
||||
setHolding(false);
|
||||
setTimeout(() => {
|
||||
setRound((prev) => prev + 1);
|
||||
resetRound();
|
||||
}, 600);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const endHold = () => {
|
||||
if (!holding) return;
|
||||
// Released too early
|
||||
animRef.current?.stop();
|
||||
setHolding(false);
|
||||
setFailed(true);
|
||||
setMessage('Released too early');
|
||||
Animated.timing(ringOpacity, { toValue: 0.2, duration: 200, useNativeDriver: true }).start();
|
||||
|
||||
setTimeout(() => {
|
||||
setRound((prev) => prev + 1);
|
||||
resetRound();
|
||||
}, 800);
|
||||
};
|
||||
|
||||
const panResponder = useRef(
|
||||
PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => true,
|
||||
onPanResponderGrant: startHold,
|
||||
onPanResponderRelease: endHold,
|
||||
onPanResponderTerminate: endHold,
|
||||
onPanResponderMove: (_, gestureState) => {
|
||||
// Fail if finger moves too far
|
||||
const dist = Math.sqrt(gestureState.dx ** 2 + gestureState.dy ** 2);
|
||||
if (dist > circleSize * 0.6 && holding) {
|
||||
animRef.current?.stop();
|
||||
setHolding(false);
|
||||
setFailed(true);
|
||||
setMessage('Moved outside!');
|
||||
Animated.timing(ringOpacity, { toValue: 0.2, duration: 200, useNativeDriver: true }).start();
|
||||
setTimeout(() => {
|
||||
setRound((prev) => prev + 1);
|
||||
resetRound();
|
||||
}, 800);
|
||||
}
|
||||
},
|
||||
})
|
||||
).current;
|
||||
|
||||
const fillWidth = progress.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ['0%', '100%'],
|
||||
});
|
||||
|
||||
const ringColor = failed ? colors.error : holding ? colors.accent : colors.primary;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.roundText}>{Math.min(round + 1, TOTAL_ROUNDS)} / {TOTAL_ROUNDS}</Text>
|
||||
<Text style={styles.successText}>{successes} held</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.holdArea}>
|
||||
{/* Hold circle */}
|
||||
<View {...panResponder.panHandlers} style={styles.circleWrapper}>
|
||||
<Animated.View style={[styles.ring, {
|
||||
width: circleSize + 20, height: circleSize + 20, borderRadius: (circleSize + 20) / 2,
|
||||
borderColor: ringColor, opacity: ringOpacity,
|
||||
}]} />
|
||||
|
||||
<View style={[styles.circle, {
|
||||
width: circleSize, height: circleSize, borderRadius: circleSize / 2,
|
||||
}]}>
|
||||
{/* Fill progress */}
|
||||
<Animated.View style={[styles.fill, {
|
||||
width: fillWidth, height: '100%', borderRadius: circleSize / 2,
|
||||
backgroundColor: failed ? 'rgba(255,82,82,0.3)' : 'rgba(0,229,255,0.3)',
|
||||
}]} />
|
||||
|
||||
<Text style={styles.circleText}>
|
||||
{holding ? 'Holding...' : round >= TOTAL_ROUNDS ? 'Done' : 'Hold'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.message, failed && styles.messageFail]}>{message}</Text>
|
||||
<Text style={styles.hint}>Press and hold the circle without moving</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, alignItems: 'center' },
|
||||
header: { flexDirection: 'row', justifyContent: 'space-between', width: '100%', paddingHorizontal: spacing.md, marginBottom: spacing.xl },
|
||||
roundText: { color: colors.text, fontSize: fonts.sizes.lg, fontWeight: fonts.weights.bold },
|
||||
successText: { color: colors.success, fontSize: fonts.sizes.md, fontWeight: fonts.weights.medium },
|
||||
holdArea: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
||||
circleWrapper: { alignItems: 'center', justifyContent: 'center' },
|
||||
ring: { position: 'absolute', borderWidth: 2, borderStyle: 'dashed' },
|
||||
circle: {
|
||||
backgroundColor: colors.surface, borderWidth: 2, borderColor: colors.border,
|
||||
alignItems: 'center', justifyContent: 'center', overflow: 'hidden',
|
||||
},
|
||||
fill: { position: 'absolute', left: 0, top: 0 },
|
||||
circleText: { color: colors.text, fontSize: fonts.sizes.md, fontWeight: fonts.weights.semibold },
|
||||
message: { color: colors.accent, fontSize: fonts.sizes.lg, fontWeight: fonts.weights.semibold, marginBottom: spacing.sm },
|
||||
messageFail: { color: colors.error },
|
||||
hint: { color: colors.textMuted, fontSize: fonts.sizes.sm },
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { View, Text, StyleSheet, Animated } from 'react-native';
|
||||
import Button from '../Button';
|
||||
import { colors, fonts, spacing, borderRadius } from '../../utils/theme';
|
||||
import { getResultMessage, getStatLabel } from '../../services/gameService';
|
||||
|
||||
export default function GameResult({ gameType, score, details, onSave, onRetry, saving }) {
|
||||
const fadeIn = useRef(new Animated.Value(0)).current;
|
||||
const scaleScore = useRef(new Animated.Value(0)).current;
|
||||
|
||||
useEffect(() => {
|
||||
Animated.sequence([
|
||||
Animated.timing(fadeIn, { toValue: 1, duration: 400, useNativeDriver: true }),
|
||||
Animated.spring(scaleScore, { toValue: 1, useNativeDriver: true, speed: 10, bounciness: 12 }),
|
||||
]).start();
|
||||
}, []);
|
||||
|
||||
const message = getResultMessage(gameType, score);
|
||||
const statLabel = getStatLabel(gameType);
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.container, { opacity: fadeIn }]}>
|
||||
<Text style={styles.title}>Challenge Complete</Text>
|
||||
|
||||
<Animated.View style={[styles.scoreCard, { transform: [{ scale: scaleScore }] }]}>
|
||||
<Text style={styles.scoreLabel}>Score</Text>
|
||||
<Text style={styles.scoreValue}>{score}</Text>
|
||||
<Text style={styles.statBonus}>+{statLabel}</Text>
|
||||
</Animated.View>
|
||||
|
||||
<Text style={styles.message}>{message}</Text>
|
||||
|
||||
{details && (
|
||||
<View style={styles.detailsCard}>
|
||||
{details.avgReaction && (
|
||||
<DetailRow label="Avg Reaction" value={`${details.avgReaction}ms`} />
|
||||
)}
|
||||
{details.rounds && (
|
||||
<DetailRow label="Rounds" value={details.rounds} />
|
||||
)}
|
||||
{details.successes !== undefined && (
|
||||
<DetailRow label="Successes" value={`${details.successes}/${details.rounds}`} />
|
||||
)}
|
||||
{details.perfects !== undefined && (
|
||||
<>
|
||||
<DetailRow label="Perfect" value={details.perfects} color={colors.accent} />
|
||||
<DetailRow label="Good" value={details.goods} color={colors.success} />
|
||||
<DetailRow label="Miss" value={details.misses} color={colors.error} />
|
||||
</>
|
||||
)}
|
||||
{details.correct !== undefined && (
|
||||
<DetailRow label="Correct" value={`${details.correct}/${details.rounds}`} />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Button title="Save & Exit" onPress={onSave} loading={saving} style={styles.btn} />
|
||||
<Button title="Play Again" onPress={onRetry} variant="secondary" style={styles.btn} />
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ label, value, color }) {
|
||||
return (
|
||||
<View style={styles.detailRow}>
|
||||
<Text style={styles.detailLabel}>{label}</Text>
|
||||
<Text style={[styles.detailValue, color && { color }]}>{value}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: spacing.lg },
|
||||
title: { color: colors.accent, fontSize: fonts.sizes.xs, fontWeight: fonts.weights.bold, letterSpacing: 3, textTransform: 'uppercase', marginBottom: spacing.xl },
|
||||
scoreCard: {
|
||||
backgroundColor: colors.surface, borderRadius: borderRadius.xl, paddingVertical: spacing.xl, paddingHorizontal: spacing.xxl,
|
||||
alignItems: 'center', borderWidth: 1, borderColor: colors.primary, marginBottom: spacing.lg,
|
||||
shadowColor: colors.primary, shadowOffset: { width: 0, height: 0 }, shadowOpacity: 0.3, shadowRadius: 20, elevation: 8,
|
||||
},
|
||||
scoreLabel: { color: colors.textSecondary, fontSize: fonts.sizes.sm, marginBottom: spacing.xs },
|
||||
scoreValue: { color: colors.text, fontSize: 56, fontWeight: fonts.weights.bold },
|
||||
statBonus: { color: colors.primary, fontSize: fonts.sizes.sm, fontWeight: fonts.weights.semibold, marginTop: spacing.xs },
|
||||
message: { color: colors.text, fontSize: fonts.sizes.md, textAlign: 'center', lineHeight: 24, marginBottom: spacing.xl, paddingHorizontal: spacing.md },
|
||||
detailsCard: {
|
||||
backgroundColor: colors.surface, borderRadius: borderRadius.md, padding: spacing.lg,
|
||||
width: '100%', borderWidth: 1, borderColor: colors.border, marginBottom: spacing.xl,
|
||||
},
|
||||
detailRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: spacing.sm },
|
||||
detailLabel: { color: colors.textSecondary, fontSize: fonts.sizes.sm },
|
||||
detailValue: { color: colors.text, fontSize: fonts.sizes.sm, fontWeight: fonts.weights.semibold },
|
||||
actions: { width: '100%' },
|
||||
btn: { marginBottom: spacing.sm },
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, Dimensions, Animated } from 'react-native';
|
||||
import { colors, fonts, spacing, borderRadius } from '../../utils/theme';
|
||||
import { getDifficulty } from '../../services/gameService';
|
||||
|
||||
const { width: SCREEN_W } = Dimensions.get('window');
|
||||
const AREA_SIZE = SCREEN_W - spacing.lg * 2;
|
||||
const ROUNDS = 10;
|
||||
|
||||
export default function ReflexTap({ currentDay, onComplete }) {
|
||||
const difficulty = getDifficulty(currentDay);
|
||||
const targetSize = Math.max(50 - difficulty * 4, 34);
|
||||
const spawnDelay = Math.max(800 - difficulty * 100, 400);
|
||||
|
||||
const [round, setRound] = useState(0);
|
||||
const [targetPos, setTargetPos] = useState(null);
|
||||
const [spawnTime, setSpawnTime] = useState(0);
|
||||
const [totalReaction, setTotalReaction] = useState(0);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
|
||||
const scale = useRef(new Animated.Value(0)).current;
|
||||
const glow = useRef(new Animated.Value(0.3)).current;
|
||||
const timerRef = useRef(null);
|
||||
|
||||
const spawnTarget = useCallback(() => {
|
||||
const x = Math.random() * (AREA_SIZE - targetSize);
|
||||
const y = Math.random() * (AREA_SIZE - targetSize);
|
||||
setTargetPos({ x, y });
|
||||
setSpawnTime(Date.now());
|
||||
|
||||
scale.setValue(0);
|
||||
glow.setValue(0.3);
|
||||
Animated.parallel([
|
||||
Animated.spring(scale, { toValue: 1, useNativeDriver: true, speed: 20, bounciness: 10 }),
|
||||
Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(glow, { toValue: 0.8, duration: 600, useNativeDriver: true }),
|
||||
Animated.timing(glow, { toValue: 0.3, duration: 600, useNativeDriver: true }),
|
||||
])
|
||||
),
|
||||
]).start();
|
||||
}, [targetSize]);
|
||||
|
||||
const start = () => {
|
||||
setRound(0);
|
||||
setTotalReaction(0);
|
||||
setPlaying(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (playing && round < ROUNDS) {
|
||||
timerRef.current = setTimeout(spawnTarget, round === 0 ? 500 : spawnDelay);
|
||||
}
|
||||
if (playing && round >= ROUNDS) {
|
||||
setPlaying(false);
|
||||
setTargetPos(null);
|
||||
const avgMs = Math.round(totalReaction / ROUNDS);
|
||||
// Score: faster = higher. Max ~15 points for <200ms avg, min 1 for >1000ms
|
||||
const score = Math.max(1, Math.min(15, Math.round((1000 - avgMs) / 60)));
|
||||
onComplete(score, { avgReaction: avgMs, rounds: ROUNDS });
|
||||
}
|
||||
return () => clearTimeout(timerRef.current);
|
||||
}, [playing, round]);
|
||||
|
||||
const handleTap = () => {
|
||||
if (!targetPos) return;
|
||||
const reaction = Date.now() - spawnTime;
|
||||
setTotalReaction((prev) => prev + reaction);
|
||||
setTargetPos(null);
|
||||
|
||||
// Hit animation
|
||||
Animated.timing(scale, { toValue: 0, duration: 100, useNativeDriver: true }).start(() => {
|
||||
setRound((prev) => prev + 1);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => { start(); }, []);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.roundText}>{round} / {ROUNDS}</Text>
|
||||
{totalReaction > 0 && round > 0 && (
|
||||
<Text style={styles.avgText}>{Math.round(totalReaction / round)}ms avg</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={[styles.area, { width: AREA_SIZE, height: AREA_SIZE }]}>
|
||||
{!playing && round === 0 && (
|
||||
<Text style={styles.readyText}>Get ready...</Text>
|
||||
)}
|
||||
|
||||
{targetPos && (
|
||||
<Pressable
|
||||
onPress={handleTap}
|
||||
style={[
|
||||
styles.targetHitbox,
|
||||
{ left: targetPos.x, top: targetPos.y, width: targetSize * 1.5, height: targetSize * 1.5 },
|
||||
]}
|
||||
>
|
||||
<Animated.View style={[styles.targetGlow, {
|
||||
width: targetSize * 1.8, height: targetSize * 1.8, borderRadius: targetSize * 0.9,
|
||||
opacity: glow, transform: [{ scale }],
|
||||
}]} />
|
||||
<Animated.View style={[styles.target, {
|
||||
width: targetSize, height: targetSize, borderRadius: targetSize / 2,
|
||||
transform: [{ scale }],
|
||||
}]}>
|
||||
<View style={[styles.targetCore, {
|
||||
width: targetSize * 0.5, height: targetSize * 0.5, borderRadius: targetSize * 0.25,
|
||||
}]} />
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Text style={styles.hint}>Tap the glowing targets as fast as you can</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, alignItems: 'center' },
|
||||
header: { flexDirection: 'row', justifyContent: 'space-between', width: '100%', paddingHorizontal: spacing.md, marginBottom: spacing.md },
|
||||
roundText: { color: colors.text, fontSize: fonts.sizes.lg, fontWeight: fonts.weights.bold },
|
||||
avgText: { color: colors.accent, fontSize: fonts.sizes.md, fontWeight: fonts.weights.medium },
|
||||
area: {
|
||||
backgroundColor: 'rgba(19, 24, 49, 0.6)', borderRadius: borderRadius.lg,
|
||||
borderWidth: 1, borderColor: colors.border, overflow: 'hidden',
|
||||
},
|
||||
readyText: { color: colors.textMuted, fontSize: fonts.sizes.lg, position: 'absolute', top: '45%', alignSelf: 'center' },
|
||||
targetHitbox: { position: 'absolute', alignItems: 'center', justifyContent: 'center' },
|
||||
targetGlow: { position: 'absolute', backgroundColor: colors.accentGlow },
|
||||
target: { backgroundColor: 'rgba(0, 229, 255, 0.25)', alignItems: 'center', justifyContent: 'center' },
|
||||
targetCore: {
|
||||
backgroundColor: colors.accent,
|
||||
shadowColor: colors.accent, shadowOffset: { width: 0, height: 0 }, shadowOpacity: 1, shadowRadius: 12, elevation: 8,
|
||||
},
|
||||
hint: { color: colors.textMuted, fontSize: fonts.sizes.sm, marginTop: spacing.md },
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { View, Text, StyleSheet, Animated, Pressable } from 'react-native';
|
||||
import { colors, fonts, spacing, borderRadius } from '../../utils/theme';
|
||||
|
||||
const SCENARIOS = [
|
||||
{
|
||||
situation: "It's 6 AM. Your alarm rings. You're exhausted.",
|
||||
skip: 'Hit snooze and sleep in',
|
||||
cont: 'Get up. The old you would sleep.',
|
||||
correct: 'cont',
|
||||
},
|
||||
{
|
||||
situation: 'Your friends invite you out. You haven\'t done your habit yet.',
|
||||
skip: 'Go out. You\'ll do it tomorrow.',
|
||||
cont: 'Finish first. Identity comes first.',
|
||||
correct: 'cont',
|
||||
},
|
||||
{
|
||||
situation: "You failed yesterday. You feel like giving up.",
|
||||
skip: 'Take a break. Start again next week.',
|
||||
cont: 'One bad day doesn\'t erase progress.',
|
||||
correct: 'cont',
|
||||
},
|
||||
{
|
||||
situation: 'You\'re scrolling social media. 30 minutes have passed.',
|
||||
skip: 'Keep scrolling. You deserve a break.',
|
||||
cont: 'Close the app. Reclaim your time.',
|
||||
correct: 'cont',
|
||||
},
|
||||
{
|
||||
situation: 'Your workout is hard today. Everything hurts.',
|
||||
skip: 'Stop early. You did enough.',
|
||||
cont: 'Finish the set. Pain is temporary.',
|
||||
correct: 'cont',
|
||||
},
|
||||
{
|
||||
situation: "Nobody would know if you skipped today.",
|
||||
skip: 'Skip it. No one is watching.',
|
||||
cont: 'You would know. Do it for yourself.',
|
||||
correct: 'cont',
|
||||
},
|
||||
{
|
||||
situation: "You're tired after work. Your habit feels impossible.",
|
||||
skip: 'Rest tonight. Tomorrow will be better.',
|
||||
cont: 'Do even 5 minutes. Show up.',
|
||||
correct: 'cont',
|
||||
},
|
||||
];
|
||||
|
||||
const ROUNDS = 5;
|
||||
|
||||
export default function TemptationChoice({ onComplete }) {
|
||||
const [round, setRound] = useState(0);
|
||||
const [score, setScore] = useState(0);
|
||||
const [feedback, setFeedback] = useState(null);
|
||||
const [scenarios] = useState(() => {
|
||||
// Shuffle and pick ROUNDS
|
||||
const shuffled = [...SCENARIOS].sort(() => Math.random() - 0.5);
|
||||
return shuffled.slice(0, ROUNDS);
|
||||
});
|
||||
|
||||
const fadeIn = useRef(new Animated.Value(0)).current;
|
||||
const feedbackScale = useRef(new Animated.Value(0)).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (round < ROUNDS) {
|
||||
fadeIn.setValue(0);
|
||||
Animated.timing(fadeIn, { toValue: 1, duration: 400, useNativeDriver: true }).start();
|
||||
}
|
||||
}, [round]);
|
||||
|
||||
useEffect(() => {
|
||||
if (round >= ROUNDS && round > 0) {
|
||||
onComplete(score, { rounds: ROUNDS, correct: score });
|
||||
}
|
||||
}, [round]);
|
||||
|
||||
const handleChoice = (choice) => {
|
||||
if (feedback) return;
|
||||
const scenario = scenarios[round];
|
||||
const isCorrect = choice === scenario.correct;
|
||||
|
||||
if (isCorrect) setScore((prev) => prev + 1);
|
||||
|
||||
setFeedback({
|
||||
correct: isCorrect,
|
||||
message: isCorrect
|
||||
? 'Your identity speaks louder than comfort.'
|
||||
: 'The old you made that choice. Choose again next time.',
|
||||
});
|
||||
|
||||
feedbackScale.setValue(0);
|
||||
Animated.spring(feedbackScale, { toValue: 1, useNativeDriver: true, speed: 14, bounciness: 8 }).start();
|
||||
|
||||
setTimeout(() => {
|
||||
setFeedback(null);
|
||||
setRound((prev) => prev + 1);
|
||||
}, 1800);
|
||||
};
|
||||
|
||||
if (round >= ROUNDS) return null;
|
||||
|
||||
const scenario = scenarios[round];
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.roundText}>{round + 1} / {ROUNDS}</Text>
|
||||
<Text style={styles.scoreText}>{score} correct</Text>
|
||||
</View>
|
||||
|
||||
<Animated.View style={[styles.card, { opacity: fadeIn }]}>
|
||||
<Text style={styles.situation}>{scenario.situation}</Text>
|
||||
</Animated.View>
|
||||
|
||||
{!feedback ? (
|
||||
<Animated.View style={[styles.choices, { opacity: fadeIn }]}>
|
||||
<Pressable
|
||||
style={[styles.choiceBtn, styles.skipBtn]}
|
||||
onPress={() => handleChoice('skip')}
|
||||
>
|
||||
<Text style={styles.choiceBtnText}>{scenario.skip}</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={[styles.choiceBtn, styles.contBtn]}
|
||||
onPress={() => handleChoice('cont')}
|
||||
>
|
||||
<Text style={styles.choiceBtnText}>{scenario.cont}</Text>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
) : (
|
||||
<Animated.View style={[styles.feedbackCard, { transform: [{ scale: feedbackScale }] }]}>
|
||||
<View style={[styles.feedbackDot, { backgroundColor: feedback.correct ? colors.success : colors.error }]} />
|
||||
<Text style={[styles.feedbackText, { color: feedback.correct ? colors.success : colors.error }]}>
|
||||
{feedback.correct ? 'Identity Aligned' : 'Temptation Won'}
|
||||
</Text>
|
||||
<Text style={styles.feedbackMessage}>{feedback.message}</Text>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
<Text style={styles.hint}>Choose how your new identity would respond</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, alignItems: 'center' },
|
||||
header: { flexDirection: 'row', justifyContent: 'space-between', width: '100%', paddingHorizontal: spacing.md, marginBottom: spacing.xl },
|
||||
roundText: { color: colors.text, fontSize: fonts.sizes.lg, fontWeight: fonts.weights.bold },
|
||||
scoreText: { color: colors.success, fontSize: fonts.sizes.md, fontWeight: fonts.weights.medium },
|
||||
card: {
|
||||
backgroundColor: colors.surface, borderRadius: borderRadius.lg, padding: spacing.xl,
|
||||
borderWidth: 1, borderColor: colors.border, width: '100%', marginBottom: spacing.xl,
|
||||
},
|
||||
situation: { color: colors.text, fontSize: fonts.sizes.lg, fontWeight: fonts.weights.semibold, lineHeight: 28, textAlign: 'center' },
|
||||
choices: { width: '100%', gap: spacing.md },
|
||||
choiceBtn: {
|
||||
paddingVertical: spacing.lg, paddingHorizontal: spacing.lg,
|
||||
borderRadius: borderRadius.md, borderWidth: 1.5,
|
||||
},
|
||||
skipBtn: { borderColor: 'rgba(255, 82, 82, 0.4)', backgroundColor: 'rgba(255, 82, 82, 0.06)' },
|
||||
contBtn: { borderColor: 'rgba(0, 230, 118, 0.4)', backgroundColor: 'rgba(0, 230, 118, 0.06)' },
|
||||
choiceBtnText: { color: colors.text, fontSize: fonts.sizes.md, textAlign: 'center', lineHeight: 22 },
|
||||
feedbackCard: {
|
||||
backgroundColor: colors.surface, borderRadius: borderRadius.lg, padding: spacing.xl,
|
||||
borderWidth: 1, borderColor: colors.border, width: '100%', alignItems: 'center',
|
||||
},
|
||||
feedbackDot: { width: 10, height: 10, borderRadius: 5, marginBottom: spacing.sm },
|
||||
feedbackText: { fontSize: fonts.sizes.lg, fontWeight: fonts.weights.bold, marginBottom: spacing.sm },
|
||||
feedbackMessage: { color: colors.textSecondary, fontSize: fonts.sizes.sm, textAlign: 'center', lineHeight: 20 },
|
||||
hint: { color: colors.textMuted, fontSize: fonts.sizes.sm, marginTop: 'auto', paddingBottom: spacing.md },
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, Animated, Dimensions } from 'react-native';
|
||||
import { colors, fonts, spacing, borderRadius } from '../../utils/theme';
|
||||
import { getDifficulty } from '../../services/gameService';
|
||||
|
||||
const { width: SCREEN_W } = Dimensions.get('window');
|
||||
const BAR_WIDTH = SCREEN_W - spacing.lg * 2 - spacing.md * 2;
|
||||
const ZONE_WIDTH_PCT = 0.15;
|
||||
const ROUNDS = 10;
|
||||
|
||||
export default function TimingTap({ currentDay, onComplete }) {
|
||||
const difficulty = getDifficulty(currentDay);
|
||||
const speed = 1200 - difficulty * 150; // ms for one sweep (faster at higher difficulty)
|
||||
const zoneWidth = Math.max(ZONE_WIDTH_PCT - difficulty * 0.015, 0.08);
|
||||
|
||||
const [round, setRound] = useState(0);
|
||||
const [results, setResults] = useState([]); // 'perfect' | 'good' | 'miss'
|
||||
const [lastResult, setLastResult] = useState(null);
|
||||
const [playing, setPlaying] = useState(true);
|
||||
|
||||
const barPos = useRef(new Animated.Value(0)).current;
|
||||
const animRef = useRef(null);
|
||||
const resultScale = useRef(new Animated.Value(0)).current;
|
||||
|
||||
// Zone position (random each round)
|
||||
const [zoneStart] = useState(() => 0.5 - zoneWidth / 2); // centered for first
|
||||
|
||||
const [zonePositions] = useState(() =>
|
||||
Array.from({ length: ROUNDS }, () => {
|
||||
const min = 0.1;
|
||||
const max = 0.9 - zoneWidth;
|
||||
return min + Math.random() * (max - min);
|
||||
})
|
||||
);
|
||||
|
||||
const startSweep = useCallback(() => {
|
||||
barPos.setValue(0);
|
||||
animRef.current = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(barPos, { toValue: 1, duration: speed, useNativeDriver: false }),
|
||||
Animated.timing(barPos, { toValue: 0, duration: speed, useNativeDriver: false }),
|
||||
])
|
||||
);
|
||||
animRef.current.start();
|
||||
}, [speed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (playing && round < ROUNDS) {
|
||||
startSweep();
|
||||
}
|
||||
return () => animRef.current?.stop();
|
||||
}, [round, playing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (round >= ROUNDS && results.length >= ROUNDS) {
|
||||
setPlaying(false);
|
||||
animRef.current?.stop();
|
||||
const perfects = results.filter((r) => r === 'perfect').length;
|
||||
const goods = results.filter((r) => r === 'good').length;
|
||||
const score = perfects * 2 + goods;
|
||||
onComplete(score, { perfects, goods, misses: ROUNDS - perfects - goods });
|
||||
}
|
||||
}, [results]);
|
||||
|
||||
const handleTap = () => {
|
||||
if (!playing || round >= ROUNDS) return;
|
||||
|
||||
// Get current position (0-1)
|
||||
animRef.current?.stop();
|
||||
let currentPos = 0;
|
||||
barPos.addListener(({ value }) => { currentPos = value; });
|
||||
// Read current value
|
||||
const id = barPos.addListener(({ value }) => { currentPos = value; });
|
||||
barPos.removeListener(id);
|
||||
|
||||
// Hacky but works: extract current value
|
||||
barPos.stopAnimation((value) => {
|
||||
const zone = zonePositions[round];
|
||||
const zoneCenter = zone + zoneWidth / 2;
|
||||
const distance = Math.abs(value - zoneCenter);
|
||||
|
||||
let result;
|
||||
if (distance < zoneWidth * 0.3) result = 'perfect';
|
||||
else if (distance < zoneWidth * 0.8) result = 'good';
|
||||
else result = 'miss';
|
||||
|
||||
setLastResult(result);
|
||||
setResults((prev) => [...prev, result]);
|
||||
|
||||
resultScale.setValue(0);
|
||||
Animated.spring(resultScale, { toValue: 1, useNativeDriver: true, speed: 20, bounciness: 10 }).start();
|
||||
|
||||
setTimeout(() => {
|
||||
setLastResult(null);
|
||||
setRound((prev) => prev + 1);
|
||||
}, 600);
|
||||
});
|
||||
};
|
||||
|
||||
const indicatorLeft = barPos.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0, BAR_WIDTH - 4],
|
||||
});
|
||||
|
||||
const currentZone = round < ROUNDS ? zonePositions[round] : 0.4;
|
||||
|
||||
const resultColor = lastResult === 'perfect' ? colors.accent : lastResult === 'good' ? colors.success : colors.error;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.roundText}>{Math.min(round + 1, ROUNDS)} / {ROUNDS}</Text>
|
||||
<View style={styles.resultsRow}>
|
||||
{results.map((r, i) => (
|
||||
<View
|
||||
key={i}
|
||||
style={[styles.resultDot, {
|
||||
backgroundColor: r === 'perfect' ? colors.accent : r === 'good' ? colors.success : colors.error,
|
||||
}]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.gameArea}>
|
||||
{/* Result flash */}
|
||||
{lastResult && (
|
||||
<Animated.View style={[styles.resultFlash, { transform: [{ scale: resultScale }] }]}>
|
||||
<Text style={[styles.resultText, { color: resultColor }]}>
|
||||
{lastResult.toUpperCase()}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* Timing bar */}
|
||||
<Pressable onPress={handleTap} style={styles.barContainer}>
|
||||
{/* Zone */}
|
||||
<View style={[styles.zone, {
|
||||
left: `${currentZone * 100}%`,
|
||||
width: `${zoneWidth * 100}%`,
|
||||
}]} />
|
||||
|
||||
{/* Moving indicator */}
|
||||
<Animated.View style={[styles.indicator, { left: indicatorLeft }]} />
|
||||
|
||||
{/* Bar background lines */}
|
||||
<View style={styles.barLine} />
|
||||
</Pressable>
|
||||
|
||||
<Text style={styles.tapHint}>TAP when the line hits the zone</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.hint}>Hit the glowing zone with perfect timing</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, alignItems: 'center' },
|
||||
header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', width: '100%', paddingHorizontal: spacing.md, marginBottom: spacing.xl },
|
||||
roundText: { color: colors.text, fontSize: fonts.sizes.lg, fontWeight: fonts.weights.bold },
|
||||
resultsRow: { flexDirection: 'row', gap: 4 },
|
||||
resultDot: { width: 8, height: 8, borderRadius: 4 },
|
||||
gameArea: { flex: 1, width: '100%', alignItems: 'center', justifyContent: 'center' },
|
||||
resultFlash: { position: 'absolute', top: '30%' },
|
||||
resultText: { fontSize: fonts.sizes.xxl, fontWeight: fonts.weights.bold, letterSpacing: 4 },
|
||||
barContainer: {
|
||||
width: BAR_WIDTH, height: 60,
|
||||
backgroundColor: colors.surface, borderRadius: borderRadius.md,
|
||||
borderWidth: 1, borderColor: colors.border,
|
||||
justifyContent: 'center', overflow: 'hidden',
|
||||
},
|
||||
barLine: {
|
||||
position: 'absolute', width: '100%', height: 2,
|
||||
backgroundColor: 'rgba(108, 99, 255, 0.15)', top: '50%',
|
||||
},
|
||||
zone: {
|
||||
position: 'absolute', height: '100%',
|
||||
backgroundColor: 'rgba(0, 229, 255, 0.12)',
|
||||
borderLeftWidth: 2, borderRightWidth: 2, borderColor: 'rgba(0, 229, 255, 0.4)',
|
||||
},
|
||||
indicator: {
|
||||
position: 'absolute', width: 4, height: '100%',
|
||||
backgroundColor: colors.text, borderRadius: 2,
|
||||
shadowColor: colors.text, shadowOffset: { width: 0, height: 0 }, shadowOpacity: 0.8, shadowRadius: 6,
|
||||
},
|
||||
tapHint: { color: colors.textMuted, fontSize: fonts.sizes.sm, marginTop: spacing.lg },
|
||||
hint: { color: colors.textMuted, fontSize: fonts.sizes.sm, marginTop: 'auto', paddingBottom: spacing.md },
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
// API Keys — move to environment variables for production
|
||||
export const GEMINI_API_KEY = 'AIzaSyBmCdxsw9zeDI-KzyRAE1dtFflo9rhKcBc';
|
||||
@@ -0,0 +1,119 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { NavigationContainer } from '@react-navigation/native';
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
import { Text } from 'react-native';
|
||||
|
||||
import SplashScreen from '../screens/SplashScreen';
|
||||
import OnboardingScreen from '../screens/OnboardingScreen';
|
||||
import LoginScreen from '../screens/LoginScreen';
|
||||
import RegisterScreen from '../screens/RegisterScreen';
|
||||
import CreateIdentityScreen from '../screens/CreateIdentityScreen';
|
||||
import IdentityStoryScreen from '../screens/IdentityStoryScreen';
|
||||
import HabitSelectionScreen from '../screens/HabitSelectionScreen';
|
||||
import HomeScreen from '../screens/HomeScreen';
|
||||
import DailyScreen from '../screens/DailyScreen';
|
||||
import StatsScreen from '../screens/StatsScreen';
|
||||
import MirrorScreen from '../screens/MirrorScreen';
|
||||
import MiniGameScreen from '../screens/MiniGameScreen';
|
||||
import CompletionScreen from '../screens/CompletionScreen';
|
||||
import ProfileScreen from '../screens/ProfileScreen';
|
||||
import DailyJournalScreen from '../screens/DailyJournalScreen';
|
||||
import JournalResultScreen from '../screens/JournalResultScreen';
|
||||
|
||||
import useAuthStore from '../store/useAuthStore';
|
||||
import * as authService from '../services/authService';
|
||||
import { colors } from '../utils/theme';
|
||||
|
||||
const Stack = createNativeStackNavigator();
|
||||
const Tab = createBottomTabNavigator();
|
||||
|
||||
function TabIcon({ label, focused }) {
|
||||
const icons = { Home: '◉', Daily: '◈', Stats: '◎', Profile: '◆' };
|
||||
return (
|
||||
<View style={tabStyles.iconContainer}>
|
||||
<Text numberOfLines={1} style={[tabStyles.label, { color: focused ? colors.primary : colors.textMuted }]}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text style={[tabStyles.icon, { color: focused ? colors.primary : colors.textMuted }]}>
|
||||
{icons[label] || '●'}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const tabStyles = StyleSheet.create({
|
||||
iconContainer: { alignItems: 'center', justifyContent: 'center', paddingTop: 6, width: 70 },
|
||||
label: { fontSize: 9, fontWeight: '600', marginBottom: 3 },
|
||||
icon: { fontSize: 18 },
|
||||
});
|
||||
|
||||
function MainTabs() {
|
||||
return (
|
||||
<Tab.Navigator
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
tabBarStyle: {
|
||||
backgroundColor: colors.surface,
|
||||
borderTopColor: colors.border,
|
||||
borderTopWidth: 1,
|
||||
height: 85,
|
||||
paddingBottom: 24,
|
||||
},
|
||||
tabBarShowLabel: false,
|
||||
}}
|
||||
>
|
||||
<Tab.Screen name="Home" component={HomeScreen}
|
||||
options={{ tabBarIcon: ({ focused }) => <TabIcon label="Home" focused={focused} /> }} />
|
||||
<Tab.Screen name="Daily" component={DailyScreen}
|
||||
options={{ tabBarIcon: ({ focused }) => <TabIcon label="Daily" focused={focused} /> }} />
|
||||
<Tab.Screen name="Stats" component={StatsScreen}
|
||||
options={{ tabBarIcon: ({ focused }) => <TabIcon label="Stats" focused={focused} /> }} />
|
||||
<Tab.Screen name="Profile" component={ProfileScreen}
|
||||
options={{ tabBarIcon: ({ focused }) => <TabIcon label="Profile" focused={focused} /> }} />
|
||||
</Tab.Navigator>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppNavigator() {
|
||||
const setSession = useAuthStore((s) => s.setSession);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const { data: { subscription } } = authService.onAuthStateChange((_event, session) => {
|
||||
setSession(session);
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
} catch (_) {
|
||||
// Network unavailable — auth listener will be inactive
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<NavigationContainer>
|
||||
<Stack.Navigator
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: colors.background },
|
||||
animation: 'fade',
|
||||
}}
|
||||
>
|
||||
{/* Splash is always the entry point — it decides where to go */}
|
||||
<Stack.Screen name="Splash" component={SplashScreen} />
|
||||
<Stack.Screen name="Onboarding" component={OnboardingScreen} />
|
||||
<Stack.Screen name="Login" component={LoginScreen} />
|
||||
<Stack.Screen name="Register" component={RegisterScreen} />
|
||||
<Stack.Screen name="IdentityStory" component={IdentityStoryScreen} />
|
||||
<Stack.Screen name="HabitSelection" component={HabitSelectionScreen} />
|
||||
<Stack.Screen name="CreateIdentity" component={CreateIdentityScreen} />
|
||||
<Stack.Screen name="MainTabs" component={MainTabs} />
|
||||
<Stack.Screen name="MiniGame" component={MiniGameScreen} />
|
||||
<Stack.Screen name="Mirror" component={MirrorScreen} />
|
||||
<Stack.Screen name="DailyJournal" component={DailyJournalScreen} />
|
||||
<Stack.Screen name="JournalResult" component={JournalResultScreen} />
|
||||
<Stack.Screen name="Completion" component={CompletionScreen} />
|
||||
</Stack.Navigator>
|
||||
</NavigationContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, Share,
|
||||
Animated, ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { showAlert } from '../components/NovaAlert';
|
||||
import ScreenWrapper from '../components/ScreenWrapper';
|
||||
import Planet from '../components/Planet';
|
||||
import Button from '../components/Button';
|
||||
import useIdentityStore from '../store/useIdentityStore';
|
||||
import useHabitStore from '../store/useHabitStore';
|
||||
import useAuthStore from '../store/useAuthStore';
|
||||
import { generateTransformationStory } from '../services/storyService';
|
||||
import { colors, fonts, spacing, borderRadius } from '../utils/theme';
|
||||
|
||||
export default function CompletionScreen({ navigation }) {
|
||||
const identity = useIdentityStore((s) => s.identity);
|
||||
const loadStats = useIdentityStore((s) => s.loadStats);
|
||||
const loadDailyLogs = useIdentityStore((s) => s.loadDailyLogs);
|
||||
const habits = useHabitStore((s) => s.habits);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const resetIdentity = useIdentityStore((s) => s.reset);
|
||||
const resetHabits = useHabitStore((s) => s.reset);
|
||||
|
||||
const [stats, setStats] = useState(null);
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [story, setStory] = useState(null);
|
||||
const [loadingStory, setLoadingStory] = useState(true);
|
||||
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||
const storyFade = useRef(new Animated.Value(0)).current;
|
||||
|
||||
useEffect(() => {
|
||||
Animated.timing(fadeAnim, { toValue: 1, duration: 800, delay: 300, useNativeDriver: true }).start();
|
||||
|
||||
const load = async () => {
|
||||
const [s, l] = await Promise.all([loadStats(), loadDailyLogs()]);
|
||||
setStats(s);
|
||||
setLogs(l);
|
||||
|
||||
// Generate transformation story
|
||||
try {
|
||||
const result = await generateTransformationStory(identity, habits, l, s);
|
||||
setStory(result);
|
||||
} catch (_) {
|
||||
// fallback is handled inside the service
|
||||
}
|
||||
setLoadingStory(false);
|
||||
Animated.timing(storyFade, { toValue: 1, duration: 600, useNativeDriver: true }).start();
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const yesCount = logs.filter((l) => l.identity_check === 'yes').length;
|
||||
const totalScore =
|
||||
(stats?.discipline_score || 0) + (stats?.focus_score || 0) + (stats?.consistency_score || 0);
|
||||
|
||||
const handleShare = async () => {
|
||||
const storyText = story
|
||||
? `${story.title}\n\n${story.paragraphs.join('\n\n')}\n\n${story.closing_line}`
|
||||
: '';
|
||||
|
||||
try {
|
||||
await Share.share({
|
||||
message: `I completed Nova40!\n\n${storyText}\n\n---\n"${identity?.title}"\n${yesCount}/40 days aligned | Score: ${totalScore}\n\n#Nova40 #Transformation`,
|
||||
});
|
||||
} catch (_) { /* cancelled */ }
|
||||
};
|
||||
|
||||
const handleNewJourney = () => {
|
||||
showAlert('New Journey', 'Ready to transform again?', [
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Begin',
|
||||
onPress: async () => {
|
||||
resetIdentity();
|
||||
resetHabits();
|
||||
await logout();
|
||||
navigation.reset({ index: 0, routes: [{ name: 'Login' }] });
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenWrapper>
|
||||
<Animated.ScrollView
|
||||
style={[styles.scroll, { opacity: fadeAnim }]}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Planet size={70} glowIntensity={1} />
|
||||
<Text style={styles.congrats}>TRANSFORMATION COMPLETE</Text>
|
||||
<Text style={styles.headerTitle}>You made it. You are Nova.</Text>
|
||||
</View>
|
||||
|
||||
{/* Transformation Story */}
|
||||
{loadingStory ? (
|
||||
<View style={styles.storyLoading}>
|
||||
<ActivityIndicator size="small" color={colors.primary} />
|
||||
<Text style={styles.storyLoadingText}>Writing your story...</Text>
|
||||
</View>
|
||||
) : story ? (
|
||||
<Animated.View style={[styles.storyCard, { opacity: storyFade }]}>
|
||||
<Text style={styles.storyBadge}>
|
||||
{story.source === 'ai' ? '✦ AI-Written Story' : '✦ Your Story'}
|
||||
</Text>
|
||||
<Text style={styles.storyTitle}>{story.title}</Text>
|
||||
{story.paragraphs.map((p, i) => (
|
||||
<Text key={i} style={styles.storyParagraph}>{p}</Text>
|
||||
))}
|
||||
<Text style={styles.closingLine}>{story.closing_line}</Text>
|
||||
</Animated.View>
|
||||
) : null}
|
||||
|
||||
{/* Identity Card */}
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardLabel}>YOUR IDENTITY</Text>
|
||||
<Text style={styles.cardTitle}>{identity?.title}</Text>
|
||||
</View>
|
||||
|
||||
{/* Stats Card */}
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardLabel}>FINAL STATS</Text>
|
||||
<View style={styles.statGrid}>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={[styles.statValue, { color: colors.primary }]}>
|
||||
{stats?.discipline_score || 0}
|
||||
</Text>
|
||||
<Text style={styles.statLabel}>Discipline</Text>
|
||||
</View>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={[styles.statValue, { color: colors.accent }]}>
|
||||
{stats?.focus_score || 0}
|
||||
</Text>
|
||||
<Text style={styles.statLabel}>Focus</Text>
|
||||
</View>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={[styles.statValue, { color: colors.success }]}>
|
||||
{stats?.consistency_score || 0}
|
||||
</Text>
|
||||
<Text style={styles.statLabel}>Consistency</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<StatRow label="Days Logged" value={`${logs.length} / 40`} />
|
||||
<StatRow label="Fully Aligned" value={`${yesCount} days`} />
|
||||
<StatRow label="Total Score" value={totalScore} color={colors.accent} />
|
||||
</View>
|
||||
|
||||
{/* Actions */}
|
||||
<View style={styles.actions}>
|
||||
<Button title="Share Your Story" onPress={handleShare} style={styles.actionBtn} />
|
||||
<Button
|
||||
title="Start New Journey"
|
||||
variant="secondary"
|
||||
onPress={handleNewJourney}
|
||||
style={styles.actionBtn}
|
||||
/>
|
||||
</View>
|
||||
</Animated.ScrollView>
|
||||
</ScreenWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
function StatRow({ label, value, color }) {
|
||||
return (
|
||||
<View style={styles.summaryRow}>
|
||||
<Text style={styles.summaryLabel}>{label}</Text>
|
||||
<Text style={[styles.summaryValue, color && { color }]}>{value}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scroll: { flex: 1 },
|
||||
scrollContent: {
|
||||
padding: spacing.lg,
|
||||
paddingBottom: spacing.xxl * 2,
|
||||
alignItems: 'center',
|
||||
},
|
||||
|
||||
// Header
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.xl,
|
||||
paddingTop: spacing.lg,
|
||||
},
|
||||
congrats: {
|
||||
color: colors.accent,
|
||||
fontSize: fonts.sizes.xs,
|
||||
fontWeight: fonts.weights.bold,
|
||||
letterSpacing: 3,
|
||||
marginTop: spacing.lg,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
headerTitle: {
|
||||
color: colors.text,
|
||||
fontSize: fonts.sizes.xl,
|
||||
fontWeight: fonts.weights.bold,
|
||||
textAlign: 'center',
|
||||
},
|
||||
|
||||
// Story
|
||||
storyLoading: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.xl,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
storyLoadingText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: fonts.sizes.sm,
|
||||
},
|
||||
storyCard: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.xl,
|
||||
width: '100%',
|
||||
marginBottom: spacing.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(108, 99, 255, 0.3)',
|
||||
},
|
||||
storyBadge: {
|
||||
color: colors.primaryLight,
|
||||
fontSize: fonts.sizes.xs,
|
||||
fontWeight: fonts.weights.medium,
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
storyTitle: {
|
||||
color: colors.text,
|
||||
fontSize: fonts.sizes.xl,
|
||||
fontWeight: fonts.weights.bold,
|
||||
marginBottom: spacing.lg,
|
||||
lineHeight: 30,
|
||||
},
|
||||
storyParagraph: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: fonts.sizes.md,
|
||||
lineHeight: 26,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
closingLine: {
|
||||
color: colors.accent,
|
||||
fontSize: fonts.sizes.md,
|
||||
fontWeight: fonts.weights.semibold,
|
||||
fontStyle: 'italic',
|
||||
marginTop: spacing.md,
|
||||
lineHeight: 24,
|
||||
},
|
||||
|
||||
// Cards
|
||||
card: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.lg,
|
||||
width: '100%',
|
||||
marginBottom: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
cardLabel: {
|
||||
color: colors.primary,
|
||||
fontSize: fonts.sizes.xs,
|
||||
fontWeight: fonts.weights.bold,
|
||||
letterSpacing: 2,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
cardTitle: {
|
||||
color: colors.text,
|
||||
fontSize: fonts.sizes.lg,
|
||||
fontWeight: fonts.weights.semibold,
|
||||
},
|
||||
|
||||
// Stats
|
||||
statGrid: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
statItem: { alignItems: 'center' },
|
||||
statValue: {
|
||||
fontSize: fonts.sizes.xxl,
|
||||
fontWeight: fonts.weights.bold,
|
||||
},
|
||||
statLabel: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: fonts.sizes.xs,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: colors.border,
|
||||
marginVertical: spacing.md,
|
||||
},
|
||||
summaryRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
summaryLabel: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: fonts.sizes.sm,
|
||||
},
|
||||
summaryValue: {
|
||||
color: colors.text,
|
||||
fontSize: fonts.sizes.sm,
|
||||
fontWeight: fonts.weights.semibold,
|
||||
},
|
||||
|
||||
// Actions
|
||||
actions: {
|
||||
width: '100%',
|
||||
marginTop: spacing.lg,
|
||||
},
|
||||
actionBtn: {
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,330 @@
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, ScrollView,
|
||||
KeyboardAvoidingView, Platform, Animated, Pressable,
|
||||
} from 'react-native';
|
||||
import { showAlert } from '../components/NovaAlert';
|
||||
import ScreenWrapper from '../components/ScreenWrapper';
|
||||
import Input from '../components/Input';
|
||||
import HabitInput from '../components/HabitInput';
|
||||
import Button from '../components/Button';
|
||||
import useIdentityStore from '../store/useIdentityStore';
|
||||
import useAppStore from '../store/useAppStore';
|
||||
import { colors, fonts, spacing, borderRadius } from '../utils/theme';
|
||||
import { getSuggestionsForIdentity } from '../utils/helpers';
|
||||
|
||||
const MAX_HABITS = 5;
|
||||
|
||||
export default function CreateIdentityScreen({ navigation }) {
|
||||
const initialIdentity = useAppStore((s) => s.initialIdentity);
|
||||
const createIdentity = useIdentityStore((s) => s.createIdentity);
|
||||
|
||||
const [title, setTitle] = useState(initialIdentity || '');
|
||||
const [habits, setHabits] = useState([{ id: 1, value: '' }]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [lastAddedId, setLastAddedId] = useState(null);
|
||||
const nextId = useRef(2);
|
||||
|
||||
// Entrance animation
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||
const slideAnim = useRef(new Animated.Value(20)).current;
|
||||
|
||||
useEffect(() => {
|
||||
Animated.parallel([
|
||||
Animated.timing(fadeAnim, { toValue: 1, duration: 500, useNativeDriver: true }),
|
||||
Animated.timing(slideAnim, { toValue: 0, duration: 500, useNativeDriver: true }),
|
||||
]).start();
|
||||
}, []);
|
||||
|
||||
// Smart suggestions based on identity input
|
||||
const suggestions = useMemo(() => {
|
||||
const existing = habits.map((h) => h.value.toLowerCase().trim()).filter(Boolean);
|
||||
return getSuggestionsForIdentity(title).filter(
|
||||
(s) => !existing.some((e) => e === s.toLowerCase())
|
||||
);
|
||||
}, [title, habits]);
|
||||
|
||||
// --- Habit CRUD ---
|
||||
const addHabit = (prefill = '') => {
|
||||
if (habits.length >= MAX_HABITS) return;
|
||||
const id = nextId.current++;
|
||||
setHabits((prev) => [...prev, { id, value: prefill }]);
|
||||
setLastAddedId(id);
|
||||
};
|
||||
|
||||
const updateHabit = (id, value) => {
|
||||
setHabits((prev) => prev.map((h) => (h.id === id ? { ...h, value } : h)));
|
||||
};
|
||||
|
||||
const deleteHabit = (id) => {
|
||||
if (habits.length <= 1) return; // keep at least 1
|
||||
setHabits((prev) => prev.filter((h) => h.id !== id));
|
||||
};
|
||||
|
||||
const addSuggestion = (text) => {
|
||||
// Fill first empty habit, or add new one
|
||||
const emptyHabit = habits.find((h) => !h.value.trim());
|
||||
if (emptyHabit) {
|
||||
updateHabit(emptyHabit.id, text);
|
||||
} else {
|
||||
addHabit(text);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Submit ---
|
||||
const handleCreate = async () => {
|
||||
const trimmedTitle = title.trim();
|
||||
if (!trimmedTitle) {
|
||||
showAlert('Missing Identity', 'Please enter who you want to become.');
|
||||
return;
|
||||
}
|
||||
|
||||
const validHabits = habits
|
||||
.map((h) => h.value.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (validHabits.length === 0) {
|
||||
showAlert('No Habits', 'Please add at least 1 habit.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for duplicates
|
||||
const unique = [...new Set(validHabits.map((h) => h.toLowerCase()))];
|
||||
if (unique.length < validHabits.length) {
|
||||
showAlert('Duplicates', 'Please remove duplicate habits.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const customHabits = validHabits.map((h) => ({ title: h }));
|
||||
await createIdentity(trimmedTitle, '', customHabits, '');
|
||||
navigation.reset({ index: 0, routes: [{ name: 'MainTabs' }] });
|
||||
} catch (error) {
|
||||
showAlert('Error', error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const validCount = habits.filter((h) => h.value.trim()).length;
|
||||
|
||||
return (
|
||||
<ScreenWrapper>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
style={styles.flex}
|
||||
>
|
||||
<Animated.View style={[styles.flex, { opacity: fadeAnim, transform: [{ translateY: slideAnim }] }]}>
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* Header */}
|
||||
<Text style={styles.title}>I want to become...</Text>
|
||||
<Text style={styles.subtitle}>Define your new identity and the habits that support it</Text>
|
||||
|
||||
{/* Identity Input */}
|
||||
<Input
|
||||
label="New Identity"
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
placeholder="e.g., A disciplined, healthy person"
|
||||
/>
|
||||
|
||||
{/* Habit Section */}
|
||||
<View style={styles.habitSection}>
|
||||
<View style={styles.habitHeader}>
|
||||
<Text style={styles.sectionTitle}>Your Habits</Text>
|
||||
<Text style={styles.habitCounter}>{validCount} / {MAX_HABITS}</Text>
|
||||
</View>
|
||||
|
||||
{/* Habit Inputs */}
|
||||
{habits.map((habit, idx) => (
|
||||
<HabitInput
|
||||
key={habit.id}
|
||||
index={idx}
|
||||
value={habit.value}
|
||||
onChangeText={(text) => updateHabit(habit.id, text)}
|
||||
onDelete={() => deleteHabit(habit.id)}
|
||||
autoFocus={habit.id === lastAddedId}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Add Habit Button */}
|
||||
{habits.length < MAX_HABITS && (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.addBtn, pressed && styles.addBtnPressed]}
|
||||
onPress={() => addHabit()}
|
||||
>
|
||||
<Text style={styles.addBtnIcon}>+</Text>
|
||||
<Text style={styles.addBtnText}>Add Habit</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Smart Suggestions */}
|
||||
{suggestions.length > 0 && (
|
||||
<View style={styles.suggestionsSection}>
|
||||
<Text style={styles.suggestionsTitle}>Suggested habits</Text>
|
||||
<View style={styles.suggestionsGrid}>
|
||||
{suggestions.map((s) => (
|
||||
<Pressable
|
||||
key={s}
|
||||
style={({ pressed }) => [styles.suggestionChip, pressed && styles.suggestionChipPressed]}
|
||||
onPress={() => addSuggestion(s)}
|
||||
disabled={habits.length >= MAX_HABITS && !habits.some((h) => !h.value.trim())}
|
||||
>
|
||||
<Text style={styles.suggestionPlus}>+</Text>
|
||||
<Text style={styles.suggestionText}>{s}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<View style={styles.submitArea}>
|
||||
<Button
|
||||
title="Start 40-Day Journey"
|
||||
onPress={handleCreate}
|
||||
loading={loading}
|
||||
disabled={!title.trim() || validCount === 0}
|
||||
style={styles.submitBtn}
|
||||
/>
|
||||
<Text style={styles.submitHint}>
|
||||
{validCount === 0
|
||||
? 'Add at least 1 habit to begin'
|
||||
: `${validCount} habit${validCount > 1 ? 's' : ''} ready. Let's go.`}
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
</KeyboardAvoidingView>
|
||||
</ScreenWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flex: { flex: 1 },
|
||||
scroll: { flex: 1 },
|
||||
scrollContent: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.xxl,
|
||||
paddingBottom: spacing.xxl,
|
||||
},
|
||||
|
||||
// Header
|
||||
title: {
|
||||
color: colors.text,
|
||||
fontSize: fonts.sizes.xxl,
|
||||
fontWeight: fonts.weights.bold,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
subtitle: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: fonts.sizes.md,
|
||||
lineHeight: 22,
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
|
||||
// Habits
|
||||
habitSection: {
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
habitHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
sectionTitle: {
|
||||
color: colors.text,
|
||||
fontSize: fonts.sizes.lg,
|
||||
fontWeight: fonts.weights.semibold,
|
||||
},
|
||||
habitCounter: {
|
||||
color: colors.textMuted,
|
||||
fontSize: fonts.sizes.sm,
|
||||
fontWeight: fonts.weights.medium,
|
||||
},
|
||||
|
||||
// Add button
|
||||
addBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: borderRadius.md,
|
||||
borderWidth: 1.5,
|
||||
borderColor: colors.border,
|
||||
borderStyle: 'dashed',
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
addBtnPressed: { opacity: 0.6 },
|
||||
addBtnIcon: {
|
||||
color: colors.primary,
|
||||
fontSize: fonts.sizes.lg,
|
||||
fontWeight: fonts.weights.bold,
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
addBtnText: {
|
||||
color: colors.primary,
|
||||
fontSize: fonts.sizes.md,
|
||||
fontWeight: fonts.weights.medium,
|
||||
},
|
||||
|
||||
// Suggestions
|
||||
suggestionsSection: {
|
||||
marginTop: spacing.xl,
|
||||
},
|
||||
suggestionsTitle: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: fonts.sizes.sm,
|
||||
fontWeight: fonts.weights.medium,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
suggestionsGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
suggestionChip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: borderRadius.full,
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
suggestionChipPressed: { opacity: 0.6, borderColor: colors.primary },
|
||||
suggestionPlus: {
|
||||
color: colors.accent,
|
||||
fontSize: fonts.sizes.sm,
|
||||
fontWeight: fonts.weights.bold,
|
||||
marginRight: spacing.xs,
|
||||
},
|
||||
suggestionText: {
|
||||
color: colors.text,
|
||||
fontSize: fonts.sizes.sm,
|
||||
},
|
||||
|
||||
// Submit
|
||||
submitArea: {
|
||||
marginTop: spacing.xxl,
|
||||
alignItems: 'center',
|
||||
},
|
||||
submitBtn: {
|
||||
width: '100%',
|
||||
},
|
||||
submitHint: {
|
||||
color: colors.textMuted,
|
||||
fontSize: fonts.sizes.sm,
|
||||
marginTop: spacing.md,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,298 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable,
|
||||
Animated, KeyboardAvoidingView, Platform,
|
||||
} from 'react-native';
|
||||
import { showAlert } from '../components/NovaAlert';
|
||||
import ScreenWrapper from '../components/ScreenWrapper';
|
||||
import Input from '../components/Input';
|
||||
import Button from '../components/Button';
|
||||
import useIdentityStore from '../store/useIdentityStore';
|
||||
import useHabitStore from '../store/useHabitStore';
|
||||
import { generateDailyReflection, saveDailyJournal } from '../services/journalService';
|
||||
import { colors, fonts, spacing, borderRadius } from '../utils/theme';
|
||||
|
||||
const MOODS = [
|
||||
{ key: 'great', emoji: '😄', label: 'Great' },
|
||||
{ key: 'good', emoji: '🙂', label: 'Good' },
|
||||
{ key: 'okay', emoji: '😐', label: 'Okay' },
|
||||
{ key: 'bad', emoji: '😔', label: 'Bad' },
|
||||
{ key: 'terrible', emoji: '😞', label: 'Terrible' },
|
||||
];
|
||||
|
||||
const IDENTITY_OPTIONS = [
|
||||
{ key: 'yes', label: 'Yes', color: colors.success },
|
||||
{ key: 'almost', label: 'Almost', color: colors.warning },
|
||||
{ key: 'no', label: 'No', color: colors.error },
|
||||
];
|
||||
|
||||
export default function DailyJournalScreen({ navigation }) {
|
||||
const identity = useIdentityStore((s) => s.identity);
|
||||
const currentDay = useIdentityStore((s) => s.currentDay);
|
||||
const habits = useHabitStore((s) => s.habits);
|
||||
const habitLogs = useHabitStore((s) => s.habitLogs);
|
||||
|
||||
const [mood, setMood] = useState('');
|
||||
const [win, setWin] = useState('');
|
||||
const [struggle, setStruggle] = useState('');
|
||||
const [highlight, setHighlight] = useState('');
|
||||
const [note, setNote] = useState('');
|
||||
const [identityCheck, setIdentityCheck] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||
|
||||
useEffect(() => {
|
||||
Animated.timing(fadeAnim, { toValue: 1, duration: 500, useNativeDriver: true }).start();
|
||||
}, []);
|
||||
|
||||
const habitsCompleted = Object.values(habitLogs).filter(Boolean).length;
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!mood) {
|
||||
showAlert('Mood', 'How are you feeling today?');
|
||||
return;
|
||||
}
|
||||
if (!identityCheck) {
|
||||
showAlert('Identity Check', 'Did you embody your identity today?');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
// Generate AI reflection
|
||||
const ai = await generateDailyReflection({ mood, win, struggle, highlight, note });
|
||||
|
||||
// Save everything
|
||||
await saveDailyJournal(identity.id, currentDay, {
|
||||
identityCheck,
|
||||
habitsCompleted,
|
||||
mood,
|
||||
win,
|
||||
struggle,
|
||||
highlight,
|
||||
note,
|
||||
aiTitle: ai.title,
|
||||
aiSummary: ai.summary,
|
||||
aiQuote: ai.quote,
|
||||
});
|
||||
|
||||
// Navigate to result
|
||||
navigation.navigate('JournalResult', {
|
||||
aiTitle: ai.title,
|
||||
aiSummary: ai.summary,
|
||||
aiQuote: ai.quote,
|
||||
source: ai.source,
|
||||
dayNumber: currentDay,
|
||||
date: new Date().toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }),
|
||||
mood,
|
||||
identityTitle: identity?.title,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Journal save error:', e);
|
||||
showAlert('Error', e?.message || 'Failed to save. Try again.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenWrapper>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
style={styles.flex}
|
||||
>
|
||||
<Animated.ScrollView
|
||||
style={[styles.flex, { opacity: fadeAnim }]}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* Header */}
|
||||
<Text style={styles.title}>Daily Journal</Text>
|
||||
<Text style={styles.subtitle}>Day {currentDay} — How was your day?</Text>
|
||||
|
||||
{/* Mood */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>How are you feeling?</Text>
|
||||
<View style={styles.moodRow}>
|
||||
{MOODS.map((m) => (
|
||||
<Pressable
|
||||
key={m.key}
|
||||
style={[styles.moodBtn, mood === m.key && styles.moodBtnSelected]}
|
||||
onPress={() => setMood(m.key)}
|
||||
>
|
||||
<Text style={styles.moodEmoji}>{m.emoji}</Text>
|
||||
<Text style={[styles.moodLabel, mood === m.key && styles.moodLabelSelected]}>
|
||||
{m.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Identity Check */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Did you embody "{identity?.title}"?</Text>
|
||||
<View style={styles.identityRow}>
|
||||
{IDENTITY_OPTIONS.map((opt) => (
|
||||
<Pressable
|
||||
key={opt.key}
|
||||
style={[
|
||||
styles.identityBtn,
|
||||
identityCheck === opt.key && { borderColor: opt.color, backgroundColor: `${opt.color}10` },
|
||||
]}
|
||||
onPress={() => setIdentityCheck(opt.key)}
|
||||
>
|
||||
<Text style={[
|
||||
styles.identityLabel,
|
||||
identityCheck === opt.key && { color: opt.color },
|
||||
]}>
|
||||
{opt.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Habits summary */}
|
||||
<View style={styles.habitSummary}>
|
||||
<Text style={styles.habitSummaryText}>
|
||||
{habitsCompleted}/{habits.length} habits completed today
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Journal inputs */}
|
||||
<View style={styles.section}>
|
||||
<Input
|
||||
label="What went well today?"
|
||||
value={win}
|
||||
onChangeText={setWin}
|
||||
placeholder="A small or big win..."
|
||||
/>
|
||||
<Input
|
||||
label="What was difficult?"
|
||||
value={struggle}
|
||||
onChangeText={setStruggle}
|
||||
placeholder="A challenge or struggle..."
|
||||
/>
|
||||
<Input
|
||||
label="Highlight of the day"
|
||||
value={highlight}
|
||||
onChangeText={setHighlight}
|
||||
placeholder="A moment that stood out..."
|
||||
/>
|
||||
<Input
|
||||
label="Reflection"
|
||||
value={note}
|
||||
onChangeText={setNote}
|
||||
placeholder="Anything on your mind..."
|
||||
multiline
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Save */}
|
||||
<Button
|
||||
title={saving ? 'Generating reflection...' : 'Save My Day'}
|
||||
onPress={handleSave}
|
||||
loading={saving}
|
||||
disabled={!mood || !identityCheck}
|
||||
style={styles.saveBtn}
|
||||
/>
|
||||
</Animated.ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</ScreenWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flex: { flex: 1 },
|
||||
scrollContent: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.xl,
|
||||
paddingBottom: spacing.xxl * 2,
|
||||
},
|
||||
title: {
|
||||
color: colors.text,
|
||||
fontSize: fonts.sizes.xxl,
|
||||
fontWeight: fonts.weights.bold,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
subtitle: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: fonts.sizes.md,
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
section: {
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
sectionTitle: {
|
||||
color: colors.text,
|
||||
fontSize: fonts.sizes.md,
|
||||
fontWeight: fonts.weights.semibold,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
|
||||
// Mood
|
||||
moodRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
moodBtn: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.sm,
|
||||
borderRadius: borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surface,
|
||||
flex: 1,
|
||||
marginHorizontal: 3,
|
||||
},
|
||||
moodBtnSelected: {
|
||||
borderColor: colors.primary,
|
||||
backgroundColor: 'rgba(108, 99, 255, 0.1)',
|
||||
},
|
||||
moodEmoji: { fontSize: 22, marginBottom: 4 },
|
||||
moodLabel: { color: colors.textMuted, fontSize: 10, fontWeight: fonts.weights.medium },
|
||||
moodLabelSelected: { color: colors.primary },
|
||||
|
||||
// Identity check
|
||||
identityRow: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
identityBtn: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surface,
|
||||
},
|
||||
identityLabel: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: fonts.sizes.sm,
|
||||
fontWeight: fonts.weights.medium,
|
||||
},
|
||||
|
||||
// Habits summary
|
||||
habitSummary: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: borderRadius.md,
|
||||
padding: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
marginBottom: spacing.lg,
|
||||
alignItems: 'center',
|
||||
},
|
||||
habitSummaryText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: fonts.sizes.sm,
|
||||
},
|
||||
|
||||
saveBtn: {
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,466 @@
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, Pressable,
|
||||
TouchableOpacity, Animated, KeyboardAvoidingView, Platform,
|
||||
} from 'react-native';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
let ViewShot, Sharing, MediaLibrary, Print;
|
||||
try {
|
||||
ViewShot = require('react-native-view-shot').default;
|
||||
Sharing = require('expo-sharing');
|
||||
MediaLibrary = require('expo-media-library');
|
||||
Print = require('expo-print');
|
||||
} catch (_) {
|
||||
// Modules unavailable — share features disabled
|
||||
}
|
||||
import { showAlert } from '../components/NovaAlert';
|
||||
import ScreenWrapper from '../components/ScreenWrapper';
|
||||
import Button from '../components/Button';
|
||||
import Input from '../components/Input';
|
||||
import useIdentityStore from '../store/useIdentityStore';
|
||||
import useHabitStore from '../store/useHabitStore';
|
||||
import { generateDailyReflection, saveDailyJournal, getJournalEntry } from '../services/journalService';
|
||||
import { colors, fonts, spacing, borderRadius } from '../utils/theme';
|
||||
import { getDayNarrative } from '../utils/helpers';
|
||||
|
||||
const MOODS = [
|
||||
{ key: 'great', emoji: '😄', label: 'Great' },
|
||||
{ key: 'good', emoji: '🙂', label: 'Good' },
|
||||
{ key: 'okay', emoji: '😐', label: 'Okay' },
|
||||
{ key: 'bad', emoji: '😔', label: 'Bad' },
|
||||
{ key: 'terrible', emoji: '😞', label: 'Awful' },
|
||||
];
|
||||
|
||||
const MOOD_EMOJIS = { great: '😄', good: '🙂', okay: '😐', bad: '😔', terrible: '😞' };
|
||||
|
||||
function HabitItem({ habit, completed, onToggle, disabled }) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.habitItem, completed && styles.habitCompleted]}
|
||||
onPress={onToggle}
|
||||
activeOpacity={0.7}
|
||||
disabled={disabled}
|
||||
>
|
||||
<View style={[styles.checkbox, completed && styles.checkboxChecked]}>
|
||||
{completed && <Text style={styles.checkmark}>✓</Text>}
|
||||
</View>
|
||||
<View style={styles.habitContent}>
|
||||
<Text style={[styles.habitTitle, completed && styles.habitTitleDone]}>{habit.title}</Text>
|
||||
{habit.description ? <Text style={styles.habitDesc}>{habit.description}</Text> : null}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DailyScreen() {
|
||||
const identity = useIdentityStore((s) => s.identity);
|
||||
const currentDay = useIdentityStore((s) => s.currentDay);
|
||||
const habits = useHabitStore((s) => s.habits);
|
||||
const habitLogs = useHabitStore((s) => s.habitLogs);
|
||||
const toggleHabit = useHabitStore((s) => s.toggleHabit);
|
||||
const loadTodayLogs = useHabitStore((s) => s.loadTodayLogs);
|
||||
|
||||
const [identityCheck, setIdentityCheck] = useState('');
|
||||
const [mood, setMood] = useState('');
|
||||
const [win, setWin] = useState('');
|
||||
const [struggle, setStruggle] = useState('');
|
||||
const [highlight, setHighlight] = useState('');
|
||||
const [note, setNote] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Already completed today
|
||||
const [completed, setCompleted] = useState(false);
|
||||
const [savedEntry, setSavedEntry] = useState(null);
|
||||
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||
const viewShotRef = useRef(null);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
const init = async () => {
|
||||
loadTodayLogs();
|
||||
|
||||
// Check if today is already completed
|
||||
if (identity?.id) {
|
||||
const existing = await getJournalEntry(identity.id);
|
||||
if (existing && existing.ai_title) {
|
||||
setCompleted(true);
|
||||
setSavedEntry(existing);
|
||||
// Fill form with saved data
|
||||
setIdentityCheck(existing.identity_check || '');
|
||||
setMood(existing.mood || '');
|
||||
setWin(existing.win || '');
|
||||
setStruggle(existing.struggle || '');
|
||||
setHighlight(existing.highlight || '');
|
||||
setNote(existing.note || '');
|
||||
} else {
|
||||
setCompleted(false);
|
||||
setSavedEntry(null);
|
||||
setIdentityCheck('');
|
||||
setMood('');
|
||||
setWin('');
|
||||
setStruggle('');
|
||||
setHighlight('');
|
||||
setNote('');
|
||||
}
|
||||
}
|
||||
|
||||
Animated.timing(fadeAnim, { toValue: 1, duration: 600, useNativeDriver: true }).start();
|
||||
};
|
||||
init();
|
||||
}, [identity?.id])
|
||||
);
|
||||
|
||||
const habitsCompleted = Object.values(habitLogs).filter(Boolean).length;
|
||||
|
||||
const handleCompleteDay = async () => {
|
||||
if (!identityCheck) {
|
||||
showAlert('Identity Check', 'Did you embody your identity today?');
|
||||
return;
|
||||
}
|
||||
if (!mood) {
|
||||
showAlert('Mood', 'How are you feeling today?');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const ai = await generateDailyReflection({ mood, win, struggle, highlight, note });
|
||||
|
||||
const saved = await saveDailyJournal(identity.id, currentDay, {
|
||||
identityCheck,
|
||||
habitsCompleted,
|
||||
mood, win, struggle, highlight, note,
|
||||
aiTitle: ai.title,
|
||||
aiSummary: ai.summary,
|
||||
aiQuote: ai.quote,
|
||||
});
|
||||
|
||||
setCompleted(true);
|
||||
setSavedEntry({ ...saved, ai_title: ai.title, ai_summary: ai.summary, ai_quote: ai.quote });
|
||||
} catch (error) {
|
||||
console.warn('DailyScreen save error:', error);
|
||||
showAlert('Error', error?.message || 'Failed to save. Try again.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Share/Save/PDF actions ---
|
||||
const handleShare = async () => {
|
||||
try {
|
||||
const uri = await viewShotRef.current.capture();
|
||||
await Sharing.shareAsync(uri, { mimeType: 'image/png' });
|
||||
} catch (_) { showAlert('Error', 'Could not share.'); }
|
||||
};
|
||||
|
||||
const handleSaveImage = async () => {
|
||||
try {
|
||||
const { status } = await MediaLibrary.requestPermissionsAsync();
|
||||
if (status !== 'granted') { showAlert('Permission', 'Allow access to save images.'); return; }
|
||||
const uri = await viewShotRef.current.capture();
|
||||
await MediaLibrary.saveToLibraryAsync(uri);
|
||||
showAlert('Saved', 'Journal saved to your gallery.');
|
||||
} catch (_) { showAlert('Error', 'Could not save image.'); }
|
||||
};
|
||||
|
||||
const handleExportPDF = async () => {
|
||||
try {
|
||||
const e = savedEntry;
|
||||
const dateStr = new Date().toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
|
||||
const html = `<html><head><style>
|
||||
body{font-family:-apple-system,sans-serif;padding:40px;background:#0A0E1A;color:#FFF}
|
||||
.brand{text-align:center;color:#6C63FF;font-size:14px;letter-spacing:4px;margin-bottom:30px}
|
||||
.title{font-size:26px;font-weight:bold;text-align:center;margin-bottom:16px}
|
||||
.summary{font-size:16px;line-height:1.8;color:#8A8FB5;text-align:center;margin-bottom:24px}
|
||||
.quote{font-size:18px;font-style:italic;color:#00E5FF;text-align:center;margin-bottom:24px}
|
||||
.meta{text-align:center;color:#4A5078;font-size:13px}
|
||||
.divider{border-top:1px solid #1E2548;margin:16px 0}
|
||||
</style></head><body>
|
||||
<div class="brand">NOVA40 — DAILY JOURNAL</div>
|
||||
<div class="title">${e?.ai_title || ''}</div>
|
||||
<div class="summary">${e?.ai_summary || ''}</div>
|
||||
<div class="divider"></div>
|
||||
<div class="quote">"${e?.ai_quote || ''}"</div>
|
||||
<div class="divider"></div>
|
||||
<div class="meta">Day ${currentDay} — ${dateStr}</div>
|
||||
<div class="meta">${identity?.title || ''}</div>
|
||||
</body></html>`;
|
||||
const { uri } = await Print.printToFileAsync({ html });
|
||||
await Sharing.shareAsync(uri, { mimeType: 'application/pdf' });
|
||||
} catch (_) { showAlert('Error', 'Could not generate PDF.'); }
|
||||
};
|
||||
|
||||
const narrative = getDayNarrative(currentDay);
|
||||
|
||||
if (!identity) {
|
||||
return (
|
||||
<ScreenWrapper>
|
||||
<View style={styles.loadingContainer}>
|
||||
<Text style={styles.loadingText}>Loading...</Text>
|
||||
</View>
|
||||
</ScreenWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScreenWrapper>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
style={styles.flex}
|
||||
>
|
||||
<Animated.ScrollView
|
||||
style={[styles.flex, { opacity: fadeAnim }]}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<Text style={styles.dayLabel}>DAY {currentDay} OF 40</Text>
|
||||
<Text style={styles.narrative}>{narrative}</Text>
|
||||
|
||||
{/* === COMPLETED STATE: show journal card + actions === */}
|
||||
{completed && savedEntry?.ai_title ? (
|
||||
<>
|
||||
{/* Completion badge */}
|
||||
<View style={styles.completedBadge}>
|
||||
<Text style={styles.completedBadgeText}>✓ Day {currentDay} Complete</Text>
|
||||
</View>
|
||||
|
||||
{/* Shareable journal card */}
|
||||
{ViewShot ? (
|
||||
<ViewShot ref={viewShotRef} options={{ format: 'png', quality: 1 }}>
|
||||
<View style={styles.journalCard}>
|
||||
<Text style={styles.cardBrand}>NOVA40</Text>
|
||||
<Text style={styles.cardMoodEmoji}>{MOOD_EMOJIS[savedEntry.mood] || '✦'}</Text>
|
||||
<Text style={styles.cardTitle}>{savedEntry.ai_title}</Text>
|
||||
<Text style={styles.cardSummary}>{savedEntry.ai_summary}</Text>
|
||||
<View style={styles.cardDivider} />
|
||||
<Text style={styles.cardQuote}>"{savedEntry.ai_quote}"</Text>
|
||||
<View style={styles.cardFooter}>
|
||||
<Text style={styles.cardDay}>Day {currentDay}</Text>
|
||||
<Text style={styles.cardDate}>
|
||||
{new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||
</Text>
|
||||
</View>
|
||||
{identity?.title && <Text style={styles.cardIdentity}>{identity.title}</Text>}
|
||||
</View>
|
||||
</ViewShot>
|
||||
) : (
|
||||
<View style={styles.journalCard}>
|
||||
<Text style={styles.cardBrand}>NOVA40</Text>
|
||||
<Text style={styles.cardMoodEmoji}>{MOOD_EMOJIS[savedEntry.mood] || '✦'}</Text>
|
||||
<Text style={styles.cardTitle}>{savedEntry.ai_title}</Text>
|
||||
<Text style={styles.cardSummary}>{savedEntry.ai_summary}</Text>
|
||||
<View style={styles.cardDivider} />
|
||||
<Text style={styles.cardQuote}>"{savedEntry.ai_quote}"</Text>
|
||||
<View style={styles.cardFooter}>
|
||||
<Text style={styles.cardDay}>Day {currentDay}</Text>
|
||||
<Text style={styles.cardDate}>
|
||||
{new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||
</Text>
|
||||
</View>
|
||||
{identity?.title && <Text style={styles.cardIdentity}>{identity.title}</Text>}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Share actions */}
|
||||
<View style={styles.actions}>
|
||||
<Button title="Share Journal" onPress={handleShare} style={styles.actionBtn} />
|
||||
<View style={styles.actionRow}>
|
||||
<Button title="Save Image" onPress={handleSaveImage} variant="secondary" style={styles.halfBtn} />
|
||||
<Button title="Export PDF" onPress={handleExportPDF} variant="secondary" style={styles.halfBtn} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Show what was logged */}
|
||||
<View style={styles.loggedSection}>
|
||||
<Text style={styles.loggedTitle}>What you logged</Text>
|
||||
{savedEntry.win ? <LoggedRow label="Win" value={savedEntry.win} /> : null}
|
||||
{savedEntry.struggle ? <LoggedRow label="Struggle" value={savedEntry.struggle} /> : null}
|
||||
{savedEntry.highlight ? <LoggedRow label="Highlight" value={savedEntry.highlight} /> : null}
|
||||
{savedEntry.note ? <LoggedRow label="Reflection" value={savedEntry.note} /> : null}
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* === FORM STATE: habits + journal inputs === */}
|
||||
|
||||
{/* Habits */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Today's Habits</Text>
|
||||
{habits.map((habit) => (
|
||||
<HabitItem
|
||||
key={habit.id}
|
||||
habit={habit}
|
||||
completed={!!habitLogs[habit.id]}
|
||||
onToggle={() => toggleHabit(habit.id)}
|
||||
disabled={false}
|
||||
/>
|
||||
))}
|
||||
<Text style={styles.habitCount}>{habitsCompleted}/{habits.length} completed</Text>
|
||||
</View>
|
||||
|
||||
{/* Identity Check */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Identity Check</Text>
|
||||
<Text style={styles.sectionSubtitle}>Did you embody "{identity?.title}" today?</Text>
|
||||
<View style={styles.identityRow}>
|
||||
{[
|
||||
{ key: 'yes', label: 'Yes', color: colors.success },
|
||||
{ key: 'almost', label: 'Almost', color: colors.warning },
|
||||
{ key: 'no', label: 'No', color: colors.error },
|
||||
].map((opt) => (
|
||||
<Pressable
|
||||
key={opt.key}
|
||||
style={[styles.identityBtn, identityCheck === opt.key && { borderColor: opt.color, backgroundColor: `${opt.color}10` }]}
|
||||
onPress={() => setIdentityCheck(opt.key)}
|
||||
>
|
||||
<Text style={[styles.identityLabel, identityCheck === opt.key && { color: opt.color }]}>{opt.label}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Mood */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>How are you feeling?</Text>
|
||||
<View style={styles.moodRow}>
|
||||
{MOODS.map((m) => (
|
||||
<Pressable key={m.key} style={[styles.moodBtn, mood === m.key && styles.moodBtnSelected]} onPress={() => setMood(m.key)}>
|
||||
<Text style={styles.moodEmoji}>{m.emoji}</Text>
|
||||
<Text style={[styles.moodLabel, mood === m.key && styles.moodLabelSelected]}>{m.label}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Journal */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Daily Journal</Text>
|
||||
<Input label="What went well?" value={win} onChangeText={setWin} placeholder="A small or big win..." />
|
||||
<Input label="What was difficult?" value={struggle} onChangeText={setStruggle} placeholder="A challenge you faced..." />
|
||||
<Input label="Highlight of the day" value={highlight} onChangeText={setHighlight} placeholder="A moment that stood out..." />
|
||||
<Input label="Reflection" value={note} onChangeText={setNote} placeholder="Anything on your mind..." multiline />
|
||||
</View>
|
||||
|
||||
{/* Complete Day */}
|
||||
<Button
|
||||
title={saving ? 'Generating reflection...' : 'Complete Day'}
|
||||
onPress={handleCompleteDay}
|
||||
loading={saving}
|
||||
disabled={!identityCheck || !mood}
|
||||
style={styles.completeBtn}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Animated.ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</ScreenWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
function LoggedRow({ label, value }) {
|
||||
return (
|
||||
<View style={styles.loggedRow}>
|
||||
<Text style={styles.loggedLabel}>{label}</Text>
|
||||
<Text style={styles.loggedValue}>{value}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flex: { flex: 1 },
|
||||
loadingContainer: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
||||
loadingText: { color: colors.textSecondary, fontSize: fonts.sizes.md },
|
||||
scrollContent: { padding: spacing.lg, paddingBottom: spacing.xxl * 2 },
|
||||
dayLabel: { color: colors.accent, fontSize: fonts.sizes.xs, fontWeight: fonts.weights.bold, letterSpacing: 3, marginBottom: spacing.sm },
|
||||
narrative: { color: colors.text, fontSize: fonts.sizes.xl, fontWeight: fonts.weights.semibold, lineHeight: 32, marginBottom: spacing.xl },
|
||||
|
||||
section: { marginBottom: spacing.xl },
|
||||
sectionTitle: { color: colors.text, fontSize: fonts.sizes.lg, fontWeight: fonts.weights.semibold, marginBottom: spacing.sm },
|
||||
sectionSubtitle: { color: colors.textSecondary, fontSize: fonts.sizes.sm, marginBottom: spacing.md },
|
||||
|
||||
// Habits
|
||||
habitItem: { flexDirection: 'row', alignItems: 'center', backgroundColor: colors.surface, borderRadius: borderRadius.md, padding: spacing.md, marginBottom: spacing.sm, borderWidth: 1, borderColor: colors.border },
|
||||
habitCompleted: { borderColor: colors.success, backgroundColor: 'rgba(0, 230, 118, 0.05)' },
|
||||
checkbox: { width: 28, height: 28, borderRadius: 14, borderWidth: 2, borderColor: colors.textMuted, alignItems: 'center', justifyContent: 'center', marginRight: spacing.md },
|
||||
checkboxChecked: { borderColor: colors.success, backgroundColor: colors.success },
|
||||
checkmark: { color: colors.background, fontSize: 14, fontWeight: fonts.weights.bold },
|
||||
habitContent: { flex: 1 },
|
||||
habitTitle: { color: colors.text, fontSize: fonts.sizes.md, fontWeight: fonts.weights.medium },
|
||||
habitTitleDone: { textDecorationLine: 'line-through', opacity: 0.6 },
|
||||
habitDesc: { color: colors.textSecondary, fontSize: fonts.sizes.sm, marginTop: 2 },
|
||||
habitCount: { color: colors.textMuted, fontSize: fonts.sizes.sm, textAlign: 'center', marginTop: spacing.sm },
|
||||
|
||||
// Identity check
|
||||
identityRow: { flexDirection: 'row', gap: spacing.sm },
|
||||
identityBtn: { flex: 1, alignItems: 'center', paddingVertical: spacing.md, borderRadius: borderRadius.md, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.surface },
|
||||
identityLabel: { color: colors.textSecondary, fontSize: fonts.sizes.sm, fontWeight: fonts.weights.medium },
|
||||
|
||||
// Mood
|
||||
moodRow: { flexDirection: 'row', justifyContent: 'space-between' },
|
||||
moodBtn: { alignItems: 'center', paddingVertical: spacing.sm, paddingHorizontal: spacing.sm, borderRadius: borderRadius.md, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.surface, flex: 1, marginHorizontal: 3 },
|
||||
moodBtnSelected: { borderColor: colors.primary, backgroundColor: 'rgba(108, 99, 255, 0.1)' },
|
||||
moodEmoji: { fontSize: 20, marginBottom: 3 },
|
||||
moodLabel: { color: colors.textMuted, fontSize: 9, fontWeight: fonts.weights.medium },
|
||||
moodLabelSelected: { color: colors.primary },
|
||||
|
||||
completeBtn: { marginTop: spacing.md },
|
||||
|
||||
// === Completed state ===
|
||||
completedBadge: {
|
||||
alignSelf: 'center',
|
||||
backgroundColor: 'rgba(0, 230, 118, 0.1)',
|
||||
borderRadius: borderRadius.full,
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(0, 230, 118, 0.3)',
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
completedBadgeText: {
|
||||
color: colors.success,
|
||||
fontSize: fonts.sizes.sm,
|
||||
fontWeight: fonts.weights.semibold,
|
||||
},
|
||||
|
||||
// Journal card
|
||||
journalCard: {
|
||||
backgroundColor: colors.background,
|
||||
borderRadius: borderRadius.xl,
|
||||
padding: spacing.xl,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(108, 99, 255, 0.25)',
|
||||
alignItems: 'center',
|
||||
},
|
||||
cardBrand: { color: colors.primary, fontSize: fonts.sizes.xs, fontWeight: fonts.weights.bold, letterSpacing: 5, marginBottom: spacing.lg },
|
||||
cardMoodEmoji: { fontSize: 36, marginBottom: spacing.md },
|
||||
cardTitle: { color: colors.text, fontSize: fonts.sizes.xl, fontWeight: fonts.weights.bold, textAlign: 'center', lineHeight: 30, marginBottom: spacing.md },
|
||||
cardSummary: { color: colors.textSecondary, fontSize: fonts.sizes.md, textAlign: 'center', lineHeight: 24, marginBottom: spacing.lg, paddingHorizontal: spacing.sm },
|
||||
cardDivider: { width: 40, height: 2, backgroundColor: colors.primary, borderRadius: 1, marginBottom: spacing.lg },
|
||||
cardQuote: { color: colors.accent, fontSize: fonts.sizes.md, fontStyle: 'italic', textAlign: 'center', lineHeight: 24, marginBottom: spacing.lg, paddingHorizontal: spacing.sm },
|
||||
cardFooter: { flexDirection: 'row', justifyContent: 'center', gap: spacing.md, marginBottom: spacing.sm },
|
||||
cardDay: { color: colors.textMuted, fontSize: fonts.sizes.sm, fontWeight: fonts.weights.semibold },
|
||||
cardDate: { color: colors.textMuted, fontSize: fonts.sizes.sm },
|
||||
cardIdentity: { color: colors.textMuted, fontSize: fonts.sizes.xs, fontStyle: 'italic' },
|
||||
|
||||
// Actions
|
||||
actions: { marginTop: spacing.xl },
|
||||
actionBtn: { marginBottom: spacing.sm },
|
||||
actionRow: { flexDirection: 'row', gap: spacing.sm, marginBottom: spacing.sm },
|
||||
halfBtn: { flex: 1 },
|
||||
|
||||
// Logged data
|
||||
loggedSection: {
|
||||
marginTop: spacing.xl,
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: borderRadius.md,
|
||||
padding: spacing.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
loggedTitle: { color: colors.text, fontSize: fonts.sizes.md, fontWeight: fonts.weights.semibold, marginBottom: spacing.md },
|
||||
loggedRow: { marginBottom: spacing.md },
|
||||
loggedLabel: { color: colors.textMuted, fontSize: fonts.sizes.xs, fontWeight: fonts.weights.medium, marginBottom: spacing.xs },
|
||||
loggedValue: { color: colors.textSecondary, fontSize: fonts.sizes.sm, lineHeight: 20 },
|
||||
});
|
||||
@@ -0,0 +1,418 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, Pressable,
|
||||
TextInput, Animated,
|
||||
} from 'react-native';
|
||||
import { showAlert } from '../components/NovaAlert';
|
||||
import ScreenWrapper from '../components/ScreenWrapper';
|
||||
import Button from '../components/Button';
|
||||
import useIdentityStore from '../store/useIdentityStore';
|
||||
import { colors, fonts, spacing, borderRadius } from '../utils/theme';
|
||||
|
||||
const MAX_HABITS = 5;
|
||||
|
||||
function HabitChip({ text, selected, onToggle, disabled }) {
|
||||
return (
|
||||
<Pressable
|
||||
style={[styles.chip, selected && styles.chipSelected, disabled && !selected && styles.chipDisabled]}
|
||||
onPress={onToggle}
|
||||
disabled={disabled && !selected}
|
||||
>
|
||||
<Text style={[styles.chipCheck, selected && styles.chipCheckSelected]}>
|
||||
{selected ? '✓' : '+'}
|
||||
</Text>
|
||||
<Text style={[styles.chipText, selected && styles.chipTextSelected]}>{text}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HabitSelectionScreen({ navigation, route }) {
|
||||
const { aiResult, story } = route.params;
|
||||
const createIdentity = useIdentityStore((s) => s.createIdentity);
|
||||
|
||||
const [selectedHabits, setSelectedHabits] = useState([]);
|
||||
const [customHabit, setCustomHabit] = useState('');
|
||||
const [customHabits, setCustomHabits] = useState([]);
|
||||
const [editingTitle, setEditingTitle] = useState(false);
|
||||
const [title, setTitle] = useState(aiResult.identity_title);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||
|
||||
useEffect(() => {
|
||||
Animated.timing(fadeAnim, { toValue: 1, duration: 500, useNativeDriver: true }).start();
|
||||
}, []);
|
||||
|
||||
const allSelected = [...selectedHabits, ...customHabits];
|
||||
const atMax = allSelected.length >= MAX_HABITS;
|
||||
|
||||
const toggleHabit = (habit) => {
|
||||
setSelectedHabits((prev) =>
|
||||
prev.includes(habit)
|
||||
? prev.filter((h) => h !== habit)
|
||||
: atMax ? prev : [...prev, habit]
|
||||
);
|
||||
};
|
||||
|
||||
const addCustomHabit = () => {
|
||||
const trimmed = customHabit.trim();
|
||||
if (!trimmed) return;
|
||||
if (allSelected.length >= MAX_HABITS) {
|
||||
showAlert('Limit', `Maximum ${MAX_HABITS} habits allowed.`);
|
||||
return;
|
||||
}
|
||||
if ([...selectedHabits, ...customHabits].some((h) => h.toLowerCase() === trimmed.toLowerCase())) {
|
||||
showAlert('Duplicate', 'This habit is already in your list.');
|
||||
return;
|
||||
}
|
||||
setCustomHabits((prev) => [...prev, trimmed]);
|
||||
setCustomHabit('');
|
||||
};
|
||||
|
||||
const removeCustom = (habit) => {
|
||||
setCustomHabits((prev) => prev.filter((h) => h !== habit));
|
||||
};
|
||||
|
||||
const handleStart = async () => {
|
||||
if (allSelected.length === 0) {
|
||||
showAlert('No Habits', 'Please select at least 1 habit.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const habits = allSelected.map((h) => ({ title: h }));
|
||||
const identityTitle = title.trim() || aiResult.identity_title;
|
||||
const description = aiResult.identity_summary || '';
|
||||
await createIdentity(identityTitle, description, habits, story || '');
|
||||
navigation.reset({ index: 0, routes: [{ name: 'MainTabs' }] });
|
||||
} catch (e) {
|
||||
showAlert('Error', e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenWrapper>
|
||||
<Animated.ScrollView
|
||||
style={[styles.scroll, { opacity: fadeAnim }]}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* Source badge */}
|
||||
<View style={styles.sourceBadge}>
|
||||
<Text style={styles.sourceBadgeText}>
|
||||
{aiResult.source === 'ai' ? '✦ AI Generated' : '⚡ Smart Suggestions'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Identity Title */}
|
||||
<View style={styles.identityCard}>
|
||||
<Text style={styles.identityLabel}>YOUR NEW IDENTITY</Text>
|
||||
{editingTitle ? (
|
||||
<TextInput
|
||||
style={styles.titleInput}
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
onBlur={() => setEditingTitle(false)}
|
||||
autoFocus
|
||||
selectionColor={colors.primary}
|
||||
/>
|
||||
) : (
|
||||
<Pressable onPress={() => setEditingTitle(true)}>
|
||||
<Text style={styles.identityTitle}>{title}</Text>
|
||||
<Text style={styles.editHint}>Tap to edit</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Summary */}
|
||||
<Text style={styles.summary}>{aiResult.identity_summary}</Text>
|
||||
|
||||
{/* Habit selection */}
|
||||
<View style={styles.habitSection}>
|
||||
<View style={styles.habitHeader}>
|
||||
<Text style={styles.sectionTitle}>Select Your Habits</Text>
|
||||
<Text style={[styles.counter, atMax && styles.counterFull]}>
|
||||
{allSelected.length} / {MAX_HABITS}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* AI suggested habits */}
|
||||
{aiResult.suggested_habits.map((habit) => (
|
||||
<HabitChip
|
||||
key={habit}
|
||||
text={habit}
|
||||
selected={selectedHabits.includes(habit)}
|
||||
onToggle={() => toggleHabit(habit)}
|
||||
disabled={atMax}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Custom habits */}
|
||||
{customHabits.map((habit) => (
|
||||
<View key={habit} style={styles.customChipRow}>
|
||||
<View style={[styles.chip, styles.chipSelected, styles.customChip]}>
|
||||
<Text style={[styles.chipCheck, styles.chipCheckSelected]}>✓</Text>
|
||||
<Text style={[styles.chipText, styles.chipTextSelected, { flex: 1 }]}>{habit}</Text>
|
||||
</View>
|
||||
<Pressable onPress={() => removeCustom(habit)} style={styles.removeBtn}>
|
||||
<Text style={styles.removeBtnText}>✕</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{/* Add custom */}
|
||||
{!atMax && (
|
||||
<View style={styles.addRow}>
|
||||
<TextInput
|
||||
style={styles.addInput}
|
||||
value={customHabit}
|
||||
onChangeText={setCustomHabit}
|
||||
placeholder="Add your own habit..."
|
||||
placeholderTextColor={colors.textMuted}
|
||||
selectionColor={colors.primary}
|
||||
onSubmitEditing={addCustomHabit}
|
||||
returnKeyType="done"
|
||||
/>
|
||||
<Pressable
|
||||
style={[styles.addBtn, !customHabit.trim() && styles.addBtnDisabled]}
|
||||
onPress={addCustomHabit}
|
||||
disabled={!customHabit.trim()}
|
||||
>
|
||||
<Text style={styles.addBtnText}>+</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Start button */}
|
||||
<Button
|
||||
title="Start 40-Day Journey"
|
||||
onPress={handleStart}
|
||||
loading={loading}
|
||||
disabled={allSelected.length === 0}
|
||||
style={styles.startBtn}
|
||||
/>
|
||||
|
||||
<Text style={styles.startHint}>
|
||||
{allSelected.length === 0
|
||||
? 'Select at least 1 habit to begin'
|
||||
: `${allSelected.length} habit${allSelected.length > 1 ? 's' : ''} selected. Ready to transform.`}
|
||||
</Text>
|
||||
</Animated.ScrollView>
|
||||
</ScreenWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scroll: { flex: 1 },
|
||||
scrollContent: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.xl,
|
||||
paddingBottom: spacing.xxl,
|
||||
},
|
||||
|
||||
// Source badge
|
||||
sourceBadge: {
|
||||
alignSelf: 'flex-start',
|
||||
backgroundColor: 'rgba(108, 99, 255, 0.08)',
|
||||
borderRadius: borderRadius.full,
|
||||
paddingVertical: spacing.xs,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(108, 99, 255, 0.2)',
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
sourceBadgeText: {
|
||||
color: colors.primaryLight,
|
||||
fontSize: fonts.sizes.xs,
|
||||
fontWeight: fonts.weights.medium,
|
||||
},
|
||||
|
||||
// Identity card
|
||||
identityCard: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.primary,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
identityLabel: {
|
||||
color: colors.primary,
|
||||
fontSize: fonts.sizes.xs,
|
||||
fontWeight: fonts.weights.bold,
|
||||
letterSpacing: 2,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
identityTitle: {
|
||||
color: colors.text,
|
||||
fontSize: fonts.sizes.xl,
|
||||
fontWeight: fonts.weights.bold,
|
||||
lineHeight: 32,
|
||||
},
|
||||
titleInput: {
|
||||
color: colors.text,
|
||||
fontSize: fonts.sizes.xl,
|
||||
fontWeight: fonts.weights.bold,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.primary,
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
editHint: {
|
||||
color: colors.textMuted,
|
||||
fontSize: fonts.sizes.xs,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
|
||||
// Summary
|
||||
summary: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: fonts.sizes.md,
|
||||
lineHeight: 24,
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
|
||||
// Habits
|
||||
habitSection: {
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
habitHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
sectionTitle: {
|
||||
color: colors.text,
|
||||
fontSize: fonts.sizes.lg,
|
||||
fontWeight: fonts.weights.semibold,
|
||||
},
|
||||
counter: {
|
||||
color: colors.textMuted,
|
||||
fontSize: fonts.sizes.sm,
|
||||
fontWeight: fonts.weights.medium,
|
||||
},
|
||||
counterFull: {
|
||||
color: colors.warning,
|
||||
},
|
||||
|
||||
// Chips
|
||||
chip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: borderRadius.md,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
chipSelected: {
|
||||
borderColor: colors.primary,
|
||||
backgroundColor: 'rgba(108, 99, 255, 0.08)',
|
||||
},
|
||||
chipDisabled: {
|
||||
opacity: 0.4,
|
||||
},
|
||||
chipCheck: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1.5,
|
||||
borderColor: colors.textMuted,
|
||||
textAlign: 'center',
|
||||
lineHeight: 22,
|
||||
fontSize: 12,
|
||||
color: colors.textMuted,
|
||||
marginRight: spacing.md,
|
||||
flexShrink: 0,
|
||||
},
|
||||
chipCheckSelected: {
|
||||
borderColor: colors.primary,
|
||||
backgroundColor: colors.primary,
|
||||
color: colors.text,
|
||||
},
|
||||
chipText: {
|
||||
color: colors.text,
|
||||
fontSize: fonts.sizes.md,
|
||||
flex: 1,
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
chipTextSelected: {
|
||||
color: colors.text,
|
||||
fontWeight: fonts.weights.medium,
|
||||
},
|
||||
|
||||
// Custom chips
|
||||
customChipRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
customChip: {
|
||||
flex: 1,
|
||||
marginBottom: 0,
|
||||
},
|
||||
removeBtn: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
removeBtnText: {
|
||||
color: colors.error,
|
||||
fontSize: 16,
|
||||
},
|
||||
|
||||
// Add custom
|
||||
addRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
addInput: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: borderRadius.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
color: colors.text,
|
||||
fontSize: fonts.sizes.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderStyle: 'dashed',
|
||||
},
|
||||
addBtn: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: colors.primary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
addBtnDisabled: {
|
||||
opacity: 0.4,
|
||||
},
|
||||
addBtnText: {
|
||||
color: colors.text,
|
||||
fontSize: 22,
|
||||
fontWeight: fonts.weights.bold,
|
||||
lineHeight: 24,
|
||||
},
|
||||
|
||||
// Start
|
||||
startBtn: {},
|
||||
startHint: {
|
||||
color: colors.textMuted,
|
||||
fontSize: fonts.sizes.sm,
|
||||
textAlign: 'center',
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { View, Text, StyleSheet, Animated, Dimensions } from 'react-native';
|
||||
import { CommonActions, useFocusEffect } from '@react-navigation/native';
|
||||
import { showAlert } from '../components/NovaAlert';
|
||||
import ScreenWrapper from '../components/ScreenWrapper';
|
||||
import OrbitContainer from '../components/OrbitContainer';
|
||||
import Button from '../components/Button';
|
||||
import useIdentityStore from '../store/useIdentityStore';
|
||||
import { colors, fonts, spacing } from '../utils/theme';
|
||||
import { getDayPhase, isCriticalDay } from '../utils/helpers';
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||
const ORBIT_SIZE = Math.min(SCREEN_WIDTH * 0.82, 340);
|
||||
const PLANET_SIZE = ORBIT_SIZE * 0.28;
|
||||
|
||||
export default function HomeScreen({ navigation }) {
|
||||
const identity = useIdentityStore((s) => s.identity);
|
||||
const currentDay = useIdentityStore((s) => s.currentDay);
|
||||
const loadIdentity = useIdentityStore((s) => s.fetchIdentity);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Entrance animations
|
||||
const fadeHeader = useRef(new Animated.Value(0)).current;
|
||||
const fadeOrbit = useRef(new Animated.Value(0)).current;
|
||||
const scaleOrbit = useRef(new Animated.Value(0.85)).current;
|
||||
const fadeBottom = useRef(new Animated.Value(0)).current;
|
||||
const slideBottom = useRef(new Animated.Value(30)).current;
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
let active = true;
|
||||
const init = async () => {
|
||||
await loadIdentity();
|
||||
if (active) setLoading(false);
|
||||
};
|
||||
init();
|
||||
return () => { active = false; };
|
||||
}, [])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !identity) {
|
||||
navigation.dispatch(
|
||||
CommonActions.reset({ index: 0, routes: [{ name: 'IdentityStory' }] })
|
||||
);
|
||||
}
|
||||
if (!loading && identity) {
|
||||
Animated.stagger(150, [
|
||||
Animated.timing(fadeHeader, { toValue: 1, duration: 600, useNativeDriver: true }),
|
||||
Animated.parallel([
|
||||
Animated.timing(fadeOrbit, { toValue: 1, duration: 800, useNativeDriver: true }),
|
||||
Animated.spring(scaleOrbit, { toValue: 1, useNativeDriver: true, speed: 8, bounciness: 6 }),
|
||||
]),
|
||||
Animated.parallel([
|
||||
Animated.timing(fadeBottom, { toValue: 1, duration: 500, useNativeDriver: true }),
|
||||
Animated.timing(slideBottom, { toValue: 0, duration: 500, useNativeDriver: true }),
|
||||
]),
|
||||
]).start();
|
||||
}
|
||||
}, [loading, identity]);
|
||||
|
||||
useEffect(() => {
|
||||
if (identity && currentDay > 0 && isCriticalDay(currentDay)) {
|
||||
showAlert(
|
||||
'Critical Day',
|
||||
`Day ${currentDay} is a pivotal moment in your journey. Complete a focus challenge to power through.`,
|
||||
[
|
||||
{ text: 'Later', style: 'cancel' },
|
||||
{ text: 'Take Challenge', onPress: () => navigation.navigate('MiniGame') },
|
||||
]
|
||||
);
|
||||
}
|
||||
}, [currentDay]);
|
||||
|
||||
const handleDotPress = useCallback((_dayIndex, status) => {
|
||||
if (status === 'current') {
|
||||
navigation.navigate('Daily');
|
||||
}
|
||||
// Future: show past day summary for completed dots
|
||||
}, [navigation]);
|
||||
|
||||
if (loading || !identity) {
|
||||
return (
|
||||
<ScreenWrapper>
|
||||
<View style={styles.loadingContainer}>
|
||||
<Text style={styles.loadingText}>Loading your universe...</Text>
|
||||
</View>
|
||||
</ScreenWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (currentDay > 40) {
|
||||
navigation.dispatch(
|
||||
CommonActions.reset({ index: 0, routes: [{ name: 'Completion' }] })
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const phase = getDayPhase(currentDay);
|
||||
const glowIntensity = 0.3 + (currentDay / 40) * 0.7;
|
||||
const progress = ((currentDay - 1) / 40) * 100;
|
||||
|
||||
return (
|
||||
<ScreenWrapper>
|
||||
<View style={styles.container}>
|
||||
{/* Phase & Identity Header */}
|
||||
<Animated.View style={[styles.header, { opacity: fadeHeader }]}>
|
||||
<Text style={styles.phase}>{phase.toUpperCase()} PHASE</Text>
|
||||
<Text style={styles.identity} numberOfLines={2}>{identity.title}</Text>
|
||||
</Animated.View>
|
||||
|
||||
{/* Orbit System */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.orbitArea,
|
||||
{ opacity: fadeOrbit, transform: [{ scale: scaleOrbit }] },
|
||||
]}
|
||||
>
|
||||
<OrbitContainer
|
||||
size={ORBIT_SIZE}
|
||||
currentDay={currentDay}
|
||||
planetSize={PLANET_SIZE}
|
||||
glowIntensity={glowIntensity}
|
||||
onDotPress={handleDotPress}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
{/* Bottom Info */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.bottomSection,
|
||||
{ opacity: fadeBottom, transform: [{ translateY: slideBottom }] },
|
||||
]}
|
||||
>
|
||||
{/* Day Counter */}
|
||||
<View style={styles.dayRow}>
|
||||
<Text style={styles.dayNumber}>Day {currentDay}</Text>
|
||||
<Text style={styles.dayTotal}> of 40</Text>
|
||||
</View>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<View style={styles.progressBar}>
|
||||
<View style={[styles.progressFill, { width: `${progress}%` }]}>
|
||||
<View style={styles.progressGlow} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Critical Day Badge */}
|
||||
{isCriticalDay(currentDay) && (
|
||||
<View style={styles.criticalBadge}>
|
||||
<View style={styles.criticalDot} />
|
||||
<Text style={styles.criticalText}>CRITICAL DAY — Stay focused</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Start Button */}
|
||||
<Button
|
||||
title="Start Today"
|
||||
onPress={() => navigation.navigate('Daily')}
|
||||
style={styles.startButton}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</ScreenWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingTop: spacing.lg,
|
||||
paddingBottom: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: fonts.sizes.md,
|
||||
},
|
||||
|
||||
// Header
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
paddingTop: spacing.sm,
|
||||
},
|
||||
phase: {
|
||||
color: colors.accent,
|
||||
fontSize: fonts.sizes.xs,
|
||||
fontWeight: fonts.weights.bold,
|
||||
letterSpacing: 4,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
identity: {
|
||||
color: colors.text,
|
||||
fontSize: fonts.sizes.lg,
|
||||
fontWeight: fonts.weights.semibold,
|
||||
textAlign: 'center',
|
||||
lineHeight: 28,
|
||||
},
|
||||
|
||||
// Orbit
|
||||
orbitArea: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
|
||||
// Bottom
|
||||
bottomSection: {
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
},
|
||||
dayRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'baseline',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
dayNumber: {
|
||||
color: colors.text,
|
||||
fontSize: fonts.sizes.xxl,
|
||||
fontWeight: fonts.weights.bold,
|
||||
},
|
||||
dayTotal: {
|
||||
color: colors.textMuted,
|
||||
fontSize: fonts.sizes.lg,
|
||||
fontWeight: fonts.weights.medium,
|
||||
},
|
||||
|
||||
// Progress
|
||||
progressBar: {
|
||||
width: '100%',
|
||||
height: 4,
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 2,
|
||||
marginBottom: spacing.lg,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressFill: {
|
||||
height: '100%',
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: 2,
|
||||
position: 'relative',
|
||||
},
|
||||
progressGlow: {
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: -2,
|
||||
width: 16,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: colors.primaryLight,
|
||||
shadowColor: colors.primary,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 1,
|
||||
shadowRadius: 6,
|
||||
elevation: 4,
|
||||
},
|
||||
|
||||
// Critical
|
||||
criticalBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(255, 82, 82, 0.1)',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: 20,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 82, 82, 0.3)',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
criticalDot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: colors.error,
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
criticalText: {
|
||||
color: colors.error,
|
||||
fontSize: fonts.sizes.xs,
|
||||
fontWeight: fonts.weights.bold,
|
||||
letterSpacing: 1,
|
||||
},
|
||||
|
||||
// Button
|
||||
startButton: {
|
||||
width: '100%',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
View, Text, TextInput, StyleSheet,
|
||||
KeyboardAvoidingView, Platform, Animated, ScrollView,
|
||||
} from 'react-native';
|
||||
import { showAlert } from '../components/NovaAlert';
|
||||
import ScreenWrapper from '../components/ScreenWrapper';
|
||||
import Button from '../components/Button';
|
||||
import { generateFromStory } from '../services/aiService';
|
||||
import { colors, fonts, spacing, borderRadius } from '../utils/theme';
|
||||
|
||||
const MIN_CHARS = 30;
|
||||
const MAX_CHARS = 1000;
|
||||
|
||||
const PROMPTS = [
|
||||
'"I want to stop procrastinating and start showing up for myself every day..."',
|
||||
'"I used to be fit and confident, but I lost myself. I want that person back..."',
|
||||
'"I dream of being someone who reads, learns, and never stops growing..."',
|
||||
'"I\'m tired of being anxious. I want to be calm, focused, and in control..."',
|
||||
];
|
||||
|
||||
export default function IdentityStoryScreen({ navigation }) {
|
||||
const [story, setStory] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [promptIndex] = useState(() => Math.floor(Math.random() * PROMPTS.length));
|
||||
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||
const slideAnim = useRef(new Animated.Value(20)).current;
|
||||
|
||||
useEffect(() => {
|
||||
Animated.parallel([
|
||||
Animated.timing(fadeAnim, { toValue: 1, duration: 600, useNativeDriver: true }),
|
||||
Animated.timing(slideAnim, { toValue: 0, duration: 600, useNativeDriver: true }),
|
||||
]).start();
|
||||
}, []);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
const trimmed = story.trim();
|
||||
if (trimmed.length < MIN_CHARS) {
|
||||
showAlert('Tell us more', `Please write at least ${MIN_CHARS} characters about yourself.`);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await generateFromStory(trimmed);
|
||||
if (result.source === 'fallback') {
|
||||
showAlert(
|
||||
'AI Unavailable',
|
||||
'We generated suggestions based on keywords instead. You can edit everything on the next screen.',
|
||||
[{ text: 'Continue', onPress: () => navigation.navigate('HabitSelection', { aiResult: result, story: trimmed }) }]
|
||||
);
|
||||
} else {
|
||||
navigation.navigate('HabitSelection', { aiResult: result, story: trimmed });
|
||||
}
|
||||
} catch (e) {
|
||||
showAlert('Error', e.message || 'Something went wrong. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
navigation.navigate('CreateIdentity');
|
||||
};
|
||||
|
||||
const charCount = story.trim().length;
|
||||
const isValid = charCount >= MIN_CHARS;
|
||||
|
||||
return (
|
||||
<ScreenWrapper>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
style={styles.flex}
|
||||
>
|
||||
<ScrollView
|
||||
style={styles.flex}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<Animated.View style={{ opacity: fadeAnim, transform: [{ translateY: slideAnim }] }}>
|
||||
{/* Header */}
|
||||
<Text style={styles.title}>Your Story</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Tell us about yourself — who you are now, who you want to become, and why this matters to you.
|
||||
</Text>
|
||||
|
||||
{/* AI badge */}
|
||||
<View style={styles.aiBadge}>
|
||||
<Text style={styles.aiBadgeIcon}>✦</Text>
|
||||
<Text style={styles.aiBadgeText}>AI will craft your identity and habits</Text>
|
||||
</View>
|
||||
|
||||
{/* Story input */}
|
||||
<View style={styles.inputContainer}>
|
||||
<TextInput
|
||||
style={styles.storyInput}
|
||||
value={story}
|
||||
onChangeText={(t) => setStory(t.slice(0, MAX_CHARS))}
|
||||
placeholder={PROMPTS[promptIndex]}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
selectionColor={colors.primary}
|
||||
/>
|
||||
<Text style={[styles.charCount, isValid && styles.charCountValid]}>
|
||||
{charCount} / {MIN_CHARS}+
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Tips */}
|
||||
<View style={styles.tipsCard}>
|
||||
<Text style={styles.tipsTitle}>What to write about</Text>
|
||||
<Text style={styles.tipItem}>• What do you struggle with right now?</Text>
|
||||
<Text style={styles.tipItem}>• What kind of person do you want to become?</Text>
|
||||
<Text style={styles.tipItem}>• What would change if you transformed?</Text>
|
||||
</View>
|
||||
|
||||
{/* Actions */}
|
||||
<Button
|
||||
title={loading ? 'Generating...' : 'Generate My Path'}
|
||||
onPress={handleGenerate}
|
||||
loading={loading}
|
||||
disabled={!isValid}
|
||||
style={styles.generateBtn}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="Skip — I'll create manually"
|
||||
onPress={handleSkip}
|
||||
variant="secondary"
|
||||
disabled={loading}
|
||||
style={styles.skipBtn}
|
||||
/>
|
||||
</Animated.View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</ScreenWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flex: { flex: 1 },
|
||||
scrollContent: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.xxl,
|
||||
paddingBottom: spacing.xxl,
|
||||
},
|
||||
title: {
|
||||
color: colors.text,
|
||||
fontSize: fonts.sizes.xxl,
|
||||
fontWeight: fonts.weights.bold,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
subtitle: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: fonts.sizes.md,
|
||||
lineHeight: 22,
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
aiBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(108, 99, 255, 0.08)',
|
||||
borderRadius: borderRadius.full,
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
alignSelf: 'flex-start',
|
||||
marginBottom: spacing.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(108, 99, 255, 0.2)',
|
||||
},
|
||||
aiBadgeIcon: {
|
||||
color: colors.primary,
|
||||
fontSize: 14,
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
aiBadgeText: {
|
||||
color: colors.primaryLight,
|
||||
fontSize: fonts.sizes.xs,
|
||||
fontWeight: fonts.weights.medium,
|
||||
},
|
||||
inputContainer: {
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
storyInput: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingTop: spacing.md,
|
||||
paddingBottom: spacing.xl,
|
||||
color: colors.text,
|
||||
fontSize: fonts.sizes.md,
|
||||
minHeight: 160,
|
||||
lineHeight: 24,
|
||||
},
|
||||
charCount: {
|
||||
position: 'absolute',
|
||||
bottom: spacing.sm,
|
||||
right: spacing.md,
|
||||
color: colors.textMuted,
|
||||
fontSize: fonts.sizes.xs,
|
||||
},
|
||||
charCountValid: {
|
||||
color: colors.success,
|
||||
},
|
||||
tipsCard: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: borderRadius.md,
|
||||
padding: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
tipsTitle: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: fonts.sizes.sm,
|
||||
fontWeight: fonts.weights.semibold,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
tipItem: {
|
||||
color: colors.textMuted,
|
||||
fontSize: fonts.sizes.sm,
|
||||
lineHeight: 22,
|
||||
},
|
||||
generateBtn: {
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
skipBtn: {},
|
||||
});
|
||||
@@ -0,0 +1,298 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, Animated, Platform,
|
||||
} from 'react-native';
|
||||
import { showAlert } from '../components/NovaAlert';
|
||||
import ViewShot from 'react-native-view-shot';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import * as MediaLibrary from 'expo-media-library';
|
||||
import * as Print from 'expo-print';
|
||||
import ScreenWrapper from '../components/ScreenWrapper';
|
||||
import Button from '../components/Button';
|
||||
import { colors, fonts, spacing, borderRadius } from '../utils/theme';
|
||||
|
||||
export default function JournalResultScreen({ navigation, route }) {
|
||||
const {
|
||||
aiTitle, aiSummary, aiQuote,
|
||||
source, dayNumber, date, mood, identityTitle,
|
||||
} = route.params;
|
||||
|
||||
const viewShotRef = useRef(null);
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||
const cardScale = useRef(new Animated.Value(0.9)).current;
|
||||
|
||||
useEffect(() => {
|
||||
Animated.parallel([
|
||||
Animated.timing(fadeAnim, { toValue: 1, duration: 600, useNativeDriver: true }),
|
||||
Animated.spring(cardScale, { toValue: 1, useNativeDriver: true, speed: 10, bounciness: 8 }),
|
||||
]).start();
|
||||
}, []);
|
||||
|
||||
const moodEmojis = { great: '😄', good: '🙂', okay: '😐', bad: '😔', terrible: '😞' };
|
||||
|
||||
// --- Share image ---
|
||||
const handleShare = async () => {
|
||||
try {
|
||||
const uri = await viewShotRef.current.capture();
|
||||
await Sharing.shareAsync(uri, { mimeType: 'image/png' });
|
||||
} catch (e) {
|
||||
showAlert('Error', 'Could not share image.');
|
||||
}
|
||||
};
|
||||
|
||||
// --- Save to gallery ---
|
||||
const handleSaveImage = async () => {
|
||||
try {
|
||||
const { status } = await MediaLibrary.requestPermissionsAsync();
|
||||
if (status !== 'granted') {
|
||||
showAlert('Permission needed', 'Allow access to save images.');
|
||||
return;
|
||||
}
|
||||
const uri = await viewShotRef.current.capture();
|
||||
await MediaLibrary.saveToLibraryAsync(uri);
|
||||
showAlert('Saved', 'Journal card saved to your gallery.');
|
||||
} catch (e) {
|
||||
showAlert('Error', 'Could not save image.');
|
||||
}
|
||||
};
|
||||
|
||||
// --- Export PDF ---
|
||||
const handleExportPDF = async () => {
|
||||
try {
|
||||
const html = `
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { font-family: -apple-system, sans-serif; padding: 40px; background: #0A0E1A; color: #FFFFFF; }
|
||||
.header { text-align: center; color: #6C63FF; font-size: 14px; letter-spacing: 4px; margin-bottom: 30px; }
|
||||
.title { font-size: 28px; font-weight: bold; margin-bottom: 20px; text-align: center; }
|
||||
.summary { font-size: 16px; line-height: 1.8; color: #8A8FB5; margin-bottom: 30px; text-align: center; }
|
||||
.quote { font-size: 18px; font-style: italic; color: #00E5FF; text-align: center; margin-bottom: 30px; }
|
||||
.meta { text-align: center; color: #4A5078; font-size: 13px; }
|
||||
.divider { border-top: 1px solid #1E2548; margin: 20px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">NOVA40 — DAILY JOURNAL</div>
|
||||
<div class="title">${aiTitle}</div>
|
||||
<div class="summary">${aiSummary}</div>
|
||||
<div class="divider"></div>
|
||||
<div class="quote">"${aiQuote}"</div>
|
||||
<div class="divider"></div>
|
||||
<div class="meta">Day ${dayNumber} — ${date}</div>
|
||||
<div class="meta">${identityTitle || ''}</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const { uri } = await Print.printToFileAsync({ html });
|
||||
|
||||
if (Platform.OS === 'ios') {
|
||||
await Sharing.shareAsync(uri);
|
||||
} else {
|
||||
await Sharing.shareAsync(uri, { mimeType: 'application/pdf' });
|
||||
}
|
||||
} catch (e) {
|
||||
showAlert('Error', 'Could not generate PDF.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenWrapper>
|
||||
<Animated.ScrollView
|
||||
style={[styles.scroll, { opacity: fadeAnim }]}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Source badge */}
|
||||
<View style={styles.badge}>
|
||||
<Text style={styles.badgeText}>
|
||||
{source === 'ai' ? '✦ AI Reflection' : '✦ Daily Reflection'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Shareable card */}
|
||||
<Animated.View style={{ transform: [{ scale: cardScale }] }}>
|
||||
<ViewShot
|
||||
ref={viewShotRef}
|
||||
options={{ format: 'png', quality: 1 }}
|
||||
>
|
||||
<View style={styles.card}>
|
||||
{/* Card header */}
|
||||
<Text style={styles.cardBrand}>NOVA40</Text>
|
||||
|
||||
{/* Mood */}
|
||||
<Text style={styles.moodEmoji}>{moodEmojis[mood] || '✦'}</Text>
|
||||
|
||||
{/* Title */}
|
||||
<Text style={styles.cardTitle}>{aiTitle}</Text>
|
||||
|
||||
{/* Summary */}
|
||||
<Text style={styles.cardSummary}>{aiSummary}</Text>
|
||||
|
||||
{/* Divider */}
|
||||
<View style={styles.cardDivider} />
|
||||
|
||||
{/* Quote */}
|
||||
<Text style={styles.cardQuote}>"{aiQuote}"</Text>
|
||||
|
||||
{/* Footer */}
|
||||
<View style={styles.cardFooter}>
|
||||
<Text style={styles.cardDay}>Day {dayNumber}</Text>
|
||||
<Text style={styles.cardDate}>{date}</Text>
|
||||
</View>
|
||||
|
||||
{identityTitle && (
|
||||
<Text style={styles.cardIdentity}>{identityTitle}</Text>
|
||||
)}
|
||||
</View>
|
||||
</ViewShot>
|
||||
</Animated.View>
|
||||
|
||||
{/* Actions */}
|
||||
<View style={styles.actions}>
|
||||
<Button title="Share" onPress={handleShare} style={styles.actionBtn} />
|
||||
|
||||
<View style={styles.actionRow}>
|
||||
<Button
|
||||
title="Save Image"
|
||||
onPress={handleSaveImage}
|
||||
variant="secondary"
|
||||
style={styles.halfBtn}
|
||||
/>
|
||||
<Button
|
||||
title="Export PDF"
|
||||
onPress={handleExportPDF}
|
||||
variant="secondary"
|
||||
style={styles.halfBtn}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
title="Done"
|
||||
onPress={() => navigation.goBack()}
|
||||
variant="secondary"
|
||||
style={styles.actionBtn}
|
||||
/>
|
||||
</View>
|
||||
</Animated.ScrollView>
|
||||
</ScreenWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scroll: { flex: 1 },
|
||||
scrollContent: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.xl,
|
||||
paddingBottom: spacing.xxl * 2,
|
||||
alignItems: 'center',
|
||||
},
|
||||
|
||||
badge: {
|
||||
alignSelf: 'center',
|
||||
backgroundColor: 'rgba(108, 99, 255, 0.08)',
|
||||
borderRadius: borderRadius.full,
|
||||
paddingVertical: spacing.xs,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(108, 99, 255, 0.2)',
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
badgeText: {
|
||||
color: colors.primaryLight,
|
||||
fontSize: fonts.sizes.xs,
|
||||
fontWeight: fonts.weights.medium,
|
||||
},
|
||||
|
||||
// Card
|
||||
card: {
|
||||
backgroundColor: colors.background,
|
||||
borderRadius: borderRadius.xl,
|
||||
padding: spacing.xl,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(108, 99, 255, 0.25)',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
},
|
||||
cardBrand: {
|
||||
color: colors.primary,
|
||||
fontSize: fonts.sizes.xs,
|
||||
fontWeight: fonts.weights.bold,
|
||||
letterSpacing: 5,
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
moodEmoji: {
|
||||
fontSize: 36,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
cardTitle: {
|
||||
color: colors.text,
|
||||
fontSize: fonts.sizes.xl,
|
||||
fontWeight: fonts.weights.bold,
|
||||
textAlign: 'center',
|
||||
lineHeight: 30,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
cardSummary: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: fonts.sizes.md,
|
||||
textAlign: 'center',
|
||||
lineHeight: 24,
|
||||
marginBottom: spacing.lg,
|
||||
paddingHorizontal: spacing.sm,
|
||||
},
|
||||
cardDivider: {
|
||||
width: 40,
|
||||
height: 2,
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: 1,
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
cardQuote: {
|
||||
color: colors.accent,
|
||||
fontSize: fonts.sizes.md,
|
||||
fontStyle: 'italic',
|
||||
textAlign: 'center',
|
||||
lineHeight: 24,
|
||||
marginBottom: spacing.lg,
|
||||
paddingHorizontal: spacing.sm,
|
||||
},
|
||||
cardFooter: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.md,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
cardDay: {
|
||||
color: colors.textMuted,
|
||||
fontSize: fonts.sizes.sm,
|
||||
fontWeight: fonts.weights.semibold,
|
||||
},
|
||||
cardDate: {
|
||||
color: colors.textMuted,
|
||||
fontSize: fonts.sizes.sm,
|
||||
},
|
||||
cardIdentity: {
|
||||
color: colors.textMuted,
|
||||
fontSize: fonts.sizes.xs,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
|
||||
// Actions
|
||||
actions: {
|
||||
width: '100%',
|
||||
marginTop: spacing.xl,
|
||||
},
|
||||
actionBtn: {
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
actionRow: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
halfBtn: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,325 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, TouchableOpacity, Pressable,
|
||||
KeyboardAvoidingView, Platform, Linking,
|
||||
} from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { showAlert } from '../components/NovaAlert';
|
||||
import ScreenWrapper from '../components/ScreenWrapper';
|
||||
import Input from '../components/Input';
|
||||
import Button from '../components/Button';
|
||||
import useAuthStore from '../store/useAuthStore';
|
||||
import { colors, fonts, spacing, borderRadius } from '../utils/theme';
|
||||
|
||||
const REMEMBER_KEY = 'remember_email';
|
||||
const DEMO_EMAIL = 'demo@nova40.app';
|
||||
const DEMO_PASSWORD = '123456';
|
||||
|
||||
export default function LoginScreen({ navigation }) {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [demoLoading, setDemoLoading] = useState(false);
|
||||
const [googleLoading, setGoogleLoading] = useState(false);
|
||||
|
||||
const login = useAuthStore((s) => s.login);
|
||||
const loginAsDemo = useAuthStore((s) => s.loginAsDemo);
|
||||
const loginWithGoogle = useAuthStore((s) => s.loginWithGoogle);
|
||||
|
||||
// Load remembered email on mount
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const saved = await AsyncStorage.getItem(REMEMBER_KEY);
|
||||
if (saved) {
|
||||
setEmail(saved);
|
||||
setRememberMe(true);
|
||||
} else {
|
||||
setEmail(DEMO_EMAIL);
|
||||
setPassword(DEMO_PASSWORD);
|
||||
}
|
||||
} catch (_) {
|
||||
setEmail(DEMO_EMAIL);
|
||||
setPassword(DEMO_PASSWORD);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const goToApp = () => {
|
||||
navigation.reset({ index: 0, routes: [{ name: 'MainTabs' }] });
|
||||
};
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!email.trim()) {
|
||||
showAlert('Validation Error', 'Please enter your email address.');
|
||||
return;
|
||||
}
|
||||
if (!password.trim()) {
|
||||
showAlert('Validation Error', 'Please enter your password.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Save or clear remembered email
|
||||
if (rememberMe) {
|
||||
await AsyncStorage.setItem(REMEMBER_KEY, email.trim());
|
||||
} else {
|
||||
await AsyncStorage.removeItem(REMEMBER_KEY);
|
||||
}
|
||||
|
||||
await login(email.trim(), password);
|
||||
goToApp();
|
||||
} catch (error) {
|
||||
showAlert('Login Failed', error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDemoLogin = async () => {
|
||||
setDemoLoading(true);
|
||||
try {
|
||||
await loginAsDemo();
|
||||
goToApp();
|
||||
} catch (error) {
|
||||
showAlert('Demo Login Failed', error.message);
|
||||
} finally {
|
||||
setDemoLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleLogin = async () => {
|
||||
setGoogleLoading(true);
|
||||
try {
|
||||
const data = await loginWithGoogle();
|
||||
// OAuth opens a browser — session will be picked up by onAuthStateChange
|
||||
if (data?.url) {
|
||||
await Linking.openURL(data.url);
|
||||
}
|
||||
} catch (error) {
|
||||
showAlert('Google Login Failed', error.message);
|
||||
} finally {
|
||||
setGoogleLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const anyLoading = loading || demoLoading || googleLoading;
|
||||
|
||||
return (
|
||||
<ScreenWrapper>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.container}
|
||||
>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Welcome Back</Text>
|
||||
<Text style={styles.subtitle}>Continue your transformation</Text>
|
||||
</View>
|
||||
|
||||
{/* Form */}
|
||||
<View style={styles.form}>
|
||||
<Input
|
||||
label="Email"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
placeholder="your@email.com"
|
||||
keyboardType="email-address"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Password"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
placeholder="Enter your password"
|
||||
secureTextEntry={!showPassword}
|
||||
rightIcon={showPassword ? '🙈' : '👁'}
|
||||
onRightIconPress={() => setShowPassword((prev) => !prev)}
|
||||
/>
|
||||
|
||||
{/* Remember Me */}
|
||||
<Pressable
|
||||
style={styles.rememberRow}
|
||||
onPress={() => setRememberMe((prev) => !prev)}
|
||||
>
|
||||
<View style={[styles.checkbox, rememberMe && styles.checkboxChecked]}>
|
||||
{rememberMe && <Text style={styles.checkmark}>✓</Text>}
|
||||
</View>
|
||||
<Text style={styles.rememberText}>Remember Me</Text>
|
||||
</Pressable>
|
||||
|
||||
{/* Sign In */}
|
||||
<Button
|
||||
title="Sign In"
|
||||
onPress={handleLogin}
|
||||
loading={loading}
|
||||
disabled={anyLoading && !loading}
|
||||
style={styles.loginBtn}
|
||||
/>
|
||||
|
||||
{/* Divider */}
|
||||
<View style={styles.divider}>
|
||||
<View style={styles.dividerLine} />
|
||||
<Text style={styles.dividerText}>or</Text>
|
||||
<View style={styles.dividerLine} />
|
||||
</View>
|
||||
|
||||
{/* Google Login */}
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.googleBtn, pressed && styles.googleBtnPressed]}
|
||||
onPress={handleGoogleLogin}
|
||||
disabled={anyLoading}
|
||||
>
|
||||
<Text style={styles.googleIcon}>G</Text>
|
||||
<Text style={styles.googleText}>Continue with Google</Text>
|
||||
</Pressable>
|
||||
|
||||
{/* Demo Login */}
|
||||
<Button
|
||||
title="Login as Demo User"
|
||||
onPress={handleDemoLogin}
|
||||
loading={demoLoading}
|
||||
disabled={anyLoading && !demoLoading}
|
||||
variant="secondary"
|
||||
style={styles.demoBtn}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Footer */}
|
||||
<View style={styles.footer}>
|
||||
<Text style={styles.footerText}>Don't have an account? </Text>
|
||||
<TouchableOpacity onPress={() => navigation.navigate('Register')}>
|
||||
<Text style={styles.footerLink}>Create one</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</ScreenWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: spacing.lg,
|
||||
},
|
||||
header: {
|
||||
marginBottom: spacing.xxl,
|
||||
},
|
||||
title: {
|
||||
color: colors.text,
|
||||
fontSize: fonts.sizes.xxl,
|
||||
fontWeight: fonts.weights.bold,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
subtitle: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: fonts.sizes.md,
|
||||
},
|
||||
form: {
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
|
||||
// Remember Me
|
||||
rememberRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.md,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
checkbox: {
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 6,
|
||||
borderWidth: 1.5,
|
||||
borderColor: colors.textMuted,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
checkboxChecked: {
|
||||
borderColor: colors.primary,
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
checkmark: {
|
||||
color: colors.text,
|
||||
fontSize: 13,
|
||||
fontWeight: fonts.weights.bold,
|
||||
},
|
||||
rememberText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: fonts.sizes.sm,
|
||||
},
|
||||
|
||||
// Buttons
|
||||
loginBtn: {
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
|
||||
// Divider
|
||||
divider: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginVertical: spacing.lg,
|
||||
},
|
||||
dividerLine: {
|
||||
flex: 1,
|
||||
height: 1,
|
||||
backgroundColor: colors.border,
|
||||
},
|
||||
dividerText: {
|
||||
color: colors.textMuted,
|
||||
fontSize: fonts.sizes.sm,
|
||||
marginHorizontal: spacing.md,
|
||||
},
|
||||
|
||||
// Google
|
||||
googleBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: borderRadius.lg,
|
||||
borderWidth: 1.5,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surface,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
googleBtnPressed: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
googleIcon: {
|
||||
fontSize: 20,
|
||||
fontWeight: fonts.weights.bold,
|
||||
color: '#4285F4',
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
googleText: {
|
||||
color: colors.text,
|
||||
fontSize: fonts.sizes.md,
|
||||
fontWeight: fonts.weights.medium,
|
||||
},
|
||||
|
||||
demoBtn: {
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
|
||||
// Footer
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
footerText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: fonts.sizes.sm,
|
||||
},
|
||||
footerLink: {
|
||||
color: colors.primary,
|
||||
fontSize: fonts.sizes.sm,
|
||||
fontWeight: fonts.weights.semibold,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, Animated, ScrollView } from 'react-native';
|
||||
import { showAlert } from '../components/NovaAlert';
|
||||
import ScreenWrapper from '../components/ScreenWrapper';
|
||||
import ReflexTap from '../components/games/ReflexTap';
|
||||
import FocusHold from '../components/games/FocusHold';
|
||||
import TemptationChoice from '../components/games/TemptationChoice';
|
||||
import TimingTap from '../components/games/TimingTap';
|
||||
import GameResult from '../components/games/GameResult';
|
||||
import useIdentityStore from '../store/useIdentityStore';
|
||||
import { saveGameSession } from '../services/gameService';
|
||||
import { colors, fonts, spacing, borderRadius } from '../utils/theme';
|
||||
|
||||
const GAMES = [
|
||||
{
|
||||
id: 'reflex_tap',
|
||||
name: 'Reflex Tap',
|
||||
icon: '⚡',
|
||||
desc: 'Train quick action',
|
||||
stat: 'Discipline',
|
||||
color: colors.accent,
|
||||
},
|
||||
{
|
||||
id: 'focus_hold',
|
||||
name: 'Focus Hold',
|
||||
icon: '🎯',
|
||||
desc: 'Train focus & stability',
|
||||
stat: 'Focus',
|
||||
color: colors.primary,
|
||||
},
|
||||
{
|
||||
id: 'temptation_choice',
|
||||
name: 'Temptation',
|
||||
icon: '🧠',
|
||||
desc: 'Real-life decisions',
|
||||
stat: 'Consistency',
|
||||
color: colors.success,
|
||||
},
|
||||
{
|
||||
id: 'timing_tap',
|
||||
name: 'Timing Tap',
|
||||
icon: '⏱',
|
||||
desc: 'Precision & awareness',
|
||||
stat: 'Focus',
|
||||
color: colors.warning,
|
||||
},
|
||||
];
|
||||
|
||||
// Phase: 'select' | 'playing' | 'result'
|
||||
export default function MiniGameScreen({ navigation, route }) {
|
||||
const currentDay = useIdentityStore((s) => s.currentDay);
|
||||
const identity = useIdentityStore((s) => s.identity);
|
||||
|
||||
const [phase, setPhase] = useState(route?.params?.gameType ? 'playing' : 'select');
|
||||
const [activeGame, setActiveGame] = useState(route?.params?.gameType || null);
|
||||
const [result, setResult] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const selectGame = (gameId) => {
|
||||
setActiveGame(gameId);
|
||||
setPhase('playing');
|
||||
setResult(null);
|
||||
};
|
||||
|
||||
const handleGameComplete = useCallback((score, details) => {
|
||||
setResult({ score, details });
|
||||
setPhase('result');
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!identity || !result) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await saveGameSession(identity.id, activeGame, result.score);
|
||||
navigation.goBack();
|
||||
} catch (e) {
|
||||
showAlert('Error', 'Failed to save score.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
setResult(null);
|
||||
setPhase('playing');
|
||||
};
|
||||
|
||||
// === SELECT PHASE ===
|
||||
if (phase === 'select') {
|
||||
return (
|
||||
<ScreenWrapper>
|
||||
<View style={styles.selectContainer}>
|
||||
<Text style={styles.selectTitle}>Mind Training</Text>
|
||||
<Text style={styles.selectSubtitle}>Choose your challenge</Text>
|
||||
|
||||
<ScrollView contentContainerStyle={styles.gameGrid} showsVerticalScrollIndicator={false}>
|
||||
{GAMES.map((game) => (
|
||||
<Pressable
|
||||
key={game.id}
|
||||
style={({ pressed }) => [styles.gameCard, pressed && styles.gameCardPressed]}
|
||||
onPress={() => selectGame(game.id)}
|
||||
>
|
||||
<Text style={styles.gameIcon}>{game.icon}</Text>
|
||||
<Text style={styles.gameName}>{game.name}</Text>
|
||||
<Text style={styles.gameDesc}>{game.desc}</Text>
|
||||
<View style={[styles.statBadge, { borderColor: game.color }]}>
|
||||
<Text style={[styles.statBadgeText, { color: game.color }]}>+{game.stat}</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</ScreenWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// === RESULT PHASE ===
|
||||
if (phase === 'result' && result) {
|
||||
return (
|
||||
<ScreenWrapper>
|
||||
<GameResult
|
||||
gameType={activeGame}
|
||||
score={result.score}
|
||||
details={result.details}
|
||||
onSave={handleSave}
|
||||
onRetry={handleRetry}
|
||||
saving={saving}
|
||||
/>
|
||||
</ScreenWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// === PLAYING PHASE ===
|
||||
const GameComponent = {
|
||||
reflex_tap: ReflexTap,
|
||||
focus_hold: FocusHold,
|
||||
temptation_choice: TemptationChoice,
|
||||
timing_tap: TimingTap,
|
||||
}[activeGame];
|
||||
|
||||
if (!GameComponent) {
|
||||
setPhase('select');
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ScreenWrapper>
|
||||
<View style={styles.playContainer}>
|
||||
<GameComponent
|
||||
currentDay={currentDay}
|
||||
onComplete={handleGameComplete}
|
||||
/>
|
||||
</View>
|
||||
</ScreenWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
// Select
|
||||
selectContainer: {
|
||||
flex: 1,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.xl,
|
||||
},
|
||||
selectTitle: {
|
||||
color: colors.text,
|
||||
fontSize: fonts.sizes.xxl,
|
||||
fontWeight: fonts.weights.bold,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
selectSubtitle: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: fonts.sizes.md,
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
gameGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.md,
|
||||
paddingBottom: spacing.xxl,
|
||||
},
|
||||
gameCard: {
|
||||
width: '47%',
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
alignItems: 'center',
|
||||
},
|
||||
gameCardPressed: {
|
||||
opacity: 0.7,
|
||||
transform: [{ scale: 0.97 }],
|
||||
},
|
||||
gameIcon: {
|
||||
fontSize: 32,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
gameName: {
|
||||
color: colors.text,
|
||||
fontSize: fonts.sizes.md,
|
||||
fontWeight: fonts.weights.semibold,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
gameDesc: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: fonts.sizes.xs,
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
statBadge: {
|
||||
borderWidth: 1,
|
||||
borderRadius: borderRadius.full,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
},
|
||||
statBadgeText: {
|
||||
fontSize: fonts.sizes.xs,
|
||||
fontWeight: fonts.weights.semibold,
|
||||
},
|
||||
|
||||
// Playing
|
||||
playContainer: {
|
||||
flex: 1,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.lg,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, Animated } from 'react-native';
|
||||
import ScreenWrapper from '../components/ScreenWrapper';
|
||||
import useIdentityStore from '../store/useIdentityStore';
|
||||
import { colors, fonts, spacing, borderRadius } from '../utils/theme';
|
||||
import { formatDate } from '../utils/helpers';
|
||||
|
||||
function DayCard({ label, day, log, color }) {
|
||||
return (
|
||||
<View style={[styles.dayCard, { borderColor: color }]}>
|
||||
<Text style={[styles.dayCardLabel, { color }]}>{label}</Text>
|
||||
<Text style={styles.dayCardDay}>Day {day}</Text>
|
||||
{log ? (
|
||||
<>
|
||||
<View style={styles.checkRow}>
|
||||
<Text style={styles.checkLabel}>Identity Check:</Text>
|
||||
<Text style={[styles.checkValue, {
|
||||
color: log.identity_check === 'yes' ? colors.success : log.identity_check === 'almost' ? colors.warning : colors.error,
|
||||
}]}>{log.identity_check.toUpperCase()}</Text>
|
||||
</View>
|
||||
{log.note ? (
|
||||
<View style={styles.noteBox}>
|
||||
<Text style={styles.noteLabel}>Reflection:</Text>
|
||||
<Text style={styles.noteText}>{log.note}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.noNote}>No reflection recorded</Text>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Text style={styles.noData}>No data logged for this day</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MirrorScreen() {
|
||||
const identity = useIdentityStore((s) => s.identity);
|
||||
const currentDay = useIdentityStore((s) => s.currentDay);
|
||||
const loadDailyLogs = useIdentityStore((s) => s.loadDailyLogs);
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const data = await loadDailyLogs();
|
||||
setLogs(data);
|
||||
setLoading(false);
|
||||
Animated.timing(fadeAnim, { toValue: 1, duration: 500, useNativeDriver: true }).start();
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const day1Log = logs.find((l) => l.day_number === 1);
|
||||
const todayLog = logs.find((l) => l.day_number === currentDay);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ScreenWrapper>
|
||||
<View style={styles.loadingContainer}><Text style={styles.loadingText}>Loading your mirror...</Text></View>
|
||||
</ScreenWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScreenWrapper>
|
||||
<Animated.ScrollView style={[styles.scroll, { opacity: fadeAnim }]} contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
|
||||
<Text style={styles.title}>The Mirror</Text>
|
||||
<Text style={styles.subtitle}>See how far you've come</Text>
|
||||
|
||||
{identity && (
|
||||
<View style={styles.identityCard}>
|
||||
<Text style={styles.identityLabel}>YOUR IDENTITY</Text>
|
||||
<Text style={styles.identityTitle}>{identity.title}</Text>
|
||||
<Text style={styles.identityDate}>Started {formatDate(identity.start_date)}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<DayCard label="WHERE YOU STARTED" day={1} log={day1Log} color={colors.textMuted} />
|
||||
<View style={styles.arrow}>
|
||||
<Text style={styles.arrowText}>↓</Text>
|
||||
<Text style={styles.arrowLabel}>{currentDay - 1} days of growth</Text>
|
||||
</View>
|
||||
<DayCard label="WHERE YOU ARE NOW" day={currentDay} log={todayLog} color={colors.accent} />
|
||||
|
||||
<View style={styles.insightCard}>
|
||||
<Text style={styles.insightTitle}>Journey Insights</Text>
|
||||
<View style={styles.insightRow}>
|
||||
<Text style={styles.insightLabel}>Days Completed</Text>
|
||||
<Text style={styles.insightValue}>{logs.length} / {currentDay}</Text>
|
||||
</View>
|
||||
<View style={styles.insightRow}>
|
||||
<Text style={styles.insightLabel}>Completion Rate</Text>
|
||||
<Text style={styles.insightValue}>{currentDay > 0 ? Math.round((logs.length / currentDay) * 100) : 0}%</Text>
|
||||
</View>
|
||||
<View style={styles.insightRow}>
|
||||
<Text style={styles.insightLabel}>Identity Alignment</Text>
|
||||
<Text style={styles.insightValue}>{logs.filter((l) => l.identity_check === 'yes').length} days fully aligned</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.ScrollView>
|
||||
</ScreenWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scroll: { flex: 1 },
|
||||
scrollContent: { padding: spacing.lg, paddingBottom: spacing.xxl },
|
||||
loadingContainer: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
||||
loadingText: { color: colors.textSecondary, fontSize: fonts.sizes.md },
|
||||
title: { color: colors.text, fontSize: fonts.sizes.xxl, fontWeight: fonts.weights.bold, marginBottom: spacing.xs },
|
||||
subtitle: { color: colors.textSecondary, fontSize: fonts.sizes.md, marginBottom: spacing.xl },
|
||||
identityCard: { backgroundColor: colors.surface, borderRadius: borderRadius.md, padding: spacing.lg, marginBottom: spacing.xl, borderWidth: 1, borderColor: colors.primary, alignItems: 'center' },
|
||||
identityLabel: { color: colors.primary, fontSize: fonts.sizes.xs, fontWeight: fonts.weights.bold, letterSpacing: 2, marginBottom: spacing.sm },
|
||||
identityTitle: { color: colors.text, fontSize: fonts.sizes.lg, fontWeight: fonts.weights.semibold, textAlign: 'center', marginBottom: spacing.xs },
|
||||
identityDate: { color: colors.textMuted, fontSize: fonts.sizes.sm },
|
||||
dayCard: { backgroundColor: colors.surface, borderRadius: borderRadius.md, padding: spacing.lg, borderWidth: 1 },
|
||||
dayCardLabel: { fontSize: fonts.sizes.xs, fontWeight: fonts.weights.bold, letterSpacing: 2, marginBottom: spacing.sm },
|
||||
dayCardDay: { color: colors.text, fontSize: fonts.sizes.lg, fontWeight: fonts.weights.bold, marginBottom: spacing.md },
|
||||
checkRow: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: spacing.sm },
|
||||
checkLabel: { color: colors.textSecondary, fontSize: fonts.sizes.sm },
|
||||
checkValue: { fontSize: fonts.sizes.sm, fontWeight: fonts.weights.bold },
|
||||
noteBox: { backgroundColor: colors.surfaceLight, borderRadius: borderRadius.sm, padding: spacing.md, marginTop: spacing.sm },
|
||||
noteLabel: { color: colors.textSecondary, fontSize: fonts.sizes.xs, marginBottom: spacing.xs },
|
||||
noteText: { color: colors.text, fontSize: fonts.sizes.sm, lineHeight: 20 },
|
||||
noNote: { color: colors.textMuted, fontSize: fonts.sizes.sm, fontStyle: 'italic' },
|
||||
noData: { color: colors.textMuted, fontSize: fonts.sizes.sm, fontStyle: 'italic' },
|
||||
arrow: { alignItems: 'center', paddingVertical: spacing.md },
|
||||
arrowText: { color: colors.primary, fontSize: 28 },
|
||||
arrowLabel: { color: colors.textSecondary, fontSize: fonts.sizes.sm, marginTop: spacing.xs },
|
||||
insightCard: { backgroundColor: colors.surface, borderRadius: borderRadius.md, padding: spacing.lg, marginTop: spacing.xl, borderWidth: 1, borderColor: colors.border },
|
||||
insightTitle: { color: colors.text, fontSize: fonts.sizes.md, fontWeight: fonts.weights.semibold, marginBottom: spacing.md },
|
||||
insightRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: spacing.sm, borderBottomWidth: 1, borderBottomColor: colors.border },
|
||||
insightLabel: { color: colors.textSecondary, fontSize: fonts.sizes.sm },
|
||||
insightValue: { color: colors.text, fontSize: fonts.sizes.sm, fontWeight: fonts.weights.medium },
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { View, Text, StyleSheet, Animated } from 'react-native';
|
||||
import { CommonActions } from '@react-navigation/native';
|
||||
import { showAlert } from '../components/NovaAlert';
|
||||
import ScreenWrapper from '../components/ScreenWrapper';
|
||||
import Button from '../components/Button';
|
||||
import useAuthStore from '../store/useAuthStore';
|
||||
import useIdentityStore from '../store/useIdentityStore';
|
||||
import useHabitStore from '../store/useHabitStore';
|
||||
import { colors, fonts, spacing, borderRadius } from '../utils/theme';
|
||||
import { formatDate } from '../utils/helpers';
|
||||
|
||||
export default function ProfileScreen({ navigation }) {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const identity = useIdentityStore((s) => s.identity);
|
||||
const currentDay = useIdentityStore((s) => s.currentDay);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const resetIdentity = useIdentityStore((s) => s.reset);
|
||||
const resetHabits = useHabitStore((s) => s.reset);
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||
|
||||
useEffect(() => {
|
||||
Animated.timing(fadeAnim, { toValue: 1, duration: 500, useNativeDriver: true }).start();
|
||||
}, []);
|
||||
|
||||
const handleSignOut = () => {
|
||||
showAlert('Sign Out', 'Are you sure you want to sign out?', [
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Sign Out',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
resetIdentity();
|
||||
resetHabits();
|
||||
await logout();
|
||||
navigation.dispatch(
|
||||
CommonActions.reset({ index: 0, routes: [{ name: 'Login' }] })
|
||||
);
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenWrapper>
|
||||
<Animated.View style={[styles.container, { opacity: fadeAnim }]}>
|
||||
<View style={styles.avatar}>
|
||||
<Text style={styles.avatarText}>{user?.email?.[0]?.toUpperCase() || 'N'}</Text>
|
||||
</View>
|
||||
<Text style={styles.email} numberOfLines={1} adjustsFontSizeToFit>{user?.email}</Text>
|
||||
|
||||
{identity && (
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardLabel}>ACTIVE IDENTITY</Text>
|
||||
<Text style={styles.cardTitle}>{identity.title}</Text>
|
||||
<Text style={styles.cardMeta}>Day {currentDay}/40 • Started {formatDate(identity.start_date)}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Button title="View Mirror" onPress={() => navigation.navigate('Mirror')} variant="secondary" style={styles.actionBtn} />
|
||||
<Button title="Play Focus Game" onPress={() => navigation.navigate('MiniGame')} variant="secondary" style={styles.actionBtn} />
|
||||
<Button title="Sign Out" onPress={handleSignOut} variant="secondary" style={[styles.actionBtn, styles.signOutBtn]} />
|
||||
</View>
|
||||
</Animated.View>
|
||||
</ScreenWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.xxl,
|
||||
},
|
||||
avatar: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: colors.primary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
alignSelf: 'center',
|
||||
marginBottom: spacing.md,
|
||||
shadowColor: colors.primary,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.5,
|
||||
shadowRadius: 15,
|
||||
elevation: 8,
|
||||
},
|
||||
avatarText: {
|
||||
color: colors.text,
|
||||
fontSize: fonts.sizes.xxl,
|
||||
fontWeight: fonts.weights.bold,
|
||||
},
|
||||
email: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: fonts.sizes.md,
|
||||
marginBottom: spacing.xl,
|
||||
textAlign: 'center',
|
||||
},
|
||||
card: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: borderRadius.md,
|
||||
padding: spacing.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
cardLabel: {
|
||||
color: colors.primary,
|
||||
fontSize: fonts.sizes.xs,
|
||||
fontWeight: fonts.weights.bold,
|
||||
letterSpacing: 2,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
cardTitle: {
|
||||
color: colors.text,
|
||||
fontSize: fonts.sizes.lg,
|
||||
fontWeight: fonts.weights.semibold,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
cardMeta: {
|
||||
color: colors.textMuted,
|
||||
fontSize: fonts.sizes.sm,
|
||||
},
|
||||
actions: {},
|
||||
actionBtn: {
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
signOutBtn: {
|
||||
borderColor: colors.error,
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, KeyboardAvoidingView, Platform } from 'react-native';
|
||||
import { showAlert } from '../components/NovaAlert';
|
||||
import ScreenWrapper from '../components/ScreenWrapper';
|
||||
import Input from '../components/Input';
|
||||
import Button from '../components/Button';
|
||||
import useAuthStore from '../store/useAuthStore';
|
||||
import { colors, fonts, spacing } from '../utils/theme';
|
||||
|
||||
export default function RegisterScreen({ navigation }) {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const register = useAuthStore((s) => s.register);
|
||||
const setSession = useAuthStore((s) => s.setSession);
|
||||
|
||||
const goToOnboarding = () => {
|
||||
navigation.reset({ index: 0, routes: [{ name: 'Onboarding' }] });
|
||||
};
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!email.trim() || !password.trim()) {
|
||||
showAlert('Error', 'Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
showAlert('Error', 'Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
showAlert('Error', 'Password must be at least 6 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await register(email.trim(), password);
|
||||
// If registration returned a session (offline mode or auto-confirm), log in directly
|
||||
if (data?.session) {
|
||||
setSession(data.session);
|
||||
goToOnboarding();
|
||||
} else {
|
||||
showAlert(
|
||||
'Account Created',
|
||||
'Check your email to confirm your account, then sign in.',
|
||||
[{ text: 'OK', onPress: () => navigation.navigate('Login') }]
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
showAlert('Registration Failed', error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenWrapper>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.container}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Begin Your Journey</Text>
|
||||
<Text style={styles.subtitle}>Create your Nova40 identity</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.form}>
|
||||
<Input
|
||||
label="Email"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
placeholder="your@email.com"
|
||||
keyboardType="email-address"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Password"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
placeholder="At least 6 characters"
|
||||
secureTextEntry
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Confirm Password"
|
||||
value={confirmPassword}
|
||||
onChangeText={setConfirmPassword}
|
||||
placeholder="Repeat your password"
|
||||
secureTextEntry
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="Create Account"
|
||||
onPress={handleRegister}
|
||||
loading={loading}
|
||||
style={styles.button}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Text style={styles.footerText}>Already have an account? </Text>
|
||||
<TouchableOpacity onPress={() => navigation.goBack()}>
|
||||
<Text style={styles.footerLink}>Sign In</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</ScreenWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: spacing.lg,
|
||||
},
|
||||
header: {
|
||||
marginBottom: spacing.xxl,
|
||||
},
|
||||
title: {
|
||||
color: colors.text,
|
||||
fontSize: fonts.sizes.xxl,
|
||||
fontWeight: fonts.weights.bold,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
subtitle: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: fonts.sizes.md,
|
||||
},
|
||||
form: {
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
button: {
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
footerText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: fonts.sizes.sm,
|
||||
},
|
||||
footerLink: {
|
||||
color: colors.primary,
|
||||
fontSize: fonts.sizes.sm,
|
||||
fontWeight: fonts.weights.semibold,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { View, Text, Image, StyleSheet, Animated, Easing } from 'react-native';
|
||||
import StarField from '../components/StarField';
|
||||
import useAuthStore from '../store/useAuthStore';
|
||||
import useAppStore from '../store/useAppStore';
|
||||
import { colors, fonts } from '../utils/theme';
|
||||
|
||||
const MIN_SPLASH_MS = 5000;
|
||||
|
||||
export default function SplashScreen({ navigation }) {
|
||||
const logoScale = useRef(new Animated.Value(0.5)).current;
|
||||
const logoOpacity = useRef(new Animated.Value(1)).current;
|
||||
const textOpacity = useRef(new Animated.Value(1)).current;
|
||||
const glowScale = useRef(new Animated.Value(0.8)).current;
|
||||
|
||||
useEffect(() => {
|
||||
// Start animations
|
||||
Animated.parallel([
|
||||
Animated.timing(logoScale, {
|
||||
toValue: 1, duration: 2000, easing: Easing.out(Easing.cubic), useNativeDriver: true,
|
||||
}),
|
||||
Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(glowScale, { toValue: 1.2, duration: 1500, useNativeDriver: true }),
|
||||
Animated.timing(glowScale, { toValue: 0.9, duration: 1500, useNativeDriver: true }),
|
||||
])
|
||||
),
|
||||
]).start();
|
||||
|
||||
// Run logic in parallel with animation
|
||||
const resolve = async () => {
|
||||
try {
|
||||
const start = Date.now();
|
||||
|
||||
// Check onboarding + session in parallel
|
||||
await Promise.all([
|
||||
useAppStore.getState().initApp(),
|
||||
useAuthStore.getState().fetchSession(),
|
||||
]);
|
||||
|
||||
const onboardingDone = useAppStore.getState().onboardingDone;
|
||||
const session = useAuthStore.getState().session;
|
||||
|
||||
// Wait for minimum splash duration
|
||||
const elapsed = Date.now() - start;
|
||||
if (elapsed < MIN_SPLASH_MS) {
|
||||
await new Promise((r) => setTimeout(r, MIN_SPLASH_MS - elapsed));
|
||||
}
|
||||
|
||||
// Route decision
|
||||
if (!onboardingDone) {
|
||||
navigation.reset({ index: 0, routes: [{ name: 'Onboarding' }] });
|
||||
} else if (!session) {
|
||||
navigation.reset({ index: 0, routes: [{ name: 'Login' }] });
|
||||
} else {
|
||||
navigation.reset({ index: 0, routes: [{ name: 'MainTabs' }] });
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Splash resolve error:', e);
|
||||
// Fallback: go to Login
|
||||
navigation.reset({ index: 0, routes: [{ name: 'Login' }] });
|
||||
}
|
||||
};
|
||||
|
||||
resolve();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<StarField count={40} />
|
||||
<View style={styles.content}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.logoContainer,
|
||||
{ opacity: logoOpacity, transform: [{ scale: logoScale }] },
|
||||
]}
|
||||
>
|
||||
<Animated.View style={[styles.glow, { transform: [{ scale: glowScale }] }]} />
|
||||
<Image
|
||||
source={require('../../assets/icon.png')}
|
||||
style={styles.logo}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.Text style={[styles.title, { opacity: textOpacity }]}>
|
||||
NOVA40
|
||||
</Animated.Text>
|
||||
<Animated.Text style={[styles.subtitle, { opacity: textOpacity }]}>
|
||||
You are becoming someone new
|
||||
</Animated.Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.background },
|
||||
content: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 40 },
|
||||
logoContainer: { alignItems: 'center', justifyContent: 'center', marginBottom: 40 },
|
||||
glow: {
|
||||
width: 200, height: 200, borderRadius: 100,
|
||||
backgroundColor: colors.planetGlow, opacity: 0.4, position: 'absolute',
|
||||
},
|
||||
logo: { width: 140, height: 140 },
|
||||
title: {
|
||||
color: colors.text, fontSize: fonts.sizes.hero,
|
||||
fontWeight: fonts.weights.bold, letterSpacing: 8, marginBottom: 16,
|
||||
},
|
||||
subtitle: {
|
||||
color: colors.textSecondary, fontSize: fonts.sizes.md,
|
||||
textAlign: 'center', fontStyle: 'italic',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import { View, Text, StyleSheet, Animated } from 'react-native';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import ScreenWrapper from '../components/ScreenWrapper';
|
||||
import useIdentityStore from '../store/useIdentityStore';
|
||||
import { colors, fonts, spacing, borderRadius } from '../utils/theme';
|
||||
import { getDayPhase } from '../utils/helpers';
|
||||
|
||||
function StatCard({ label, value, maxValue, color }) {
|
||||
const percentage = maxValue > 0 ? Math.min((value / maxValue) * 100, 100) : 0;
|
||||
return (
|
||||
<View style={styles.statCard}>
|
||||
<View style={styles.statHeader}>
|
||||
<Text style={styles.statLabel}>{label}</Text>
|
||||
<Text style={[styles.statValue, { color }]}>{value}</Text>
|
||||
</View>
|
||||
<View style={styles.barBg}>
|
||||
<View style={[styles.barFill, { width: `${percentage}%`, backgroundColor: color }]} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StatsScreen() {
|
||||
const currentDay = useIdentityStore((s) => s.currentDay);
|
||||
const loadStats = useIdentityStore((s) => s.loadStats);
|
||||
const loadDailyLogs = useIdentityStore((s) => s.loadDailyLogs);
|
||||
const [stats, setStats] = useState(null);
|
||||
const [dailyLogs, setDailyLogs] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
let active = true;
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
const [s, logs] = await Promise.all([loadStats(), loadDailyLogs()]);
|
||||
if (active) {
|
||||
setStats(s);
|
||||
setDailyLogs(logs);
|
||||
setLoading(false);
|
||||
Animated.timing(fadeAnim, { toValue: 1, duration: 500, useNativeDriver: true }).start();
|
||||
}
|
||||
};
|
||||
load();
|
||||
return () => { active = false; };
|
||||
}, [])
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ScreenWrapper>
|
||||
<View style={styles.loadingContainer}>
|
||||
<Text style={styles.loadingText}>Loading stats...</Text>
|
||||
</View>
|
||||
</ScreenWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const maxScore = currentDay * 3;
|
||||
const yesCount = dailyLogs.filter((l) => l.identity_check === 'yes').length;
|
||||
const almostCount = dailyLogs.filter((l) => l.identity_check === 'almost').length;
|
||||
const noCount = dailyLogs.filter((l) => l.identity_check === 'no').length;
|
||||
const totalScore = (stats?.discipline_score || 0) + (stats?.focus_score || 0) + (stats?.consistency_score || 0);
|
||||
|
||||
const sorted = [...dailyLogs].sort((a, b) => b.day_number - a.day_number);
|
||||
let streakDays = 0;
|
||||
for (const log of sorted) {
|
||||
if (log.identity_check === 'yes' || log.identity_check === 'almost') streakDays++;
|
||||
else break;
|
||||
}
|
||||
|
||||
return (
|
||||
<ScreenWrapper>
|
||||
<Animated.ScrollView
|
||||
style={[styles.scroll, { opacity: fadeAnim }]}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<Text style={styles.title}>Your Journey</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
{getDayPhase(currentDay).toUpperCase()} PHASE — Day {currentDay}/40
|
||||
</Text>
|
||||
|
||||
<StatCard label="Discipline" value={stats?.discipline_score || 0} maxValue={maxScore} color={colors.primary} />
|
||||
<StatCard label="Focus" value={stats?.focus_score || 0} maxValue={maxScore} color={colors.accent} />
|
||||
<StatCard label="Consistency" value={stats?.consistency_score || 0} maxValue={currentDay * 2} color={colors.success} />
|
||||
|
||||
{/* Total */}
|
||||
<View style={styles.totalCard}>
|
||||
<Text style={styles.totalLabel}>Total Score</Text>
|
||||
<Text style={styles.totalValue}>{totalScore}</Text>
|
||||
</View>
|
||||
|
||||
{/* Identity Check Summary */}
|
||||
<View style={styles.summaryCard}>
|
||||
<Text style={styles.summaryTitle}>Identity Check Summary</Text>
|
||||
<View style={styles.summaryRow}>
|
||||
<View style={styles.summaryItem}>
|
||||
<Text style={[styles.summaryValue, { color: colors.success }]}>{yesCount}</Text>
|
||||
<Text style={styles.summaryLabel}>Yes</Text>
|
||||
</View>
|
||||
<View style={styles.summaryItem}>
|
||||
<Text style={[styles.summaryValue, { color: colors.warning }]}>{almostCount}</Text>
|
||||
<Text style={styles.summaryLabel}>Almost</Text>
|
||||
</View>
|
||||
<View style={styles.summaryItem}>
|
||||
<Text style={[styles.summaryValue, { color: colors.error }]}>{noCount}</Text>
|
||||
<Text style={styles.summaryLabel}>No</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Streak */}
|
||||
<View style={styles.streakCard}>
|
||||
<Text style={styles.streakNumber}>{streakDays}</Text>
|
||||
<Text style={styles.streakLabel}>Day Streak</Text>
|
||||
</View>
|
||||
</Animated.ScrollView>
|
||||
</ScreenWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scroll: { flex: 1 },
|
||||
scrollContent: { padding: spacing.lg, paddingBottom: spacing.xxl },
|
||||
loadingContainer: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
||||
loadingText: { color: colors.textSecondary, fontSize: fonts.sizes.md },
|
||||
title: { color: colors.text, fontSize: fonts.sizes.xxl, fontWeight: fonts.weights.bold, marginBottom: spacing.xs },
|
||||
subtitle: { color: colors.accent, fontSize: fonts.sizes.xs, fontWeight: fonts.weights.bold, letterSpacing: 2, marginBottom: spacing.xl },
|
||||
|
||||
statCard: { backgroundColor: colors.surface, borderRadius: borderRadius.md, padding: spacing.md, marginBottom: spacing.md, borderWidth: 1, borderColor: colors.border },
|
||||
statHeader: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: spacing.sm },
|
||||
statLabel: { color: colors.textSecondary, fontSize: fonts.sizes.md, fontWeight: fonts.weights.medium },
|
||||
statValue: { fontSize: fonts.sizes.lg, fontWeight: fonts.weights.bold },
|
||||
barBg: { height: 6, backgroundColor: colors.surfaceLight, borderRadius: 3, overflow: 'hidden' },
|
||||
barFill: { height: '100%', borderRadius: 3 },
|
||||
|
||||
totalCard: { backgroundColor: colors.surface, borderRadius: borderRadius.md, padding: spacing.lg, marginBottom: spacing.md, borderWidth: 1, borderColor: colors.accent, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
|
||||
totalLabel: { color: colors.textSecondary, fontSize: fonts.sizes.md, fontWeight: fonts.weights.medium },
|
||||
totalValue: { color: colors.accent, fontSize: fonts.sizes.xxl, fontWeight: fonts.weights.bold },
|
||||
|
||||
summaryCard: { backgroundColor: colors.surface, borderRadius: borderRadius.md, padding: spacing.lg, marginBottom: spacing.md, borderWidth: 1, borderColor: colors.border },
|
||||
summaryTitle: { color: colors.text, fontSize: fonts.sizes.md, fontWeight: fonts.weights.semibold, marginBottom: spacing.md },
|
||||
summaryRow: { flexDirection: 'row', justifyContent: 'space-around' },
|
||||
summaryItem: { alignItems: 'center' },
|
||||
summaryValue: { fontSize: fonts.sizes.xxl, fontWeight: fonts.weights.bold },
|
||||
summaryLabel: { color: colors.textSecondary, fontSize: fonts.sizes.sm, marginTop: spacing.xs },
|
||||
|
||||
streakCard: { backgroundColor: colors.surface, borderRadius: borderRadius.md, padding: spacing.xl, alignItems: 'center', borderWidth: 1, borderColor: colors.primary },
|
||||
streakNumber: { color: colors.primary, fontSize: fonts.sizes.hero, fontWeight: fonts.weights.bold },
|
||||
streakLabel: { color: colors.textSecondary, fontSize: fonts.sizes.md, marginTop: spacing.xs },
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
import safeParseAI from '../utils/safeParseAI';
|
||||
import { getSuggestionsForIdentity } from '../utils/helpers';
|
||||
import { GEMINI_API_KEY } from '../config/keys';
|
||||
|
||||
const GEMINI_URL =
|
||||
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent';
|
||||
|
||||
function buildPrompt(story) {
|
||||
return `You are a thoughtful and emotionally intelligent personal growth coach.
|
||||
|
||||
A user has written a personal story about their current life and who they want to become.
|
||||
|
||||
Your job is NOT just to generate habits.
|
||||
Your job is to deeply understand the person and reflect their intention back in a meaningful way.
|
||||
|
||||
---
|
||||
|
||||
INSTRUCTIONS:
|
||||
|
||||
1. Read the user's story carefully.
|
||||
2. Understand:
|
||||
- their struggles
|
||||
- their desires
|
||||
- their emotional tone
|
||||
|
||||
3. Then generate:
|
||||
|
||||
A. Identity Title
|
||||
- A short, powerful sentence
|
||||
- Feels personal and motivating
|
||||
- Example: "I am someone who shows up even when it's hard"
|
||||
|
||||
B. Identity Summary
|
||||
- 2-3 sentences
|
||||
- Reflect their story in a human tone
|
||||
- Make it feel like someone truly understands them
|
||||
- Avoid generic phrases
|
||||
|
||||
C. Suggested Habits (5-8 items)
|
||||
|
||||
Rules:
|
||||
- Very simple and actionable
|
||||
- Can be done daily
|
||||
- No complexity
|
||||
- Each habit max 1 sentence
|
||||
- Feels aligned with their story (not random)
|
||||
|
||||
IMPORTANT:
|
||||
- Habits should feel like "small proof of identity"
|
||||
- Not tasks, but expressions of who they want to become
|
||||
|
||||
D. Tone Style:
|
||||
- Warm
|
||||
- Supportive
|
||||
- Slightly motivational
|
||||
- Never robotic
|
||||
- Never overly formal
|
||||
- Avoid buzzwords like "optimize", "maximize"
|
||||
|
||||
---
|
||||
|
||||
OUTPUT FORMAT (JSON ONLY):
|
||||
Return ONLY valid JSON. No explanation, no markdown, no extra text.
|
||||
|
||||
{
|
||||
"identity_title": "",
|
||||
"identity_summary": "",
|
||||
"suggested_habits": [
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
""
|
||||
]
|
||||
}
|
||||
|
||||
---
|
||||
|
||||
EXAMPLE:
|
||||
|
||||
User story: "I feel like I procrastinate a lot and I want to be more focused and disciplined."
|
||||
|
||||
Bad output: "Improve productivity with structured routines"
|
||||
Good output: "I am someone who takes action even when I don't feel ready"
|
||||
|
||||
Make the user feel understood, not analyzed.
|
||||
|
||||
---
|
||||
|
||||
User story:
|
||||
"""
|
||||
${story}
|
||||
"""`;
|
||||
}
|
||||
|
||||
const MAX_RETRIES = 2;
|
||||
const RETRY_DELAY_MS = 4000;
|
||||
|
||||
async function callGemini(story, attempt = 1) {
|
||||
const response = await fetch(`${GEMINI_URL}?key=${GEMINI_API_KEY}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contents: [{ parts: [{ text: buildPrompt(story) }] }],
|
||||
generationConfig: {
|
||||
temperature: 0.7,
|
||||
topP: 0.9,
|
||||
maxOutputTokens: 1024,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
// Rate limited — retry after delay
|
||||
if (response.status === 429 && attempt <= MAX_RETRIES) {
|
||||
console.warn(`Gemini rate limited, retrying in ${RETRY_DELAY_MS}ms (attempt ${attempt})...`);
|
||||
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
|
||||
return callGemini(story, attempt + 1);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errText = await response.text();
|
||||
console.warn('Gemini API error:', response.status, errText);
|
||||
throw new Error(`AI request failed (${response.status})`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate identity + habits from user's personal story using Gemini AI.
|
||||
* Retries on rate limit. Falls back to local generation if AI fails.
|
||||
*/
|
||||
export async function generateFromStory(story) {
|
||||
try {
|
||||
const json = await callGemini(story);
|
||||
const rawText = json?.candidates?.[0]?.content?.parts?.[0]?.text || '';
|
||||
|
||||
const parsed = safeParseAI(rawText);
|
||||
|
||||
if (
|
||||
parsed.identity_title &&
|
||||
parsed.identity_summary &&
|
||||
parsed.suggested_habits.length > 0
|
||||
) {
|
||||
return {
|
||||
...parsed,
|
||||
suggested_habits: parsed.suggested_habits.slice(0, 8),
|
||||
source: 'ai',
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('Invalid AI response');
|
||||
} catch (e) {
|
||||
console.warn('AI generation failed, using fallback:', e.message);
|
||||
return generateFallback(story);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Local fallback when AI is unavailable.
|
||||
*/
|
||||
function generateFallback(story) {
|
||||
const words = story.toLowerCase();
|
||||
|
||||
// Extract identity title from story patterns
|
||||
let title = 'A better version of myself';
|
||||
const patterns = [
|
||||
{ match: /want to be(come)?\s+(.+?)[\.\,\!\n]/i, group: 2 },
|
||||
{ match: /i want to\s+(.+?)[\.\,\!\n]/i, group: 1 },
|
||||
{ match: /dream of\s+(.+?)[\.\,\!\n]/i, group: 1 },
|
||||
{ match: /goal is to\s+(.+?)[\.\,\!\n]/i, group: 1 },
|
||||
{ match: /i wish i (was|were|could)\s+(.+?)[\.\,\!\n]/i, group: 2 },
|
||||
{ match: /i need to\s+(.+?)[\.\,\!\n]/i, group: 1 },
|
||||
{ match: /i('m| am) tired of\s+(.+?)[\.\,\!\n]/i, group: 2 },
|
||||
{ match: /i struggle with\s+(.+?)[\.\,\!\n]/i, group: 1 },
|
||||
];
|
||||
|
||||
for (const p of patterns) {
|
||||
const m = story.match(p.match);
|
||||
if (m && m[p.group]) {
|
||||
let extracted = m[p.group].trim();
|
||||
// Convert negative patterns to positive identity
|
||||
if (p.match.source.includes('tired of') || p.match.source.includes('struggle')) {
|
||||
extracted = 'overcoming ' + extracted;
|
||||
}
|
||||
title = extracted.charAt(0).toUpperCase() + extracted.slice(1);
|
||||
if (title.length > 50) title = title.slice(0, 50);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate varied habits from the story text
|
||||
let habits = getSuggestionsForIdentity(words);
|
||||
if (habits.length < 3) {
|
||||
// Also try with the extracted title
|
||||
const titleHabits = getSuggestionsForIdentity(title);
|
||||
titleHabits.forEach((h) => { if (!habits.includes(h)) habits.push(h); });
|
||||
}
|
||||
|
||||
return {
|
||||
identity_title: title,
|
||||
identity_summary: `Based on your story, this journey is about becoming ${title.toLowerCase()}. Every day is a step closer to the person you described.`,
|
||||
suggested_habits: habits.slice(0, 8),
|
||||
source: 'fallback',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { supabase } from './supabase';
|
||||
|
||||
// ============================================
|
||||
// OFFLINE-FIRST AUTH (dummy mode)
|
||||
// Uses AsyncStorage when Supabase is unreachable.
|
||||
// Switch USE_OFFLINE to false for real Supabase auth.
|
||||
// ============================================
|
||||
const USE_OFFLINE = true;
|
||||
const USERS_KEY = 'nova40_users';
|
||||
const SESSION_KEY = 'nova40_session';
|
||||
|
||||
// --- Offline helpers ---
|
||||
|
||||
function generateUUID() {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
function makeUser(email) {
|
||||
return {
|
||||
id: generateUUID(),
|
||||
email,
|
||||
email_confirmed_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeSession(user) {
|
||||
return {
|
||||
access_token: 'offline_' + user.id,
|
||||
user,
|
||||
};
|
||||
}
|
||||
|
||||
async function getStoredUsers() {
|
||||
const raw = await AsyncStorage.getItem(USERS_KEY);
|
||||
return raw ? JSON.parse(raw) : {};
|
||||
}
|
||||
|
||||
async function saveUsers(users) {
|
||||
await AsyncStorage.setItem(USERS_KEY, JSON.stringify(users));
|
||||
}
|
||||
|
||||
async function saveSession(session) {
|
||||
await AsyncStorage.setItem(SESSION_KEY, JSON.stringify(session));
|
||||
}
|
||||
|
||||
async function clearSession() {
|
||||
await AsyncStorage.removeItem(SESSION_KEY);
|
||||
}
|
||||
|
||||
// --- Public API ---
|
||||
|
||||
export async function signIn(email, password) {
|
||||
if (!USE_OFFLINE) {
|
||||
const { data, error } = await supabase.auth.signInWithPassword({ email, password });
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
const users = await getStoredUsers();
|
||||
const key = email.toLowerCase();
|
||||
const stored = users[key];
|
||||
if (!stored) throw new Error('No account found with this email. Please register first.');
|
||||
if (stored.password !== password) throw new Error('Incorrect password.');
|
||||
|
||||
// Migrate old non-UUID IDs to proper UUIDs
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
if (!uuidRegex.test(stored.id)) {
|
||||
stored.id = generateUUID();
|
||||
users[key] = stored;
|
||||
await saveUsers(users);
|
||||
}
|
||||
|
||||
const user = makeUser(email);
|
||||
user.id = stored.id;
|
||||
const session = makeSession(user);
|
||||
await saveSession(session);
|
||||
return { session, user };
|
||||
}
|
||||
|
||||
export async function signUp(email, password) {
|
||||
if (!USE_OFFLINE) {
|
||||
const { data, error } = await supabase.auth.signUp({ email, password });
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
const users = await getStoredUsers();
|
||||
const key = email.toLowerCase();
|
||||
if (users[key]) throw new Error('An account with this email already exists.');
|
||||
|
||||
const user = makeUser(email);
|
||||
users[key] = { id: user.id, password };
|
||||
await saveUsers(users);
|
||||
|
||||
// Auto-login (no email confirmation needed)
|
||||
const session = makeSession(user);
|
||||
await saveSession(session);
|
||||
return { session, user };
|
||||
}
|
||||
|
||||
export async function signOut() {
|
||||
if (!USE_OFFLINE) {
|
||||
const { error } = await supabase.auth.signOut();
|
||||
if (error) throw error;
|
||||
return;
|
||||
}
|
||||
await clearSession();
|
||||
}
|
||||
|
||||
export async function getSession() {
|
||||
if (!USE_OFFLINE) {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
return session;
|
||||
}
|
||||
|
||||
const raw = await AsyncStorage.getItem(SESSION_KEY);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
}
|
||||
|
||||
export async function signInAsDemo() {
|
||||
return signIn('demo@nova40.app', '123456').catch(async () => {
|
||||
// Auto-register demo user if not found
|
||||
const data = await signUp('demo@nova40.app', '123456');
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
export async function signInWithGoogle() {
|
||||
if (!USE_OFFLINE) {
|
||||
const { data, error } = await supabase.auth.signInWithOAuth({
|
||||
provider: 'google',
|
||||
options: { redirectTo: 'nova40://auth/callback' },
|
||||
});
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
throw new Error('Google login is not available in offline mode.');
|
||||
}
|
||||
|
||||
export function onAuthStateChange(callback) {
|
||||
if (!USE_OFFLINE) {
|
||||
return supabase.auth.onAuthStateChange(callback);
|
||||
}
|
||||
// Return a no-op subscription for offline mode
|
||||
return { data: { subscription: { unsubscribe: () => {} } } };
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { supabase } from './supabase';
|
||||
import * as offline from './offlineStorage';
|
||||
|
||||
const USE_OFFLINE = true;
|
||||
|
||||
// Score-to-stat mapping per game type
|
||||
const STAT_BONUSES = {
|
||||
reflex_tap: { discipline: 1.0, focus: 0.3, consistency: 0.2 },
|
||||
focus_hold: { discipline: 0.3, focus: 1.0, consistency: 0.3 },
|
||||
temptation_choice: { discipline: 0.4, focus: 0.2, consistency: 1.0 },
|
||||
timing_tap: { discipline: 0.5, focus: 0.8, consistency: 0.2 },
|
||||
};
|
||||
|
||||
export async function saveGameSession(identityId, gameType, score) {
|
||||
const bonuses = STAT_BONUSES[gameType] || { discipline: 0.5, focus: 0.5, consistency: 0.5 };
|
||||
|
||||
if (USE_OFFLINE) {
|
||||
await offline.insert('game_sessions', {
|
||||
identity_id: identityId,
|
||||
game_type: gameType,
|
||||
score,
|
||||
});
|
||||
|
||||
const stats = await offline.getOne('stats', { identity_id: identityId });
|
||||
if (stats) {
|
||||
await offline.update('stats', { id: stats.id }, {
|
||||
discipline_score: stats.discipline_score + Math.round(score * bonuses.discipline),
|
||||
focus_score: stats.focus_score + Math.round(score * bonuses.focus),
|
||||
consistency_score: stats.consistency_score + Math.round(score * bonuses.consistency),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await supabase.from('game_sessions').insert({
|
||||
identity_id: identityId,
|
||||
game_type: gameType,
|
||||
score,
|
||||
});
|
||||
if (error) throw error;
|
||||
|
||||
const { data: stats } = await supabase
|
||||
.from('stats')
|
||||
.select('*')
|
||||
.eq('identity_id', identityId)
|
||||
.single();
|
||||
|
||||
if (stats) {
|
||||
await supabase
|
||||
.from('stats')
|
||||
.update({
|
||||
discipline_score: stats.discipline_score + Math.round(score * bonuses.discipline),
|
||||
focus_score: stats.focus_score + Math.round(score * bonuses.focus),
|
||||
consistency_score: stats.consistency_score + Math.round(score * bonuses.consistency),
|
||||
})
|
||||
.eq('id', stats.id);
|
||||
}
|
||||
}
|
||||
|
||||
export function getResultMessage(gameType, score) {
|
||||
const messages = {
|
||||
reflex_tap: [
|
||||
{ min: 0, text: 'Slow start. Sharpen your reflexes.' },
|
||||
{ min: 5, text: 'Getting faster. Keep pushing.' },
|
||||
{ min: 10, text: 'Quick hands. Your discipline is showing.' },
|
||||
{ min: 15, text: 'Lightning reflexes. You act without hesitation.' },
|
||||
],
|
||||
focus_hold: [
|
||||
{ min: 0, text: 'Your focus wavers. Train your stillness.' },
|
||||
{ min: 2, text: 'Decent focus. Room to grow.' },
|
||||
{ min: 4, text: 'Strong focus. You hold your ground.' },
|
||||
{ min: 5, text: 'Unshakeable. Your mind is iron.' },
|
||||
],
|
||||
temptation_choice: [
|
||||
{ min: 0, text: 'You gave in. Next time, choose differently.' },
|
||||
{ min: 3, text: 'Some good choices. Stay aware.' },
|
||||
{ min: 4, text: 'Strong willpower. You chose your identity.' },
|
||||
{ min: 5, text: 'Perfect clarity. Temptation has no power over you.' },
|
||||
],
|
||||
timing_tap: [
|
||||
{ min: 0, text: 'Off-beat. Train your timing.' },
|
||||
{ min: 3, text: 'Getting in rhythm. Stay present.' },
|
||||
{ min: 6, text: 'Great timing. You know when to strike.' },
|
||||
{ min: 9, text: 'Perfect precision. You are in the zone.' },
|
||||
],
|
||||
};
|
||||
|
||||
const list = messages[gameType] || messages.reflex_tap;
|
||||
let result = list[0].text;
|
||||
for (const entry of list) {
|
||||
if (score >= entry.min) result = entry.text;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getStatLabel(gameType) {
|
||||
const labels = {
|
||||
reflex_tap: 'Discipline',
|
||||
focus_hold: 'Focus',
|
||||
temptation_choice: 'Consistency',
|
||||
timing_tap: 'Focus',
|
||||
};
|
||||
return labels[gameType] || 'Score';
|
||||
}
|
||||
|
||||
// Difficulty scaling based on current day
|
||||
export function getDifficulty(currentDay) {
|
||||
if (currentDay <= 10) return 1;
|
||||
if (currentDay <= 20) return 2;
|
||||
if (currentDay <= 30) return 3;
|
||||
return 4;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { supabase } from './supabase';
|
||||
import * as offline from './offlineStorage';
|
||||
import { todayISO } from '../utils/date';
|
||||
|
||||
// Must match authService.js
|
||||
const USE_OFFLINE = true;
|
||||
|
||||
export async function getHabits(identityId) {
|
||||
if (USE_OFFLINE) {
|
||||
return offline.getAll('habits', { identity_id: identityId });
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('habits')
|
||||
.select('*')
|
||||
.eq('identity_id', identityId);
|
||||
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
|
||||
export async function getTodayHabitLogs(habitIds) {
|
||||
if (!habitIds.length) return {};
|
||||
const today = todayISO();
|
||||
|
||||
if (USE_OFFLINE) {
|
||||
const allLogs = await offline.getAll('habit_logs');
|
||||
const logs = {};
|
||||
allLogs.forEach((log) => {
|
||||
if (habitIds.includes(log.habit_id) && log.date === today) {
|
||||
logs[log.habit_id] = log.completed;
|
||||
}
|
||||
});
|
||||
return logs;
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('habit_logs')
|
||||
.select('*')
|
||||
.in('habit_id', habitIds)
|
||||
.eq('date', today);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const logs = {};
|
||||
(data || []).forEach((log) => {
|
||||
logs[log.habit_id] = log.completed;
|
||||
});
|
||||
return logs;
|
||||
}
|
||||
|
||||
export async function saveHabitLog(habitId, completed) {
|
||||
const today = todayISO();
|
||||
|
||||
if (USE_OFFLINE) {
|
||||
await offline.upsert(
|
||||
'habit_logs',
|
||||
{ habit_id: habitId, date: today },
|
||||
{ completed }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: existing } = await supabase
|
||||
.from('habit_logs')
|
||||
.select('id')
|
||||
.eq('habit_id', habitId)
|
||||
.eq('date', today)
|
||||
.maybeSingle();
|
||||
|
||||
if (existing) {
|
||||
const { error } = await supabase
|
||||
.from('habit_logs')
|
||||
.update({ completed })
|
||||
.eq('id', existing.id);
|
||||
if (error) throw error;
|
||||
} else {
|
||||
const { error } = await supabase
|
||||
.from('habit_logs')
|
||||
.insert({ habit_id: habitId, date: today, completed });
|
||||
if (error) throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { supabase } from './supabase';
|
||||
import * as offline from './offlineStorage';
|
||||
import { generateHabitsFromIdentity } from '../utils/helpers';
|
||||
import { todayISO, addDays } from '../utils/date';
|
||||
|
||||
// Must match authService.js
|
||||
const USE_OFFLINE = true;
|
||||
|
||||
// ========================
|
||||
// GET IDENTITY
|
||||
// ========================
|
||||
export async function getIdentity(userId) {
|
||||
if (USE_OFFLINE) {
|
||||
return offline.getOne('identities', { user_id: userId });
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('identities')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
if (error && error.code !== 'PGRST116') throw error;
|
||||
return data || null;
|
||||
}
|
||||
|
||||
// ========================
|
||||
// CREATE IDENTITY
|
||||
// ========================
|
||||
export async function createIdentity(userId, title, description, customHabits, storyText) {
|
||||
const startDate = todayISO();
|
||||
const endDate = addDays(new Date(), 40);
|
||||
|
||||
if (USE_OFFLINE) {
|
||||
const identity = await offline.insert('identities', {
|
||||
user_id: userId,
|
||||
title,
|
||||
description: description || title,
|
||||
story_text: storyText || '',
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const habitsList = customHabits && customHabits.length > 0
|
||||
? customHabits
|
||||
: generateHabitsFromIdentity(title);
|
||||
|
||||
const habitsToInsert = habitsList.map((h) => ({
|
||||
identity_id: identity.id,
|
||||
title: h.title,
|
||||
description: h.description || '',
|
||||
}));
|
||||
|
||||
const habits = await offline.insertMany('habits', habitsToInsert);
|
||||
|
||||
await offline.insert('stats', {
|
||||
identity_id: identity.id,
|
||||
discipline_score: 0,
|
||||
focus_score: 0,
|
||||
consistency_score: 0,
|
||||
});
|
||||
|
||||
return { identity, habits };
|
||||
}
|
||||
|
||||
// --- Supabase path ---
|
||||
const { data, error } = await supabase
|
||||
.from('identities')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
title,
|
||||
description: description || title,
|
||||
story_text: storyText || '',
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
status: 'active',
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const habitsList = customHabits && customHabits.length > 0
|
||||
? customHabits
|
||||
: generateHabitsFromIdentity(title);
|
||||
|
||||
const habitsToInsert = habitsList.map((h) => ({
|
||||
identity_id: data.id,
|
||||
title: h.title,
|
||||
description: h.description || '',
|
||||
}));
|
||||
|
||||
const { data: habits, error: habitsError } = await supabase
|
||||
.from('habits')
|
||||
.insert(habitsToInsert)
|
||||
.select();
|
||||
|
||||
if (habitsError) throw habitsError;
|
||||
|
||||
await supabase.from('stats').insert({
|
||||
identity_id: data.id,
|
||||
discipline_score: 0,
|
||||
focus_score: 0,
|
||||
consistency_score: 0,
|
||||
});
|
||||
|
||||
return { identity: data, habits: habits || [] };
|
||||
}
|
||||
|
||||
// ========================
|
||||
// DAILY LOGS
|
||||
// ========================
|
||||
export async function getDailyLogs(identityId) {
|
||||
if (USE_OFFLINE) {
|
||||
const logs = await offline.getAll('daily_logs', { identity_id: identityId });
|
||||
return logs.sort((a, b) => a.day_number - b.day_number);
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('daily_logs')
|
||||
.select('*')
|
||||
.eq('identity_id', identityId)
|
||||
.order('day_number', { ascending: true });
|
||||
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
|
||||
export async function saveDailyLog(identityId, dayNumber, identityCheck, note) {
|
||||
const today = todayISO();
|
||||
|
||||
if (USE_OFFLINE) {
|
||||
await offline.upsert(
|
||||
'daily_logs',
|
||||
{ identity_id: identityId, date: today },
|
||||
{ day_number: dayNumber, identity_check: identityCheck, note: note || '' }
|
||||
);
|
||||
|
||||
// Update stats
|
||||
const scoreAdd = identityCheck === 'yes' ? 3 : identityCheck === 'almost' ? 1 : 0;
|
||||
if (scoreAdd > 0) {
|
||||
const stats = await offline.getOne('stats', { identity_id: identityId });
|
||||
if (stats) {
|
||||
await offline.update('stats', { id: stats.id }, {
|
||||
discipline_score: stats.discipline_score + scoreAdd,
|
||||
consistency_score: stats.consistency_score + (identityCheck === 'yes' ? 2 : 1),
|
||||
focus_score: stats.focus_score + scoreAdd,
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Supabase path ---
|
||||
const { data: existing } = await supabase
|
||||
.from('daily_logs')
|
||||
.select('id')
|
||||
.eq('identity_id', identityId)
|
||||
.eq('date', today)
|
||||
.maybeSingle();
|
||||
|
||||
if (existing) {
|
||||
const { error } = await supabase
|
||||
.from('daily_logs')
|
||||
.update({ identity_check: identityCheck, note: note || '' })
|
||||
.eq('id', existing.id);
|
||||
if (error) throw error;
|
||||
} else {
|
||||
const { error } = await supabase
|
||||
.from('daily_logs')
|
||||
.insert({
|
||||
identity_id: identityId,
|
||||
date: today,
|
||||
day_number: dayNumber,
|
||||
identity_check: identityCheck,
|
||||
note: note || '',
|
||||
});
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
const scoreAdd = identityCheck === 'yes' ? 3 : identityCheck === 'almost' ? 1 : 0;
|
||||
if (scoreAdd > 0) {
|
||||
const { data: stats } = await supabase
|
||||
.from('stats')
|
||||
.select('*')
|
||||
.eq('identity_id', identityId)
|
||||
.maybeSingle();
|
||||
|
||||
if (stats) {
|
||||
await supabase
|
||||
.from('stats')
|
||||
.update({
|
||||
discipline_score: stats.discipline_score + scoreAdd,
|
||||
consistency_score: stats.consistency_score + (identityCheck === 'yes' ? 2 : 1),
|
||||
focus_score: stats.focus_score + scoreAdd,
|
||||
})
|
||||
.eq('id', stats.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========================
|
||||
// STATS
|
||||
// ========================
|
||||
export async function getStats(identityId) {
|
||||
if (USE_OFFLINE) {
|
||||
return offline.getOne('stats', { identity_id: identityId });
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('stats')
|
||||
.select('*')
|
||||
.eq('identity_id', identityId)
|
||||
.single();
|
||||
|
||||
if (error && error.code !== 'PGRST116') throw error;
|
||||
return data || null;
|
||||
}
|
||||
|
||||
// ========================
|
||||
// GAME SESSIONS
|
||||
// ========================
|
||||
export async function saveGameScore(identityId, gameType, score) {
|
||||
if (USE_OFFLINE) {
|
||||
await offline.insert('game_sessions', {
|
||||
identity_id: identityId,
|
||||
game_type: gameType,
|
||||
score,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await supabase.from('game_sessions').insert({
|
||||
identity_id: identityId,
|
||||
game_type: gameType,
|
||||
score,
|
||||
});
|
||||
if (error) throw error;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import * as offline from './offlineStorage';
|
||||
import safeParseJournal from '../utils/safeParseJournal';
|
||||
import { GEMINI_API_KEY } from '../config/keys';
|
||||
import { todayISO } from '../utils/date';
|
||||
|
||||
const GEMINI_URL =
|
||||
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent';
|
||||
|
||||
function buildPrompt({ mood, win, struggle, highlight, note }) {
|
||||
return `You are a reflective and thoughtful journal companion.
|
||||
|
||||
A user wrote a daily journal.
|
||||
|
||||
Your job: Turn it into a meaningful reflection.
|
||||
|
||||
INPUT:
|
||||
- Mood: "${mood || 'not specified'}"
|
||||
- What went well: "${win || 'not specified'}"
|
||||
- What was difficult: "${struggle || 'not specified'}"
|
||||
- Highlight of the day: "${highlight || 'not specified'}"
|
||||
- Reflection: "${note || 'not specified'}"
|
||||
|
||||
OUTPUT:
|
||||
|
||||
1. title: Short, emotional, personal (max 8 words)
|
||||
2. summary: 2-3 sentences, natural, human, not generic. Reflect what they actually experienced.
|
||||
3. quote: 1 short powerful sentence, relatable, feels like it was written for them.
|
||||
|
||||
STYLE:
|
||||
- calm and reflective
|
||||
- real, not a motivational speech
|
||||
- avoid generic phrases like "keep going" or "believe in yourself"
|
||||
|
||||
Return ONLY valid JSON:
|
||||
{
|
||||
"title": "",
|
||||
"summary": "",
|
||||
"quote": ""
|
||||
}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate AI reflection from journal inputs.
|
||||
*/
|
||||
export async function generateDailyReflection(data) {
|
||||
try {
|
||||
const response = await fetch(`${GEMINI_URL}?key=${GEMINI_API_KEY}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contents: [{ parts: [{ text: buildPrompt(data) }] }],
|
||||
generationConfig: { temperature: 0.8, topP: 0.9, maxOutputTokens: 512 },
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.status === 429) {
|
||||
await new Promise((r) => setTimeout(r, 4000));
|
||||
const retry = await fetch(`${GEMINI_URL}?key=${GEMINI_API_KEY}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contents: [{ parts: [{ text: buildPrompt(data) }] }],
|
||||
generationConfig: { temperature: 0.8, topP: 0.9, maxOutputTokens: 512 },
|
||||
}),
|
||||
});
|
||||
if (!retry.ok) throw new Error('Rate limited');
|
||||
const json = await retry.json();
|
||||
return { ...safeParseJournal(json?.candidates?.[0]?.content?.parts?.[0]?.text), source: 'ai' };
|
||||
}
|
||||
|
||||
if (!response.ok) throw new Error('AI failed');
|
||||
|
||||
const json = await response.json();
|
||||
const rawText = json?.candidates?.[0]?.content?.parts?.[0]?.text || '';
|
||||
return { ...safeParseJournal(rawText), source: 'ai' };
|
||||
} catch (e) {
|
||||
console.warn('Journal AI failed, using fallback:', e.message);
|
||||
return { ...buildFallback(data), source: 'fallback' };
|
||||
}
|
||||
}
|
||||
|
||||
function buildFallback({ mood, win, struggle }) {
|
||||
const titles = {
|
||||
great: 'A Day That Lifted You',
|
||||
good: 'Quiet Progress',
|
||||
okay: 'Steady and Present',
|
||||
bad: 'Through the Hard Parts',
|
||||
terrible: 'Surviving Is Enough',
|
||||
};
|
||||
|
||||
return {
|
||||
title: titles[mood?.toLowerCase()] || 'Another Step Forward',
|
||||
summary: win
|
||||
? `Today you noticed something good: ${win.toLowerCase()}. ${struggle ? `Even though ${struggle.toLowerCase()}, you kept moving.` : 'That awareness is growth.'}`
|
||||
: 'You showed up today. Some days that is the whole victory.',
|
||||
quote: struggle
|
||||
? 'The hard days are proof that you are still trying.'
|
||||
: 'You are closer than yesterday.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save daily journal with all fields.
|
||||
*/
|
||||
export async function saveDailyJournal(identityId, dayNumber, data) {
|
||||
const today = todayISO();
|
||||
|
||||
const record = {
|
||||
identity_id: identityId,
|
||||
date: today,
|
||||
day_number: dayNumber,
|
||||
identity_check: data.identityCheck || 'almost',
|
||||
habits_completed: data.habitsCompleted || 0,
|
||||
mood: data.mood || '',
|
||||
win: data.win || '',
|
||||
struggle: data.struggle || '',
|
||||
highlight: data.highlight || '',
|
||||
note: data.note || '',
|
||||
ai_title: data.aiTitle || '',
|
||||
ai_summary: data.aiSummary || '',
|
||||
ai_quote: data.aiQuote || '',
|
||||
};
|
||||
|
||||
await offline.upsert(
|
||||
'daily_logs',
|
||||
{ identity_id: identityId, date: today },
|
||||
record
|
||||
);
|
||||
|
||||
// Update stats
|
||||
const scoreAdd = data.identityCheck === 'yes' ? 3 : data.identityCheck === 'almost' ? 1 : 0;
|
||||
if (scoreAdd > 0) {
|
||||
const stats = await offline.getOne('stats', { identity_id: identityId });
|
||||
if (stats) {
|
||||
await offline.update('stats', { id: stats.id }, {
|
||||
discipline_score: stats.discipline_score + scoreAdd,
|
||||
consistency_score: stats.consistency_score + (data.identityCheck === 'yes' ? 2 : 1),
|
||||
focus_score: stats.focus_score + scoreAdd,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get journal entry for a specific date.
|
||||
*/
|
||||
export async function getJournalEntry(identityId, date) {
|
||||
return offline.getOne('daily_logs', { identity_id: identityId, date: date || todayISO() });
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
const PREFIX = 'nova40_data_';
|
||||
|
||||
function key(collection, id) {
|
||||
return id ? `${PREFIX}${collection}_${id}` : `${PREFIX}${collection}`;
|
||||
}
|
||||
|
||||
function generateId() {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
// Get all items in a collection for a given filter
|
||||
async function getCollection(collection) {
|
||||
const raw = await AsyncStorage.getItem(key(collection));
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
}
|
||||
|
||||
async function saveCollection(collection, items) {
|
||||
await AsyncStorage.setItem(key(collection), JSON.stringify(items));
|
||||
}
|
||||
|
||||
// --- CRUD helpers ---
|
||||
|
||||
export async function getAll(collection, filter = {}) {
|
||||
const items = await getCollection(collection);
|
||||
return items.filter((item) => {
|
||||
for (const [k, v] of Object.entries(filter)) {
|
||||
if (item[k] !== v) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export async function getOne(collection, filter = {}) {
|
||||
const items = await getAll(collection, filter);
|
||||
return items[0] || null;
|
||||
}
|
||||
|
||||
export async function insert(collection, data) {
|
||||
const items = await getCollection(collection);
|
||||
const newItem = { id: generateId(), ...data, created_at: new Date().toISOString() };
|
||||
items.push(newItem);
|
||||
await saveCollection(collection, items);
|
||||
return newItem;
|
||||
}
|
||||
|
||||
export async function insertMany(collection, dataArray) {
|
||||
const items = await getCollection(collection);
|
||||
const newItems = dataArray.map((d) => ({
|
||||
id: generateId(),
|
||||
...d,
|
||||
created_at: new Date().toISOString(),
|
||||
}));
|
||||
items.push(...newItems);
|
||||
await saveCollection(collection, items);
|
||||
return newItems;
|
||||
}
|
||||
|
||||
export async function update(collection, filter, updates) {
|
||||
const items = await getCollection(collection);
|
||||
let updated = null;
|
||||
const newItems = items.map((item) => {
|
||||
const matches = Object.entries(filter).every(([k, v]) => item[k] === v);
|
||||
if (matches && !updated) {
|
||||
updated = { ...item, ...updates };
|
||||
return updated;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
await saveCollection(collection, newItems);
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function upsert(collection, filter, data) {
|
||||
const existing = await getOne(collection, filter);
|
||||
if (existing) {
|
||||
return update(collection, { id: existing.id }, data);
|
||||
}
|
||||
return insert(collection, { ...filter, ...data });
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import safeParseStory from '../utils/safeParseStory';
|
||||
import { GEMINI_API_KEY } from '../config/keys';
|
||||
|
||||
const GEMINI_URL =
|
||||
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent';
|
||||
|
||||
function buildPrompt(identity, habits, logs) {
|
||||
const yesCount = logs.filter((l) => l.identity_check === 'yes').length;
|
||||
const almostCount = logs.filter((l) => l.identity_check === 'almost').length;
|
||||
const noCount = logs.filter((l) => l.identity_check === 'no').length;
|
||||
const totalLogged = logs.length;
|
||||
|
||||
// Collect reflections (notes) from journal
|
||||
const reflections = logs
|
||||
.filter((l) => l.note && l.note.trim())
|
||||
.map((l) => `Day ${l.day_number}: "${l.note.trim()}"`)
|
||||
.slice(0, 15); // max 15 to keep prompt size reasonable
|
||||
|
||||
const habitNames = habits.map((h) => h.title).join(', ');
|
||||
|
||||
return `You are a reflective storyteller.
|
||||
|
||||
A user has completed a 40-day personal transformation journey.
|
||||
|
||||
Here is their data:
|
||||
|
||||
Identity: "${identity.title}"
|
||||
${identity.story_text ? `Original story: "${identity.story_text}"` : ''}
|
||||
${identity.description ? `Description: "${identity.description}"` : ''}
|
||||
Habits they practiced: ${habitNames}
|
||||
Days fully aligned: ${yesCount}/40
|
||||
Days almost aligned: ${almostCount}
|
||||
Days missed: ${noCount}
|
||||
Total days logged: ${totalLogged}
|
||||
|
||||
${reflections.length > 0 ? `Their journal entries:\n${reflections.join('\n')}` : 'No journal entries recorded.'}
|
||||
|
||||
---
|
||||
|
||||
Your job: Turn this into a meaningful and emotional story.
|
||||
|
||||
STRUCTURE:
|
||||
|
||||
1. Beginning — Who they were. What they wanted.
|
||||
2. Struggles — Challenges they faced. Moments they almost gave up.
|
||||
3. Breakthrough — When things started to change. Small wins.
|
||||
4. Transformation — Who they became. What changed.
|
||||
|
||||
STYLE:
|
||||
- Personal and human
|
||||
- Emotional but not dramatic
|
||||
- Simple language
|
||||
- Feels real, not a motivational speech
|
||||
- Written in third person or second person ("you")
|
||||
|
||||
OUTPUT FORMAT (JSON ONLY):
|
||||
Return ONLY valid JSON. No markdown, no explanation.
|
||||
|
||||
{
|
||||
"title": "short and powerful story title",
|
||||
"paragraphs": [
|
||||
"paragraph 1 - the beginning",
|
||||
"paragraph 2 - the struggles",
|
||||
"paragraph 3 - the breakthrough",
|
||||
"paragraph 4 - the transformation"
|
||||
],
|
||||
"closing_line": "a memorable final sentence"
|
||||
}
|
||||
|
||||
IMPORTANT: Make it feel like this story belongs to ONE person, not a template.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a transformation story using AI.
|
||||
* Falls back to local generation if AI fails.
|
||||
*/
|
||||
export async function generateTransformationStory(identity, habits, logs, stats) {
|
||||
try {
|
||||
const response = await fetch(`${GEMINI_URL}?key=${GEMINI_API_KEY}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contents: [{ parts: [{ text: buildPrompt(identity, habits, logs) }] }],
|
||||
generationConfig: {
|
||||
temperature: 0.8,
|
||||
topP: 0.9,
|
||||
maxOutputTokens: 2048,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.status === 429) {
|
||||
// Rate limited — wait and retry once
|
||||
await new Promise((r) => setTimeout(r, 4000));
|
||||
const retry = await fetch(`${GEMINI_URL}?key=${GEMINI_API_KEY}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contents: [{ parts: [{ text: buildPrompt(identity, habits, logs) }] }],
|
||||
generationConfig: { temperature: 0.8, topP: 0.9, maxOutputTokens: 2048 },
|
||||
}),
|
||||
});
|
||||
if (!retry.ok) throw new Error('AI rate limited');
|
||||
const retryJson = await retry.json();
|
||||
const rawText = retryJson?.candidates?.[0]?.content?.parts?.[0]?.text || '';
|
||||
return { ...safeParseStory(rawText, identity, logs, stats), source: 'ai' };
|
||||
}
|
||||
|
||||
if (!response.ok) throw new Error('AI request failed');
|
||||
|
||||
const json = await response.json();
|
||||
const rawText = json?.candidates?.[0]?.content?.parts?.[0]?.text || '';
|
||||
const parsed = safeParseStory(rawText, identity, logs, stats);
|
||||
return { ...parsed, source: 'ai' };
|
||||
} catch (e) {
|
||||
console.warn('Story AI failed, using fallback:', e.message);
|
||||
return { ...buildFallbackStory(identity, habits, logs, stats), source: 'fallback' };
|
||||
}
|
||||
}
|
||||
|
||||
function buildFallbackStory(identity, habits, logs, stats) {
|
||||
const yesCount = logs.filter((l) => l.identity_check === 'yes').length;
|
||||
const totalLogged = logs.length;
|
||||
const title = identity.title || 'A New Beginning';
|
||||
const habitNames = habits.slice(0, 3).map((h) => h.title).join(', ');
|
||||
|
||||
return {
|
||||
title: `The ${totalLogged}-Day Journey`,
|
||||
paragraphs: [
|
||||
`It started with a decision. You said: "${title}". That was the moment everything began to shift — not because the world changed, but because you did.`,
|
||||
`The first days were hard. Building habits like ${habitNames || 'showing up daily'} felt unnatural. There were moments when giving up seemed easier than continuing. But you kept going.`,
|
||||
`Somewhere around the middle, something clicked. The habits stopped feeling forced. You weren't just doing things — you were becoming someone. ${yesCount > 20 ? 'More often than not, you showed up fully aligned with who you wanted to be.' : 'Even on the hard days, you found a way to keep moving forward.'}`,
|
||||
`40 days later, you are not the same person who started this journey. You logged ${totalLogged} days. You showed up ${yesCount} times fully aligned. The numbers tell a story, but the real transformation happened inside.`,
|
||||
],
|
||||
closing_line: 'You didn\'t just change your habits. You changed who you are.',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'react-native-url-polyfill/auto';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const SUPABASE_URL = 'https://fvysqunfasbmzqjwxjew.supabase.co';
|
||||
const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImZ2eXNxdW5mYXNibXpxand4amV3Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzYxNDE2MDUsImV4cCI6MjA5MTcxNzYwNX0.0oHcbFz48YYT8aBSjEK2kkCqbRChWAN6dx7u-kLl-Jk';
|
||||
|
||||
export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
||||
auth: {
|
||||
storage: AsyncStorage,
|
||||
autoRefreshToken: true,
|
||||
persistSession: true,
|
||||
detectSessionInUrl: false,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { create } from 'zustand';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
const ONBOARDING_KEY = 'onboarding_done';
|
||||
|
||||
const useAppStore = create((set) => ({
|
||||
onboardingDone: false,
|
||||
initialIdentity: '',
|
||||
appLoading: true,
|
||||
|
||||
initApp: async () => {
|
||||
try {
|
||||
const value = await AsyncStorage.getItem(ONBOARDING_KEY);
|
||||
set({ onboardingDone: value === 'true', appLoading: false });
|
||||
} catch (_) {
|
||||
set({ onboardingDone: false, appLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
setOnboardingDone: async () => {
|
||||
await AsyncStorage.setItem(ONBOARDING_KEY, 'true');
|
||||
set({ onboardingDone: true });
|
||||
},
|
||||
|
||||
setInitialIdentity: (value) => set({ initialIdentity: value }),
|
||||
}));
|
||||
|
||||
export default useAppStore;
|
||||
@@ -0,0 +1,98 @@
|
||||
import { create } from 'zustand';
|
||||
import * as authService from '../services/authService';
|
||||
|
||||
const useAuthStore = create((set) => ({
|
||||
session: null,
|
||||
user: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
setSession: (session) =>
|
||||
set({
|
||||
session,
|
||||
user: session?.user ?? null,
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
|
||||
login: async (email, password) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const data = await authService.signIn(email, password);
|
||||
set({ session: data.session, user: data.user, loading: false });
|
||||
return data;
|
||||
} catch (e) {
|
||||
set({ loading: false, error: e.message });
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
loginAsDemo: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const data = await authService.signInAsDemo();
|
||||
set({ session: data.session, user: data.user, loading: false });
|
||||
return data;
|
||||
} catch (e) {
|
||||
set({ loading: false, error: e.message });
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
loginWithGoogle: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const data = await authService.signInWithGoogle();
|
||||
set({ loading: false });
|
||||
return data;
|
||||
} catch (e) {
|
||||
set({ loading: false, error: e.message });
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
register: async (email, password) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const data = await authService.signUp(email, password);
|
||||
// If signup returned a session (offline mode or auto-confirm), set it
|
||||
if (data?.session) {
|
||||
set({
|
||||
session: data.session,
|
||||
user: data.session?.user ?? data.user ?? null,
|
||||
loading: false,
|
||||
});
|
||||
} else {
|
||||
set({ loading: false });
|
||||
}
|
||||
return data;
|
||||
} catch (e) {
|
||||
set({ loading: false, error: e.message });
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
try {
|
||||
await authService.signOut();
|
||||
} catch (_) {
|
||||
// proceed even if signOut fails (e.g. network)
|
||||
}
|
||||
set({ session: null, user: null, error: null });
|
||||
},
|
||||
|
||||
fetchSession: async () => {
|
||||
try {
|
||||
const session = await authService.getSession();
|
||||
set({ session, user: session?.user ?? null });
|
||||
return session;
|
||||
} catch (_) {
|
||||
set({ session: null, user: null });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
clearError: () => set({ error: null }),
|
||||
}));
|
||||
|
||||
export default useAuthStore;
|
||||
@@ -0,0 +1,55 @@
|
||||
import { create } from 'zustand';
|
||||
import * as habitService from '../services/habitService';
|
||||
|
||||
const useHabitStore = create((set, get) => ({
|
||||
habits: [],
|
||||
habitLogs: {},
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
setHabits: (habits) => set({ habits }),
|
||||
|
||||
fetchHabits: async (identityId) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const habits = await habitService.getHabits(identityId);
|
||||
set({ habits, loading: false });
|
||||
} catch (e) {
|
||||
set({ loading: false, error: e.message });
|
||||
}
|
||||
},
|
||||
|
||||
loadTodayLogs: async () => {
|
||||
const { habits } = get();
|
||||
if (!habits.length) return {};
|
||||
|
||||
try {
|
||||
const logs = await habitService.getTodayHabitLogs(habits.map((h) => h.id));
|
||||
set({ habitLogs: logs });
|
||||
return logs;
|
||||
} catch (e) {
|
||||
set({ error: e.message });
|
||||
return {};
|
||||
}
|
||||
},
|
||||
|
||||
toggleHabit: async (habitId) => {
|
||||
const { habitLogs } = get();
|
||||
const newValue = !habitLogs[habitId];
|
||||
|
||||
// Optimistic update
|
||||
set({ habitLogs: { ...habitLogs, [habitId]: newValue } });
|
||||
|
||||
try {
|
||||
await habitService.saveHabitLog(habitId, newValue);
|
||||
} catch (e) {
|
||||
// Rollback
|
||||
set({ habitLogs: { ...get().habitLogs, [habitId]: !newValue }, error: e.message });
|
||||
}
|
||||
},
|
||||
|
||||
// Reset on logout
|
||||
reset: () => set({ habits: [], habitLogs: {}, loading: false, error: null }),
|
||||
}));
|
||||
|
||||
export default useHabitStore;
|
||||
@@ -0,0 +1,82 @@
|
||||
import { create } from 'zustand';
|
||||
import * as identityService from '../services/identityService';
|
||||
import { calculateCurrentDay } from '../utils/date';
|
||||
import useAuthStore from './useAuthStore';
|
||||
import useHabitStore from './useHabitStore';
|
||||
|
||||
const useIdentityStore = create((set, get) => ({
|
||||
identity: null,
|
||||
currentDay: 0,
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
fetchIdentity: async () => {
|
||||
const user = useAuthStore.getState().user;
|
||||
if (!user) return;
|
||||
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const data = await identityService.getIdentity(user.id);
|
||||
if (data) {
|
||||
const day = calculateCurrentDay(data.start_date);
|
||||
set({ identity: data, currentDay: day, loading: false });
|
||||
// Also load habits into habit store
|
||||
await useHabitStore.getState().fetchHabits(data.id);
|
||||
} else {
|
||||
set({ identity: null, currentDay: 0, loading: false });
|
||||
}
|
||||
} catch (e) {
|
||||
set({ loading: false, error: e.message });
|
||||
}
|
||||
},
|
||||
|
||||
createIdentity: async (title, description, customHabits, storyText) => {
|
||||
const user = useAuthStore.getState().user;
|
||||
if (!user) throw new Error('Not authenticated');
|
||||
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const { identity, habits } = await identityService.createIdentity(
|
||||
user.id, title, description, customHabits, storyText
|
||||
);
|
||||
set({ identity, currentDay: 1, loading: false });
|
||||
useHabitStore.getState().setHabits(habits);
|
||||
return identity;
|
||||
} catch (e) {
|
||||
set({ loading: false, error: e.message });
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
// Daily logs
|
||||
saveDailyLog: async (identityCheck, note) => {
|
||||
const { identity, currentDay } = get();
|
||||
if (!identity) return;
|
||||
await identityService.saveDailyLog(identity.id, currentDay, identityCheck, note);
|
||||
},
|
||||
|
||||
loadDailyLogs: async () => {
|
||||
const { identity } = get();
|
||||
if (!identity) return [];
|
||||
return identityService.getDailyLogs(identity.id);
|
||||
},
|
||||
|
||||
// Stats
|
||||
loadStats: async () => {
|
||||
const { identity } = get();
|
||||
if (!identity) return null;
|
||||
return identityService.getStats(identity.id);
|
||||
},
|
||||
|
||||
// Game
|
||||
saveGameScore: async (gameType, score) => {
|
||||
const { identity } = get();
|
||||
if (!identity) return;
|
||||
await identityService.saveGameScore(identity.id, gameType, score);
|
||||
},
|
||||
|
||||
// Reset on logout
|
||||
reset: () => set({ identity: null, currentDay: 0, loading: false, error: null }),
|
||||
}));
|
||||
|
||||
export default useIdentityStore;
|
||||
@@ -0,0 +1,19 @@
|
||||
export function todayISO() {
|
||||
return new Date().toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
export function calculateCurrentDay(startDate) {
|
||||
if (!startDate) return 0;
|
||||
const start = new Date(startDate);
|
||||
const today = new Date();
|
||||
start.setHours(0, 0, 0, 0);
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const diffDays = Math.floor((today - start) / (1000 * 60 * 60 * 24)) + 1;
|
||||
return Math.max(1, Math.min(diffDays, 40));
|
||||
}
|
||||
|
||||
export function addDays(date, days) {
|
||||
return new Date(date.getTime() + days * 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.split('T')[0];
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
export function calculateCurrentDay(startDate) {
|
||||
if (!startDate) return 0;
|
||||
const start = new Date(startDate);
|
||||
const today = new Date();
|
||||
start.setHours(0, 0, 0, 0);
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const diffTime = today - start;
|
||||
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)) + 1;
|
||||
return Math.max(1, Math.min(diffDays, 40));
|
||||
}
|
||||
|
||||
export function getDayPhase(day) {
|
||||
if (day <= 7) return 'awakening';
|
||||
if (day <= 14) return 'building';
|
||||
if (day <= 21) return 'testing';
|
||||
if (day <= 30) return 'strengthening';
|
||||
return 'transcending';
|
||||
}
|
||||
|
||||
export function getDayNarrative(day) {
|
||||
const phase = getDayPhase(day);
|
||||
const narratives = {
|
||||
awakening: [
|
||||
'The seed has been planted. Your old self watches curiously.',
|
||||
'Small cracks appear in old patterns. Keep going.',
|
||||
'Your identity is shifting beneath the surface.',
|
||||
'The resistance you feel is proof of change.',
|
||||
'Day by day, you become who you decided to be.',
|
||||
'Your habits are writing a new story.',
|
||||
'One week in. The foundation is forming.',
|
||||
],
|
||||
building: [
|
||||
'Momentum is building. Can you feel it?',
|
||||
'Your new self is taking shape.',
|
||||
'The gap between who you were and who you are is growing.',
|
||||
'Discipline is becoming your default.',
|
||||
'You are proving something to yourself.',
|
||||
'Two weeks. This is no longer a phase.',
|
||||
'Your identity is crystallizing.',
|
||||
],
|
||||
testing: [
|
||||
'This is where most people quit. You are not most people.',
|
||||
'The test is not the habit — it is who you become when it gets hard.',
|
||||
'Three weeks. Your neural pathways are rewiring.',
|
||||
'Comfort zone? You left that behind.',
|
||||
'Every day you show up, you vote for your new identity.',
|
||||
'The old you would have stopped by now.',
|
||||
'You are becoming undeniable.',
|
||||
],
|
||||
strengthening: [
|
||||
'Your transformation is visible now.',
|
||||
'What was hard is becoming natural.',
|
||||
'You do not do these things — you ARE this person.',
|
||||
'The orbit is nearly complete.',
|
||||
'Identity is not what you say. It is what you do repeatedly.',
|
||||
'You have proven you can change.',
|
||||
'The final stretch. Every day counts.',
|
||||
],
|
||||
transcending: [
|
||||
'The metamorphosis nears completion.',
|
||||
'You are not the same person who started this.',
|
||||
'Look back at Day 1. See how far you have come.',
|
||||
'These habits are now part of your DNA.',
|
||||
'The person you imagined? You are becoming them.',
|
||||
'Almost there. The universe is watching.',
|
||||
'Your new identity is forged.',
|
||||
'The final days. Make them count.',
|
||||
'Tomorrow, you complete your transformation.',
|
||||
'Day 40. You made it. You are Nova.',
|
||||
],
|
||||
};
|
||||
|
||||
const phaseNarratives = narratives[phase];
|
||||
const phaseStart =
|
||||
phase === 'awakening' ? 1
|
||||
: phase === 'building' ? 8
|
||||
: phase === 'testing' ? 15
|
||||
: phase === 'strengthening' ? 22
|
||||
: 31;
|
||||
const index = Math.min(day - phaseStart, phaseNarratives.length - 1);
|
||||
return phaseNarratives[index];
|
||||
}
|
||||
|
||||
export function isCriticalDay(day) {
|
||||
return [3, 10, 21, 30].includes(day);
|
||||
}
|
||||
|
||||
// Shuffle array (Fisher-Yates)
|
||||
function shuffle(arr) {
|
||||
const a = [...arr];
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
// Habit pool — each keyword maps to many habits, pick random subset
|
||||
const HABIT_POOL = {
|
||||
// Fitness / Body
|
||||
'fit,gym,exercise,workout,body,weight,muscle,run,jog,active': [
|
||||
{ title: 'Move your body 30 minutes', description: 'Walk, run, lift — just move' },
|
||||
{ title: 'Take 10,000 steps', description: 'Stay active throughout the day' },
|
||||
{ title: 'Do a morning stretch', description: '10 minutes to wake your body up' },
|
||||
{ title: 'No elevator — take stairs', description: 'Small movement adds up' },
|
||||
{ title: 'Try a new workout', description: 'Keep your body guessing' },
|
||||
{ title: 'Track your workout', description: 'Log sets, reps, or distance' },
|
||||
{ title: 'Cool down after exercise', description: '5 min stretch post-workout' },
|
||||
],
|
||||
// Health / Nutrition
|
||||
'health,eat,food,diet,nutrition,sugar,meal,cook,clean': [
|
||||
{ title: 'Eat one clean meal', description: 'Whole foods, no processed stuff' },
|
||||
{ title: 'Drink 2L of water', description: 'Hydrate before you caffeinate' },
|
||||
{ title: 'No junk food today', description: 'Choose fuel over comfort' },
|
||||
{ title: 'Eat a fruit or vegetable', description: 'Add color to your plate' },
|
||||
{ title: 'Cook one meal at home', description: 'Control what goes in your body' },
|
||||
{ title: 'Skip sugar after 6pm', description: 'Let your body rest clean' },
|
||||
{ title: 'Eat slowly — no screen', description: 'Be present with your meal' },
|
||||
],
|
||||
// Sleep / Rest
|
||||
'sleep,rest,tired,exhaust,energy,insomnia,bed': [
|
||||
{ title: 'Sleep by 11pm', description: 'Protect your rest window' },
|
||||
{ title: 'No screens 30min before bed', description: 'Let your brain wind down' },
|
||||
{ title: 'Sleep 7+ hours', description: 'Make rest non-negotiable' },
|
||||
{ title: 'Create a bedtime ritual', description: 'Tea, book, stretch — your call' },
|
||||
{ title: 'Wake at the same time daily', description: 'Consistency over alarm snoozing' },
|
||||
{ title: 'No caffeine after 2pm', description: 'Protect your sleep quality' },
|
||||
],
|
||||
// Reading / Learning
|
||||
'read,book,learn,study,knowledge,smart,curious,educat': [
|
||||
{ title: 'Read 15 pages', description: 'A small chapter changes everything' },
|
||||
{ title: 'Listen to a podcast', description: 'Learn while you commute' },
|
||||
{ title: 'Write one thing you learned', description: 'Retention through reflection' },
|
||||
{ title: 'Replace 30min scrolling with reading', description: 'Swap consumption types' },
|
||||
{ title: 'Teach someone what you learned', description: 'Teaching deepens understanding' },
|
||||
{ title: 'Read before bed instead of phone', description: 'End the day growing' },
|
||||
{ title: 'Take handwritten notes', description: 'Pen to paper locks it in' },
|
||||
],
|
||||
// Discipline / Routine
|
||||
'disciplin,routine,consist,habit,lazy,procrastinat,structure': [
|
||||
{ title: 'Wake up at the same time', description: 'Discipline starts with your alarm' },
|
||||
{ title: 'Make your bed immediately', description: 'First win of the day' },
|
||||
{ title: 'Follow a written schedule', description: 'Plan it, then do it' },
|
||||
{ title: 'Do the hardest task first', description: 'Eat the frog before noon' },
|
||||
{ title: 'No snooze button', description: 'Rise when the alarm rings' },
|
||||
{ title: 'End the day with a review', description: 'What went well? What to fix?' },
|
||||
{ title: 'Say no to one distraction', description: 'Guard your time like your life' },
|
||||
],
|
||||
// Focus / Productivity
|
||||
'focus,product,distract,attention,deep work,concentrat,procrast': [
|
||||
{ title: 'One 90-min deep work block', description: 'Phone off, door closed, create' },
|
||||
{ title: 'Use a Pomodoro timer', description: '25 on, 5 off — repeat' },
|
||||
{ title: 'Plan your top 3 tasks tonight', description: 'Wake up with clarity' },
|
||||
{ title: 'Clear your workspace', description: 'Clean space, clear mind' },
|
||||
{ title: 'Single-task for 1 hour', description: 'No tabs, no switching' },
|
||||
{ title: 'Delete one app that wastes time', description: 'Remove temptation at the source' },
|
||||
{ title: 'Review your week every Sunday', description: 'Reflect and recalibrate' },
|
||||
],
|
||||
// Confidence / Social
|
||||
'confiden,shy,social,anxi,fear,brave,courage,speak,voice': [
|
||||
{ title: 'Give one genuine compliment', description: 'Kindness builds confidence' },
|
||||
{ title: 'Speak up once in a group', description: 'Your voice matters' },
|
||||
{ title: 'Make eye contact with strangers', description: 'Presence over avoidance' },
|
||||
{ title: 'Write 3 wins from today', description: 'Train your brain to see strength' },
|
||||
{ title: 'Say no to something you don\'t want', description: 'Boundaries are self-respect' },
|
||||
{ title: 'Do one thing that scares you', description: 'Growth lives outside comfort' },
|
||||
{ title: 'Record a voice note to yourself', description: 'Hear your own conviction' },
|
||||
],
|
||||
// Calm / Mental health
|
||||
'calm,stress,anxious,peace,mental,meditat,mindful,relax,overwhelm,worry': [
|
||||
{ title: 'Meditate for 10 minutes', description: 'Sit still. Breathe. That\'s enough' },
|
||||
{ title: 'Write in a gratitude journal', description: '3 things you\'re thankful for' },
|
||||
{ title: 'Take a walk in nature', description: 'No phone, just presence' },
|
||||
{ title: 'Do a 5-minute breathing exercise', description: 'Inhale 4, hold 4, exhale 4' },
|
||||
{ title: 'Digital detox for 1 hour', description: 'Unplug and reconnect with yourself' },
|
||||
{ title: 'Stretch for 10 minutes', description: 'Release tension from your body' },
|
||||
{ title: 'Listen to calming music', description: 'Let sound reset your mood' },
|
||||
],
|
||||
// Creative / Art
|
||||
'creat,art,music,draw,paint,design,write,craft,imagin,innovat': [
|
||||
{ title: 'Create something for 20 minutes', description: 'Draw, write, build — anything' },
|
||||
{ title: 'Brainstorm 5 ideas', description: 'Quantity unlocks quality' },
|
||||
{ title: 'Practice your craft', description: 'Show up even without inspiration' },
|
||||
{ title: 'Consume inspiring work', description: 'Study someone you admire' },
|
||||
{ title: 'Share one thing you made', description: 'Put your work into the world' },
|
||||
{ title: 'Try a new creative technique', description: 'Experiment without pressure' },
|
||||
{ title: 'Freewrite for 10 minutes', description: 'No editing, just flow' },
|
||||
],
|
||||
// Finance / Career
|
||||
'money,financ,save,earn,career,business,invest,rich,wealth,job,work': [
|
||||
{ title: 'Track every expense today', description: 'Awareness is the first step' },
|
||||
{ title: 'Save before you spend', description: 'Pay yourself first' },
|
||||
{ title: 'Learn one new skill for your career', description: '15 minutes of growth' },
|
||||
{ title: 'Review your financial goals', description: 'Are you on track?' },
|
||||
{ title: 'Avoid one impulse purchase', description: 'Ask: do I need this or want this?' },
|
||||
{ title: 'Network — reach out to someone', description: 'Opportunities come through people' },
|
||||
{ title: 'Read about your industry', description: 'Stay sharp and informed' },
|
||||
],
|
||||
// Relationships
|
||||
'relationship,friend,family,love,connect,lonel,kind,empath,partner': [
|
||||
{ title: 'Call or text someone you care about', description: 'Connection takes 2 minutes' },
|
||||
{ title: 'Practice active listening', description: 'Listen to understand, not to reply' },
|
||||
{ title: 'Give an unexpected compliment', description: 'Brighten someone\'s day' },
|
||||
{ title: 'Be fully present in a conversation', description: 'Phone down, eyes up' },
|
||||
{ title: 'Write a thank-you message', description: 'Gratitude strengthens bonds' },
|
||||
{ title: 'Forgive one small thing', description: 'Carrying grudges weighs you down' },
|
||||
{ title: 'Plan quality time with someone', description: 'Make people a priority, not an afterthought' },
|
||||
],
|
||||
// Morning / Routine
|
||||
'morn,wake,alarm,sunrise,start,begin,early': [
|
||||
{ title: 'Wake before 6:30 AM', description: 'Own the morning, own the day' },
|
||||
{ title: 'No phone for first 30 minutes', description: 'Your mind deserves a slow start' },
|
||||
{ title: 'Drink water before coffee', description: 'Rehydrate first' },
|
||||
{ title: 'Move your body within 15 min of waking', description: 'Jumpstart your energy' },
|
||||
{ title: 'Set 3 intentions for the day', description: 'Know what matters before you start' },
|
||||
{ title: 'Eat a real breakfast', description: 'Fuel, not just caffeine' },
|
||||
],
|
||||
// Spiritual / Purpose
|
||||
'spirit,purpose,meaning,faith,pray,god,soul,grateful,thank': [
|
||||
{ title: 'Spend 10 minutes in stillness', description: 'Prayer, meditation, or silence' },
|
||||
{ title: 'Write what you\'re grateful for', description: 'Gratitude changes your lens' },
|
||||
{ title: 'Read something that feeds your soul', description: 'A verse, a poem, a passage' },
|
||||
{ title: 'Reflect on your purpose', description: 'Why are you here? What matters?' },
|
||||
{ title: 'Do one act of kindness', description: 'Give without expecting return' },
|
||||
{ title: 'Forgive yourself for something', description: 'Grace is a daily practice' },
|
||||
],
|
||||
};
|
||||
|
||||
// Universal habits that apply to anyone
|
||||
const UNIVERSAL_HABITS = [
|
||||
{ title: 'Journal for 5 minutes', description: 'Put your thoughts on paper' },
|
||||
{ title: 'Drink a full glass of water first thing', description: 'Start clean' },
|
||||
{ title: 'Take a 10-minute walk', description: 'Movement clears the mind' },
|
||||
{ title: 'Do one thing outside your comfort zone', description: 'Growth is on the other side' },
|
||||
{ title: 'Spend 5 minutes planning tomorrow', description: 'Never wake up without a plan' },
|
||||
{ title: 'Say something kind to yourself', description: 'You talk to yourself more than anyone' },
|
||||
{ title: 'Avoid complaining for 1 hour', description: 'Replace complaints with solutions' },
|
||||
{ title: 'Tidy one small area', description: 'Order outside creates order inside' },
|
||||
{ title: 'Do something you\'ve been avoiding', description: 'The relief is worth the effort' },
|
||||
{ title: 'Go to bed 15 minutes earlier', description: 'Small shifts, big impact' },
|
||||
];
|
||||
|
||||
export function generateHabitsFromIdentity(title) {
|
||||
const text = (title || '').toLowerCase();
|
||||
const matched = [];
|
||||
|
||||
// Collect all matching habits from the pool
|
||||
for (const [keys, habits] of Object.entries(HABIT_POOL)) {
|
||||
const keyList = keys.split(',');
|
||||
if (keyList.some((k) => text.includes(k.trim()))) {
|
||||
habits.forEach((h) => {
|
||||
if (!matched.some((m) => m.title === h.title)) matched.push(h);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Shuffle matched and pick 3-5
|
||||
if (matched.length > 0) {
|
||||
const picked = shuffle(matched).slice(0, 3 + Math.floor(Math.random() * 2));
|
||||
return picked;
|
||||
}
|
||||
|
||||
// No keyword match — return random universal habits
|
||||
return shuffle(UNIVERSAL_HABITS).slice(0, 3);
|
||||
}
|
||||
|
||||
export function getSuggestionsForIdentity(title) {
|
||||
if (!title || title.trim().length < 2) return [];
|
||||
const text = title.toLowerCase();
|
||||
const matched = [];
|
||||
|
||||
for (const [keys, habits] of Object.entries(HABIT_POOL)) {
|
||||
const keyList = keys.split(',');
|
||||
if (keyList.some((k) => text.includes(k.trim()))) {
|
||||
habits.forEach((h) => {
|
||||
if (!matched.includes(h.title)) matched.push(h.title);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Shuffle so same input doesn't always show same order
|
||||
const shuffled = shuffle(matched);
|
||||
|
||||
// If few matches, pad with universal habits
|
||||
if (shuffled.length < 5) {
|
||||
const universalTitles = shuffle(UNIVERSAL_HABITS).map((h) => h.title);
|
||||
universalTitles.forEach((t) => {
|
||||
if (!shuffled.includes(t)) shuffled.push(t);
|
||||
});
|
||||
}
|
||||
|
||||
return shuffled.slice(0, 8);
|
||||
}
|
||||
|
||||
export function formatDate(date) {
|
||||
return new Date(date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
const FALLBACK = {
|
||||
identity_title: 'A Better Version of Me',
|
||||
identity_summary: 'You are starting a journey to transform yourself, step by step. Every small action counts.',
|
||||
suggested_habits: [
|
||||
'Do one small positive action today',
|
||||
'Avoid one bad habit',
|
||||
'Reflect for 2 minutes before sleep',
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Safely parse AI JSON response.
|
||||
* Handles markdown wrapping, broken JSON, and missing fields.
|
||||
* Always returns a valid result — never throws.
|
||||
*
|
||||
* @param {string} raw - Raw text from AI response
|
||||
* @returns {{ identity_title: string, identity_summary: string, suggested_habits: string[] }}
|
||||
*/
|
||||
export default function safeParseAI(raw) {
|
||||
if (!raw || typeof raw !== 'string') return { ...FALLBACK };
|
||||
|
||||
try {
|
||||
// Strip markdown code fences
|
||||
let cleaned = raw.trim();
|
||||
cleaned = cleaned.replace(/```json\s*/gi, '').replace(/```\s*/gi, '');
|
||||
cleaned = cleaned.trim();
|
||||
|
||||
// Find first { and last } to extract JSON object
|
||||
const start = cleaned.indexOf('{');
|
||||
const end = cleaned.lastIndexOf('}');
|
||||
if (start === -1 || end === -1) return { ...FALLBACK };
|
||||
|
||||
cleaned = cleaned.slice(start, end + 1);
|
||||
const parsed = JSON.parse(cleaned);
|
||||
|
||||
// Validate required fields
|
||||
const title = typeof parsed.identity_title === 'string' && parsed.identity_title.trim()
|
||||
? parsed.identity_title.trim()
|
||||
: FALLBACK.identity_title;
|
||||
|
||||
const summary = typeof parsed.identity_summary === 'string' && parsed.identity_summary.trim()
|
||||
? parsed.identity_summary.trim()
|
||||
: FALLBACK.identity_summary;
|
||||
|
||||
let habits = FALLBACK.suggested_habits;
|
||||
if (Array.isArray(parsed.suggested_habits) && parsed.suggested_habits.length > 0) {
|
||||
habits = parsed.suggested_habits
|
||||
.filter((h) => typeof h === 'string' && h.trim())
|
||||
.map((h) => h.trim());
|
||||
}
|
||||
|
||||
if (habits.length === 0) habits = FALLBACK.suggested_habits;
|
||||
|
||||
return {
|
||||
identity_title: title,
|
||||
identity_summary: summary,
|
||||
suggested_habits: habits,
|
||||
};
|
||||
} catch (_) {
|
||||
return { ...FALLBACK };
|
||||
}
|
||||
}
|
||||
|
||||
export { FALLBACK };
|
||||
@@ -0,0 +1,30 @@
|
||||
const FALLBACK = {
|
||||
title: 'A Day in Your Journey',
|
||||
summary: 'Today you showed up. That alone matters more than you think.',
|
||||
quote: 'Small steps still move you forward.',
|
||||
};
|
||||
|
||||
export default function safeParseJournal(raw) {
|
||||
if (!raw || typeof raw !== 'string') return { ...FALLBACK };
|
||||
|
||||
try {
|
||||
let cleaned = raw.trim();
|
||||
cleaned = cleaned.replace(/```json\s*/gi, '').replace(/```\s*/gi, '');
|
||||
|
||||
const start = cleaned.indexOf('{');
|
||||
const end = cleaned.lastIndexOf('}');
|
||||
if (start === -1 || end === -1) return { ...FALLBACK };
|
||||
|
||||
const parsed = JSON.parse(cleaned.slice(start, end + 1));
|
||||
|
||||
return {
|
||||
title: (typeof parsed.title === 'string' && parsed.title.trim()) || FALLBACK.title,
|
||||
summary: (typeof parsed.summary === 'string' && parsed.summary.trim()) || FALLBACK.summary,
|
||||
quote: (typeof parsed.quote === 'string' && parsed.quote.trim()) || FALLBACK.quote,
|
||||
};
|
||||
} catch (_) {
|
||||
return { ...FALLBACK };
|
||||
}
|
||||
}
|
||||
|
||||
export { FALLBACK };
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Safely parse AI-generated transformation story.
|
||||
* Always returns a valid result — never throws.
|
||||
*/
|
||||
export default function safeParseStory(raw, identity, logs, stats) {
|
||||
const fallback = buildMinimalFallback(identity, logs);
|
||||
|
||||
if (!raw || typeof raw !== 'string') return fallback;
|
||||
|
||||
try {
|
||||
let cleaned = raw.trim();
|
||||
cleaned = cleaned.replace(/```json\s*/gi, '').replace(/```\s*/gi, '');
|
||||
|
||||
const start = cleaned.indexOf('{');
|
||||
const end = cleaned.lastIndexOf('}');
|
||||
if (start === -1 || end === -1) return fallback;
|
||||
|
||||
cleaned = cleaned.slice(start, end + 1);
|
||||
const parsed = JSON.parse(cleaned);
|
||||
|
||||
const title = typeof parsed.title === 'string' && parsed.title.trim()
|
||||
? parsed.title.trim()
|
||||
: fallback.title;
|
||||
|
||||
let paragraphs = fallback.paragraphs;
|
||||
if (Array.isArray(parsed.paragraphs) && parsed.paragraphs.length >= 2) {
|
||||
paragraphs = parsed.paragraphs
|
||||
.filter((p) => typeof p === 'string' && p.trim())
|
||||
.map((p) => p.trim());
|
||||
}
|
||||
|
||||
const closing_line = typeof parsed.closing_line === 'string' && parsed.closing_line.trim()
|
||||
? parsed.closing_line.trim()
|
||||
: fallback.closing_line;
|
||||
|
||||
return { title, paragraphs, closing_line };
|
||||
} catch (_) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function buildMinimalFallback(identity, logs) {
|
||||
const yesCount = logs.filter((l) => l.identity_check === 'yes').length;
|
||||
return {
|
||||
title: 'Your Transformation',
|
||||
paragraphs: [
|
||||
`You set out to become "${identity?.title || 'someone new'}". That took courage.`,
|
||||
`Over 40 days, you showed up ${yesCount} times fully aligned with your identity.`,
|
||||
`The journey wasn't perfect, but it was yours. And that's what matters.`,
|
||||
],
|
||||
closing_line: 'This is not the end. This is who you are now.',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
export const colors = {
|
||||
background: '#0A0E1A',
|
||||
surface: '#131831',
|
||||
surfaceLight: '#1C2345',
|
||||
primary: '#6C63FF',
|
||||
primaryLight: '#8B85FF',
|
||||
accent: '#00E5FF',
|
||||
accentGlow: 'rgba(0, 229, 255, 0.3)',
|
||||
success: '#00E676',
|
||||
warning: '#FFD740',
|
||||
error: '#FF5252',
|
||||
text: '#FFFFFF',
|
||||
textSecondary: '#8A8FB5',
|
||||
textMuted: '#4A5078',
|
||||
border: '#1E2548',
|
||||
glow: 'rgba(108, 99, 255, 0.4)',
|
||||
planetCore: '#6C63FF',
|
||||
planetGlow: 'rgba(108, 99, 255, 0.6)',
|
||||
orbitLine: 'rgba(108, 99, 255, 0.2)',
|
||||
orbitActive: '#00E5FF',
|
||||
star: 'rgba(255, 255, 255, 0.6)',
|
||||
};
|
||||
|
||||
export const fonts = {
|
||||
sizes: {
|
||||
xs: 12,
|
||||
sm: 14,
|
||||
md: 16,
|
||||
lg: 20,
|
||||
xl: 24,
|
||||
xxl: 32,
|
||||
hero: 40,
|
||||
},
|
||||
weights: {
|
||||
regular: '400',
|
||||
medium: '500',
|
||||
semibold: '600',
|
||||
bold: '700',
|
||||
},
|
||||
};
|
||||
|
||||
export const spacing = {
|
||||
xs: 4,
|
||||
sm: 8,
|
||||
md: 16,
|
||||
lg: 24,
|
||||
xl: 32,
|
||||
xxl: 48,
|
||||
};
|
||||
|
||||
export const borderRadius = {
|
||||
sm: 8,
|
||||
md: 12,
|
||||
lg: 16,
|
||||
xl: 24,
|
||||
full: 999,
|
||||
};
|
||||
@@ -0,0 +1,196 @@
|
||||
// Supabase Edge Function: generate-habits
|
||||
// Deploy: supabase functions deploy generate-habits --no-verify-jwt
|
||||
// Set secret: supabase secrets set GEMINI_API_KEY=your-key-here
|
||||
|
||||
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
|
||||
|
||||
const GEMINI_URL =
|
||||
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent';
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
};
|
||||
|
||||
function buildPrompt(story: string): string {
|
||||
return `You are a thoughtful and emotionally intelligent personal growth coach.
|
||||
|
||||
A user has written a personal story about their current life and who they want to become.
|
||||
|
||||
Your job is NOT just to generate habits.
|
||||
Your job is to deeply understand the person and reflect their intention back in a meaningful way.
|
||||
|
||||
---
|
||||
|
||||
INSTRUCTIONS:
|
||||
|
||||
1. Read the user's story carefully.
|
||||
2. Understand:
|
||||
- their struggles
|
||||
- their desires
|
||||
- their emotional tone
|
||||
|
||||
3. Then generate:
|
||||
|
||||
A. Identity Title
|
||||
- A short, powerful sentence
|
||||
- Feels personal and motivating
|
||||
- Example: "I am someone who shows up even when it's hard"
|
||||
|
||||
B. Identity Summary
|
||||
- 2-3 sentences
|
||||
- Reflect their story in a human tone
|
||||
- Make it feel like someone truly understands them
|
||||
- Avoid generic phrases
|
||||
|
||||
C. Suggested Habits (5-8 items)
|
||||
|
||||
Rules:
|
||||
- Very simple and actionable
|
||||
- Can be done daily
|
||||
- No complexity
|
||||
- Each habit max 1 sentence
|
||||
- Feels aligned with their story (not random)
|
||||
|
||||
IMPORTANT:
|
||||
- Habits should feel like "small proof of identity"
|
||||
- Not tasks, but expressions of who they want to become
|
||||
|
||||
D. Tone Style:
|
||||
- Warm
|
||||
- Supportive
|
||||
- Slightly motivational
|
||||
- Never robotic
|
||||
- Never overly formal
|
||||
- Avoid buzzwords like "optimize", "maximize"
|
||||
|
||||
---
|
||||
|
||||
OUTPUT FORMAT (JSON ONLY):
|
||||
Return ONLY valid JSON. No explanation, no markdown, no extra text.
|
||||
|
||||
{
|
||||
"identity_title": "",
|
||||
"identity_summary": "",
|
||||
"suggested_habits": [
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
""
|
||||
]
|
||||
}
|
||||
|
||||
---
|
||||
|
||||
EXAMPLE:
|
||||
|
||||
User story: "I feel like I procrastinate a lot and I want to be more focused and disciplined."
|
||||
|
||||
Bad output: "Improve productivity with structured routines"
|
||||
Good output: "I am someone who takes action even when I don't feel ready"
|
||||
|
||||
Make the user feel understood, not analyzed.
|
||||
|
||||
---
|
||||
|
||||
User story:
|
||||
"""
|
||||
${story}
|
||||
"""`;
|
||||
}
|
||||
|
||||
serve(async (req) => {
|
||||
// Handle CORS preflight
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response('ok', { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const { story } = await req.json();
|
||||
|
||||
if (!story || typeof story !== 'string' || story.trim().length < 10) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Please write at least a few sentences about yourself.' }),
|
||||
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
const apiKey = Deno.env.get('GEMINI_API_KEY');
|
||||
if (!apiKey) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'AI service not configured. Please set GEMINI_API_KEY.' }),
|
||||
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
// Call Gemini API
|
||||
const geminiResponse = await fetch(`${GEMINI_URL}?key=${apiKey}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contents: [{ parts: [{ text: buildPrompt(story.trim()) }] }],
|
||||
generationConfig: {
|
||||
temperature: 0.7,
|
||||
topP: 0.9,
|
||||
maxOutputTokens: 1024,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!geminiResponse.ok) {
|
||||
const errText = await geminiResponse.text();
|
||||
console.error('Gemini API error:', errText);
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'AI generation failed. Please try again.' }),
|
||||
{ status: 502, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
const geminiData = await geminiResponse.json();
|
||||
|
||||
// Extract text from Gemini response
|
||||
const rawText =
|
||||
geminiData?.candidates?.[0]?.content?.parts?.[0]?.text || '';
|
||||
|
||||
// Parse JSON from response (handle potential markdown wrapping)
|
||||
let cleaned = rawText.trim();
|
||||
// Remove markdown code blocks if present
|
||||
cleaned = cleaned.replace(/```json\s*/gi, '').replace(/```\s*/gi, '');
|
||||
cleaned = cleaned.trim();
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = JSON.parse(cleaned);
|
||||
} catch {
|
||||
console.error('Failed to parse Gemini output:', cleaned);
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'AI returned invalid data. Please try again.' }),
|
||||
{ status: 502, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate structure
|
||||
if (
|
||||
!result.identity_title ||
|
||||
!result.identity_summary ||
|
||||
!Array.isArray(result.suggested_habits) ||
|
||||
result.suggested_habits.length === 0
|
||||
) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'AI returned incomplete data. Please try again.' }),
|
||||
{ status: 502, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(result), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Function error:', err);
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Something went wrong. Please try again.' }),
|
||||
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
-- Nova40 Database Schema
|
||||
-- Run this in your Supabase SQL editor
|
||||
|
||||
-- Enable UUID extension
|
||||
create extension if not exists "uuid-ossp";
|
||||
|
||||
-- 1. Identities table
|
||||
create table identities (
|
||||
id uuid default uuid_generate_v4() primary key,
|
||||
user_id uuid references auth.users(id) on delete cascade not null,
|
||||
title text not null,
|
||||
description text,
|
||||
start_date date not null,
|
||||
end_date date not null,
|
||||
created_at timestamptz default now()
|
||||
);
|
||||
|
||||
alter table identities enable row level security;
|
||||
|
||||
create policy "Users can view their own identities"
|
||||
on identities for select using (auth.uid() = user_id);
|
||||
|
||||
create policy "Users can create their own identities"
|
||||
on identities for insert with check (auth.uid() = user_id);
|
||||
|
||||
-- 2. Habits table
|
||||
create table habits (
|
||||
id uuid default uuid_generate_v4() primary key,
|
||||
identity_id uuid references identities(id) on delete cascade not null,
|
||||
title text not null,
|
||||
description text
|
||||
);
|
||||
|
||||
alter table habits enable row level security;
|
||||
|
||||
create policy "Users can view habits for their identities"
|
||||
on habits for select using (
|
||||
identity_id in (select id from identities where user_id = auth.uid())
|
||||
);
|
||||
|
||||
create policy "Users can create habits for their identities"
|
||||
on habits for insert with check (
|
||||
identity_id in (select id from identities where user_id = auth.uid())
|
||||
);
|
||||
|
||||
-- 3. Daily logs table
|
||||
create table daily_logs (
|
||||
id uuid default uuid_generate_v4() primary key,
|
||||
identity_id uuid references identities(id) on delete cascade not null,
|
||||
date date not null,
|
||||
day_number int not null,
|
||||
identity_check text check (identity_check in ('yes', 'almost', 'no')),
|
||||
note text,
|
||||
unique (identity_id, date)
|
||||
);
|
||||
|
||||
alter table daily_logs enable row level security;
|
||||
|
||||
create policy "Users can view their daily logs"
|
||||
on daily_logs for select using (
|
||||
identity_id in (select id from identities where user_id = auth.uid())
|
||||
);
|
||||
|
||||
create policy "Users can insert daily logs"
|
||||
on daily_logs for insert with check (
|
||||
identity_id in (select id from identities where user_id = auth.uid())
|
||||
);
|
||||
|
||||
create policy "Users can update their daily logs"
|
||||
on daily_logs for update using (
|
||||
identity_id in (select id from identities where user_id = auth.uid())
|
||||
);
|
||||
|
||||
-- 4. Habit logs table
|
||||
create table habit_logs (
|
||||
id uuid default uuid_generate_v4() primary key,
|
||||
habit_id uuid references habits(id) on delete cascade not null,
|
||||
date date not null,
|
||||
completed boolean default false,
|
||||
unique (habit_id, date)
|
||||
);
|
||||
|
||||
alter table habit_logs enable row level security;
|
||||
|
||||
create policy "Users can view their habit logs"
|
||||
on habit_logs for select using (
|
||||
habit_id in (
|
||||
select h.id from habits h
|
||||
join identities i on h.identity_id = i.id
|
||||
where i.user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
create policy "Users can insert habit logs"
|
||||
on habit_logs for insert with check (
|
||||
habit_id in (
|
||||
select h.id from habits h
|
||||
join identities i on h.identity_id = i.id
|
||||
where i.user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
create policy "Users can update their habit logs"
|
||||
on habit_logs for update using (
|
||||
habit_id in (
|
||||
select h.id from habits h
|
||||
join identities i on h.identity_id = i.id
|
||||
where i.user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- 5. Game sessions table
|
||||
create table game_sessions (
|
||||
id uuid default uuid_generate_v4() primary key,
|
||||
identity_id uuid references identities(id) on delete cascade not null,
|
||||
game_type text not null,
|
||||
score int default 0,
|
||||
created_at timestamptz default now()
|
||||
);
|
||||
|
||||
alter table game_sessions enable row level security;
|
||||
|
||||
create policy "Users can view their game sessions"
|
||||
on game_sessions for select using (
|
||||
identity_id in (select id from identities where user_id = auth.uid())
|
||||
);
|
||||
|
||||
create policy "Users can insert game sessions"
|
||||
on game_sessions for insert with check (
|
||||
identity_id in (select id from identities where user_id = auth.uid())
|
||||
);
|
||||
|
||||
-- 6. Stats table
|
||||
create table stats (
|
||||
id uuid default uuid_generate_v4() primary key,
|
||||
identity_id uuid references identities(id) on delete cascade not null unique,
|
||||
discipline_score int default 0,
|
||||
focus_score int default 0,
|
||||
consistency_score int default 0
|
||||
);
|
||||
|
||||
alter table stats enable row level security;
|
||||
|
||||
create policy "Users can view their stats"
|
||||
on stats for select using (
|
||||
identity_id in (select id from identities where user_id = auth.uid())
|
||||
);
|
||||
|
||||
create policy "Users can insert their stats"
|
||||
on stats for insert with check (
|
||||
identity_id in (select id from identities where user_id = auth.uid())
|
||||
);
|
||||
|
||||
create policy "Users can update their stats"
|
||||
on stats for update using (
|
||||
identity_id in (select id from identities where user_id = auth.uid())
|
||||
);
|
||||
@@ -0,0 +1,75 @@
|
||||
-- Nova40 Demo User Seeder
|
||||
-- Run this in Supabase SQL Editor
|
||||
--
|
||||
-- Creates: demo@nova40.app / 123456 (pre-confirmed, ready to login)
|
||||
|
||||
-- Step 1: Create the demo user in auth.users
|
||||
do $$
|
||||
declare
|
||||
demo_uid uuid := gen_random_uuid();
|
||||
begin
|
||||
-- Skip if demo user already exists
|
||||
if exists (select 1 from auth.users where email = 'demo@nova40.app') then
|
||||
raise notice 'Demo user already exists, skipping.';
|
||||
return;
|
||||
end if;
|
||||
|
||||
-- Insert into auth.users with minimal required columns
|
||||
insert into auth.users (
|
||||
id,
|
||||
instance_id,
|
||||
aud,
|
||||
role,
|
||||
email,
|
||||
encrypted_password,
|
||||
email_confirmed_at,
|
||||
raw_app_meta_data,
|
||||
raw_user_meta_data,
|
||||
created_at,
|
||||
updated_at,
|
||||
confirmation_token
|
||||
) values (
|
||||
demo_uid,
|
||||
'00000000-0000-0000-0000-000000000000',
|
||||
'authenticated',
|
||||
'authenticated',
|
||||
'demo@nova40.app',
|
||||
crypt('123456', gen_salt('bf')),
|
||||
now(),
|
||||
'{"provider": "email", "providers": ["email"]}'::jsonb,
|
||||
'{"email": "demo@nova40.app"}'::jsonb,
|
||||
now(),
|
||||
now(),
|
||||
''
|
||||
);
|
||||
|
||||
-- Insert into auth.identities (required for sign-in to work)
|
||||
insert into auth.identities (
|
||||
id,
|
||||
user_id,
|
||||
provider_id,
|
||||
identity_data,
|
||||
provider,
|
||||
last_sign_in_at,
|
||||
created_at,
|
||||
updated_at
|
||||
) values (
|
||||
demo_uid,
|
||||
demo_uid,
|
||||
'demo@nova40.app',
|
||||
jsonb_build_object(
|
||||
'sub', demo_uid::text,
|
||||
'email', 'demo@nova40.app',
|
||||
'email_verified', true
|
||||
),
|
||||
'email',
|
||||
now(),
|
||||
now(),
|
||||
now()
|
||||
);
|
||||
|
||||
raise notice 'Demo user created successfully (id: %)', demo_uid;
|
||||
end $$;
|
||||
|
||||
-- Verify: you should see one row
|
||||
select id, email, email_confirmed_at from auth.users where email = 'demo@nova40.app';
|
||||
@@ -0,0 +1,107 @@
|
||||
-- Nova40: Fix auth schema and create demo user
|
||||
-- Run this in Supabase SQL Editor
|
||||
--
|
||||
-- This script:
|
||||
-- 1. Cleans up any broken demo user entries from previous seeder attempts
|
||||
-- 2. Verifies auth schema health
|
||||
-- 3. Creates a proper demo user
|
||||
|
||||
-- ============================================
|
||||
-- STEP 1: Clean up any broken demo user data
|
||||
-- ============================================
|
||||
delete from auth.identities
|
||||
where user_id in (select id from auth.users where email = 'demo@nova40.app');
|
||||
|
||||
delete from auth.users
|
||||
where email = 'demo@nova40.app';
|
||||
|
||||
-- ============================================
|
||||
-- STEP 2: Verify auth schema is healthy
|
||||
-- ============================================
|
||||
-- This should return a number (0 is fine)
|
||||
select count(*) as total_users from auth.users;
|
||||
|
||||
-- ============================================
|
||||
-- STEP 3: Create demo user properly
|
||||
-- ============================================
|
||||
-- We need the pgcrypto extension for crypt()
|
||||
create extension if not exists pgcrypto;
|
||||
|
||||
do $$
|
||||
declare
|
||||
new_uid uuid := gen_random_uuid();
|
||||
begin
|
||||
-- Insert user
|
||||
insert into auth.users (
|
||||
id,
|
||||
instance_id,
|
||||
aud,
|
||||
role,
|
||||
email,
|
||||
encrypted_password,
|
||||
email_confirmed_at,
|
||||
raw_app_meta_data,
|
||||
raw_user_meta_data,
|
||||
created_at,
|
||||
updated_at,
|
||||
confirmation_token,
|
||||
recovery_token,
|
||||
email_change_token_new,
|
||||
email_change
|
||||
) values (
|
||||
new_uid,
|
||||
'00000000-0000-0000-0000-000000000000',
|
||||
'authenticated',
|
||||
'authenticated',
|
||||
'demo@nova40.app',
|
||||
crypt('123456', gen_salt('bf')),
|
||||
now(),
|
||||
'{"provider": "email", "providers": ["email"]}'::jsonb,
|
||||
'{"email": "demo@nova40.app"}'::jsonb,
|
||||
now(),
|
||||
now(),
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
''
|
||||
);
|
||||
|
||||
-- Insert identity (REQUIRED for Supabase auth login to work)
|
||||
insert into auth.identities (
|
||||
id,
|
||||
user_id,
|
||||
provider_id,
|
||||
identity_data,
|
||||
provider,
|
||||
last_sign_in_at,
|
||||
created_at,
|
||||
updated_at
|
||||
) values (
|
||||
new_uid,
|
||||
new_uid,
|
||||
'demo@nova40.app',
|
||||
jsonb_build_object(
|
||||
'sub', new_uid::text,
|
||||
'email', 'demo@nova40.app',
|
||||
'email_verified', true
|
||||
),
|
||||
'email',
|
||||
now(),
|
||||
now(),
|
||||
now()
|
||||
);
|
||||
|
||||
raise notice '✅ Demo user created! ID: %', new_uid;
|
||||
end $$;
|
||||
|
||||
-- ============================================
|
||||
-- STEP 4: Verify the user was created
|
||||
-- ============================================
|
||||
select
|
||||
u.id,
|
||||
u.email,
|
||||
u.email_confirmed_at,
|
||||
i.provider
|
||||
from auth.users u
|
||||
left join auth.identities i on i.user_id = u.id
|
||||
where u.email = 'demo@nova40.app';
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"compilerOptions": {},
|
||||
"extends": "expo/tsconfig.base"
|
||||
}
|
||||