This commit is contained in:
dios.one
2026-04-21 18:00:30 +07:00
parent 2173a765c9
commit 23dc306f12
67 changed files with 10302 additions and 67 deletions
+16 -15
View File
@@ -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',
},
});
+18 -6
View File
@@ -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"
} }
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 731 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 731 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 731 KiB

+6
View File
@@ -0,0 +1,6 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
};
};
+17
View File
@@ -0,0 +1,17 @@
{
"cli": {
"version": ">= 3.0.0"
},
"build": {
"preview": {
"android": {
"buildType": "apk"
}
},
"production": {
"android": {
"buildType": "app-bundle"
}
}
}
}
+1466 -44
View File
File diff suppressed because it is too large Load Diff
+24 -2
View File
@@ -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"
}
} }
+93
View File
@@ -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,
},
});
+77
View File
@@ -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,
},
});
+87
View File
@@ -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,
},
});
+179
View File
@@ -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,
},
});
+134
View File
@@ -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',
},
});
+145
View File
@@ -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)',
},
});
+130
View File
@@ -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,
},
});
+87
View File
@@ -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)',
},
});
+25
View File
@@ -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,
},
});
+126
View File
@@ -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.54.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, // 47px
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, // 58px 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,
},
});
+167
View File
@@ -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 },
});
+95
View File
@@ -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 },
});
+140
View File
@@ -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 },
});
+173
View File
@@ -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 },
});
+189
View File
@@ -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 },
});
+2
View File
@@ -0,0 +1,2 @@
// API Keys — move to environment variables for production
export const GEMINI_API_KEY = 'AIzaSyBmCdxsw9zeDI-KzyRAE1dtFflo9rhKcBc';
+119
View File
@@ -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>
);
}
+324
View File
@@ -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,
},
});
+330
View File
@@ -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',
},
});
+298
View File
@@ -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,
},
});
+466
View File
@@ -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 },
});
+418
View File
@@ -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,
},
});
+294
View File
@@ -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%',
},
});
+233
View File
@@ -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: {},
});
+298
View File
@@ -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,
},
});
+325
View File
@@ -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,
},
});
+229
View File
@@ -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,
},
});
+137
View File
@@ -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 },
});
+372
View File
@@ -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,
},
});
+135
View File
@@ -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,
},
});
+152
View File
@@ -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,
},
});
+114
View File
@@ -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',
},
});
+154
View File
@@ -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 },
});
+206
View File
@@ -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',
};
}
+151
View File
@@ -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: () => {} } } };
}
+112
View File
@@ -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;
}
+83
View File
@@ -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;
}
}
+242
View File
@@ -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;
}
+151
View File
@@ -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() });
}
+85
View File
@@ -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 });
}
+137
View File
@@ -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.',
};
}
+15
View File
@@ -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,
},
});
+28
View File
@@ -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;
+98
View File
@@ -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;
+55
View File
@@ -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;
+82
View File
@@ -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;
+19
View File
@@ -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];
}
+301
View File
@@ -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',
});
}
+64
View File
@@ -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 };
+30
View File
@@ -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 };
+53
View File
@@ -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.',
};
}
+57
View File
@@ -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,
};
+196
View File
@@ -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' } }
);
}
});
+157
View File
@@ -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';
+4
View File
@@ -0,0 +1,4 @@
{
"compilerOptions": {},
"extends": "expo/tsconfig.base"
}