init
@@ -1,20 +1,21 @@
|
|||||||
import { StatusBar } from 'expo-status-bar';
|
import 'react-native-gesture-handler';
|
||||||
import { StyleSheet, Text, View } from 'react-native';
|
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() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<Text>Open up App.js to start working on your app!</Text>
|
<SafeAreaProvider>
|
||||||
<StatusBar style="auto" />
|
<NovaAlertProvider>
|
||||||
</View>
|
<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",
|
"version": "1.0.0",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"userInterfaceStyle": "light",
|
"userInterfaceStyle": "dark",
|
||||||
"newArchEnabled": true,
|
"newArchEnabled": true,
|
||||||
"splash": {
|
"splash": {
|
||||||
"image": "./assets/splash-icon.png",
|
"image": "./assets/splash-icon.png",
|
||||||
"resizeMode": "contain",
|
"resizeMode": "contain",
|
||||||
"backgroundColor": "#ffffff"
|
"backgroundColor": "#0A0E1A"
|
||||||
},
|
},
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true
|
"supportsTablet": true,
|
||||||
|
"bundleIdentifier": "com.nova40.app"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/adaptive-icon.png",
|
"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": {
|
"web": {
|
||||||
"favicon": "./assets/favicon.png"
|
"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"
|
"web": "expo start --web"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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": "~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",
|
"expo-status-bar": "~3.0.9",
|
||||||
"react": "19.1.0",
|
"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"
|
||||||
|
}
|
||||||