merubah UI dan login dan register dan profile

This commit is contained in:
dios.one
2026-05-04 17:32:06 +07:00
parent 9a9ad9c741
commit a5fcbb6c26
51 changed files with 4866 additions and 1238 deletions
+16 -8
View File
@@ -1,21 +1,29 @@
import 'react-native-gesture-handler';
import React from 'react';
import React, { useEffect } from 'react';
import { LogBox } from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import ErrorBoundary from './src/components/ErrorBoundary';
import NovaAlertProvider from './src/components/NovaAlert';
import AppNavigator from './src/navigation/AppNavigator';
import { initCrashlytics } from './src/services/crashlytics';
LogBox.ignoreLogs(['TypeError: Network request failed']);
export default function App() {
useEffect(() => {
initCrashlytics();
}, []);
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<NovaAlertProvider>
<AppNavigator />
</NovaAlertProvider>
</SafeAreaProvider>
</GestureHandlerRootView>
<ErrorBoundary>
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<NovaAlertProvider>
<AppNavigator />
</NovaAlertProvider>
</SafeAreaProvider>
</GestureHandlerRootView>
</ErrorBoundary>
);
}
+6 -1
View File
@@ -23,6 +23,7 @@
},
"edgeToEdgeEnabled": true,
"package": "com.nova40.app",
"googleServicesFile": "./google-services.json",
"permissions": [
"WRITE_EXTERNAL_STORAGE",
"READ_EXTERNAL_STORAGE"
@@ -36,6 +37,10 @@
"projectId": "f0ecc895-4610-481d-96db-73a121e78254"
}
},
"owner": "heyaciell"
"owner": "heyaciell",
"plugins": [
"@react-native-firebase/app",
"@react-native-firebase/crashlytics"
]
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"project_info": {
"project_number": "753665389666",
"project_id": "nova40-87347",
"storage_bucket": "nova40-87347.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:753665389666:android:8455a4f7523cffe463cd3b",
"android_client_info": {
"package_name": "com.nova40.app"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyDHO1iphyyDf5aPR3nnRldPZMAePHBLfho"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}
+1001
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -10,6 +10,9 @@
},
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-firebase/analytics": "^24.0.0",
"@react-native-firebase/app": "^24.0.0",
"@react-native-firebase/crashlytics": "^24.0.0",
"@react-navigation/bottom-tabs": "^7.15.9",
"@react-navigation/native": "^7.2.2",
"@react-navigation/native-stack": "^7.14.11",
@@ -17,6 +20,7 @@
"@supabase/supabase-js": "^2.103.0",
"babel-preset-expo": "~54.0.10",
"expo": "~54.0.33",
"expo-image-picker": "~17.0.11",
"expo-linear-gradient": "~15.0.8",
"expo-media-library": "~18.2.1",
"expo-print": "~15.0.8",
@@ -25,6 +29,7 @@
"react": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-google-mobile-ads": "^16.3.3",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
+11 -2
View File
@@ -11,6 +11,7 @@ export default function Button({
style,
}) {
const isPrimary = variant === 'primary';
const isDanger = variant === 'danger';
const scale = useRef(new Animated.Value(1)).current;
const onPressIn = () => {
@@ -36,7 +37,7 @@ export default function Button({
<Pressable
style={[
styles.button,
isPrimary ? styles.primary : styles.secondary,
isPrimary ? styles.primary : isDanger ? styles.danger : styles.secondary,
disabled && styles.disabled,
]}
onPress={onPress}
@@ -47,7 +48,7 @@ export default function Button({
{loading ? (
<ActivityIndicator color={colors.text} />
) : (
<Text style={[styles.text, !isPrimary && styles.secondaryText]}>
<Text style={[styles.text, !isPrimary && !isDanger && styles.secondaryText]}>
{title}
</Text>
)}
@@ -78,6 +79,14 @@ const styles = StyleSheet.create({
borderWidth: 1.5,
borderColor: colors.primary,
},
danger: {
backgroundColor: colors.error,
shadowColor: colors.error,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.4,
shadowRadius: 12,
elevation: 6,
},
disabled: {
opacity: 0.5,
},
+77
View File
@@ -0,0 +1,77 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { recordError } from '../services/crashlytics';
import { colors, fonts, spacing, borderRadius } from '../utils/theme';
export default class ErrorBoundary extends React.Component {
state = { hasError: false, error: null };
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
recordError(error, `ErrorBoundary: ${errorInfo?.componentStack || ''}`);
}
handleRetry = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
return (
<View style={styles.container}>
<Text style={styles.emoji}></Text>
<Text style={styles.title}>Oops, something broke</Text>
<Text style={styles.message}>
We hit a bump, but don't worry — we already know about it. Try again and it should work.
</Text>
<Pressable style={styles.button} onPress={this.handleRetry}>
<Text style={styles.buttonText}>Let's Try Again</Text>
</Pressable>
</View>
);
}
return this.props.children;
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
alignItems: 'center',
justifyContent: 'center',
padding: spacing.xl,
},
emoji: {
fontSize: 48,
marginBottom: spacing.lg,
},
title: {
color: colors.text,
fontSize: fonts.sizes.xl,
fontWeight: fonts.weights.bold,
marginBottom: spacing.md,
},
message: {
color: colors.textSecondary,
fontSize: fonts.sizes.md,
textAlign: 'center',
lineHeight: 22,
marginBottom: spacing.xl,
},
button: {
backgroundColor: colors.primary,
paddingVertical: spacing.md,
paddingHorizontal: spacing.xl,
borderRadius: borderRadius.lg,
},
buttonText: {
color: colors.text,
fontSize: fonts.sizes.md,
fontWeight: fonts.weights.semibold,
},
});
+33
View File
@@ -0,0 +1,33 @@
import React, { useRef, useEffect } from 'react';
import { Animated } from 'react-native';
/**
* Wrap any component to give it a subtle fade+slide entrance.
*
* Props:
* delay - ms delay before animation starts (default 0)
* duration - ms animation duration (default 350)
* slide - px to slide up from (default 10)
* trigger - change this value to replay the animation (e.g. a counter)
* style - additional style
*/
export default function FadeIn({ children, delay = 0, duration = 350, slide = 10, trigger, style }) {
const fade = useRef(new Animated.Value(0)).current;
const translateY = useRef(new Animated.Value(slide)).current;
useEffect(() => {
// Reset and replay
fade.setValue(0);
translateY.setValue(slide);
Animated.parallel([
Animated.timing(fade, { toValue: 1, duration, delay, useNativeDriver: true }),
Animated.timing(translateY, { toValue: 0, duration, delay, useNativeDriver: true }),
]).start();
}, [trigger]);
return (
<Animated.View style={[{ opacity: fade, transform: [{ translateY }] }, style]}>
{children}
</Animated.View>
);
}
+2 -2
View File
@@ -81,7 +81,7 @@ const styles = StyleSheet.create({
justifyContent: 'center',
},
iconText: {
fontSize: 18,
color: colors.textSecondary,
fontSize: 20,
color: colors.primary,
},
});
+77
View File
@@ -0,0 +1,77 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { colors, fonts, spacing, borderRadius } from '../utils/theme';
/**
* Ad placeholder component.
*
* In Expo Go: shows a subtle themed placeholder.
* In production build: replace the content with real AdMob BannerAd.
*
* To enable real ads:
* 1. Run: eas build --profile preview
* 2. Uncomment the AdMob import below
* 3. Replace placeholder with <BannerAd />
*/
// Uncomment for production build:
// import { BannerAd, BannerAdSize, TestIds } from 'react-native-google-mobile-ads';
// const AD_UNIT = TestIds.BANNER;
export default function NovaAd({ style }) {
return (
<View style={[st.container, style]}>
<View style={st.card}>
<View style={st.labelRow}>
<View style={st.labelDot} />
<Text style={st.labelText}>Sponsored</Text>
</View>
<View style={st.placeholder}>
<Text style={st.placeholderText}>Ad Space</Text>
</View>
</View>
</View>
);
}
const st = StyleSheet.create({
container: {
marginVertical: spacing.sm,
},
card: {
backgroundColor: colors.surface,
borderRadius: borderRadius.md,
borderWidth: 1,
borderColor: colors.border,
overflow: 'hidden',
},
labelRow: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.sm,
paddingTop: spacing.xs,
paddingBottom: spacing.xs,
},
labelDot: {
width: 4,
height: 4,
borderRadius: 2,
backgroundColor: colors.textMuted,
marginRight: spacing.xs,
},
labelText: {
color: colors.textMuted,
fontSize: 9,
fontWeight: fonts.weights.medium,
},
placeholder: {
height: 52,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(108,99,255,0.04)',
},
placeholderText: {
color: colors.textMuted,
fontSize: 10,
},
});
+2 -4
View File
@@ -35,10 +35,8 @@ export default function NovaAlertProvider({ children }) {
}, [alert]);
const dismiss = (callback) => {
Animated.timing(fadeAnim, { toValue: 0, duration: 150, useNativeDriver: true }).start(() => {
setAlert(null);
if (callback) callback();
});
setAlert(null);
if (callback) setTimeout(callback, 50);
};
return (
+78
View File
@@ -0,0 +1,78 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { colors, fonts, spacing, borderRadius } from '../utils/theme';
function getStrength(password) {
if (!password) return { level: 0, label: '', color: 'transparent' };
let score = 0;
if (password.length >= 6) score++;
if (password.length >= 10) score++;
if (/[A-Z]/.test(password)) score++;
if (/[0-9]/.test(password)) score++;
if (/[^A-Za-z0-9]/.test(password)) score++;
if (score <= 1) return { level: 1, label: 'Weak', color: colors.error, hint: 'Try adding numbers or symbols' };
if (score <= 2) return { level: 2, label: 'Fair', color: colors.warning, hint: 'Getting there — add uppercase or symbols' };
if (score <= 3) return { level: 3, label: 'Good', color: colors.accent, hint: 'Nice — almost strong' };
return { level: 4, label: 'Strong', color: colors.success, hint: 'Great password!' };
}
export default function PasswordStrength({ password }) {
const { level, label, color, hint } = getStrength(password);
if (!password) return null;
return (
<View style={styles.container}>
{/* Bars */}
<View style={styles.barRow}>
{[1, 2, 3, 4].map((i) => (
<View
key={i}
style={[
styles.bar,
{ backgroundColor: i <= level ? color : colors.border },
]}
/>
))}
</View>
{/* Label + hint */}
<View style={styles.textRow}>
<Text style={[styles.label, { color }]}>{label}</Text>
<Text style={styles.hint}>{hint}</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
marginTop: -spacing.sm,
marginBottom: spacing.md,
},
barRow: {
flexDirection: 'row',
gap: 4,
marginBottom: spacing.xs,
},
bar: {
flex: 1,
height: 4,
borderRadius: 2,
},
textRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
label: {
fontSize: fonts.sizes.xs,
fontWeight: fonts.weights.bold,
},
hint: {
fontSize: fonts.sizes.xs,
color: colors.textMuted,
},
});
+7 -7
View File
@@ -14,7 +14,7 @@ export default function FocusHold({ currentDay, onComplete }) {
const [holding, setHolding] = useState(false);
const [failed, setFailed] = useState(false);
const [successes, setSuccesses] = useState(0);
const [message, setMessage] = useState('Press and hold');
const [message, setMessage] = useState('Hold it steady');
const progress = useRef(new Animated.Value(0)).current;
const ringOpacity = useRef(new Animated.Value(0.4)).current;
@@ -26,7 +26,7 @@ export default function FocusHold({ currentDay, onComplete }) {
ringOpacity.setValue(0.4);
setHolding(false);
setFailed(false);
setMessage('Press and hold');
setMessage('Hold it steady');
}, []);
useEffect(() => {
@@ -40,7 +40,7 @@ export default function FocusHold({ currentDay, onComplete }) {
if (round >= TOTAL_ROUNDS) return;
setHolding(true);
setFailed(false);
setMessage('Hold steady...');
setMessage('Stay with it...');
animRef.current = Animated.timing(progress, {
toValue: 1,
@@ -54,7 +54,7 @@ export default function FocusHold({ currentDay, onComplete }) {
if (finished) {
// Success
setSuccesses((prev) => prev + 1);
setMessage('Perfect!');
setMessage('Nailed it!');
setHolding(false);
setTimeout(() => {
setRound((prev) => prev + 1);
@@ -70,7 +70,7 @@ export default function FocusHold({ currentDay, onComplete }) {
animRef.current?.stop();
setHolding(false);
setFailed(true);
setMessage('Released too early');
setMessage('You let go too soon');
Animated.timing(ringOpacity, { toValue: 0.2, duration: 200, useNativeDriver: true }).start();
setTimeout(() => {
@@ -92,7 +92,7 @@ export default function FocusHold({ currentDay, onComplete }) {
animRef.current?.stop();
setHolding(false);
setFailed(true);
setMessage('Moved outside!');
setMessage('Finger moved too far');
Animated.timing(ringOpacity, { toValue: 0.2, duration: 200, useNativeDriver: true }).start();
setTimeout(() => {
setRound((prev) => prev + 1);
@@ -142,7 +142,7 @@ export default function FocusHold({ currentDay, onComplete }) {
</View>
<Text style={[styles.message, failed && styles.messageFail]}>{message}</Text>
<Text style={styles.hint}>Press and hold the circle without moving</Text>
<Text style={styles.hint}>Press, hold, and stay perfectly still</Text>
</View>
);
}
+17 -6
View File
@@ -4,7 +4,7 @@ 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 }) {
export default function GameResult({ gameType, score, details, onExit, onRetry }) {
const fadeIn = useRef(new Animated.Value(0)).current;
const scaleScore = useRef(new Animated.Value(0)).current;
@@ -20,14 +20,19 @@ export default function GameResult({ gameType, score, details, onSave, onRetry,
return (
<Animated.View style={[styles.container, { opacity: fadeIn }]}>
<Text style={styles.title}>Challenge Complete</Text>
<Text style={styles.title}>Nice work</Text>
<Animated.View style={[styles.scoreCard, { transform: [{ scale: scaleScore }] }]}>
<Text style={styles.scoreLabel}>Score</Text>
<Text style={styles.scoreLabel}>Your score</Text>
<Text style={styles.scoreValue}>{score}</Text>
<Text style={styles.statBonus}>+{statLabel}</Text>
</Animated.View>
{/* Auto-saved indicator */}
<View style={styles.savedBadge}>
<Text style={styles.savedText}> Score saved</Text>
</View>
<Text style={styles.message}>{message}</Text>
{details && (
@@ -55,8 +60,8 @@ export default function GameResult({ gameType, score, details, onSave, onRetry,
)}
<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} />
<Button title="Play Again" onPress={onRetry} style={styles.btn} />
<Button title="Back to Games" onPress={onExit} variant="secondary" style={styles.btn} />
</View>
</Animated.View>
);
@@ -76,12 +81,18 @@ const styles = StyleSheet.create({
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,
alignItems: 'center', borderWidth: 1, borderColor: colors.primary, marginBottom: spacing.md,
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 },
savedBadge: {
backgroundColor: 'rgba(0, 230, 118, 0.1)', borderRadius: borderRadius.full,
paddingVertical: spacing.xs, paddingHorizontal: spacing.lg,
borderWidth: 1, borderColor: 'rgba(0, 230, 118, 0.25)', marginBottom: spacing.md,
},
savedText: { color: colors.success, fontSize: fonts.sizes.xs, fontWeight: fonts.weights.semibold },
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,
+2 -2
View File
@@ -87,7 +87,7 @@ export default function ReflexTap({ currentDay, onComplete }) {
<View style={[styles.area, { width: AREA_SIZE, height: AREA_SIZE }]}>
{!playing && round === 0 && (
<Text style={styles.readyText}>Get ready...</Text>
<Text style={styles.readyText}>Here we go...</Text>
)}
{targetPos && (
@@ -114,7 +114,7 @@ export default function ReflexTap({ currentDay, onComplete }) {
)}
</View>
<Text style={styles.hint}>Tap the glowing targets as fast as you can</Text>
<Text style={styles.hint}>Tap the glow don't think, just react</Text>
</View>
);
}
+1 -1
View File
@@ -139,7 +139,7 @@ export default function TemptationChoice({ onComplete }) {
</Animated.View>
)}
<Text style={styles.hint}>Choose how your new identity would respond</Text>
<Text style={styles.hint}>What would the new you do?</Text>
</View>
);
}
+37 -35
View File
@@ -10,21 +10,20 @@ 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 speed = 1200 - difficulty * 150;
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 [results, setResults] = useState([]);
const [lastResult, setLastResult] = useState(null);
const [playing, setPlaying] = useState(true);
const [waiting, setWaiting] = useState(false);
const barPos = useRef(new Animated.Value(0)).current;
const currentValue = useRef(0);
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;
@@ -33,8 +32,17 @@ export default function TimingTap({ currentDay, onComplete }) {
})
);
// Track bar position in real time
useEffect(() => {
const id = barPos.addListener(({ value }) => {
currentValue.current = value;
});
return () => barPos.removeListener(id);
}, []);
const startSweep = useCallback(() => {
barPos.setValue(0);
currentValue.current = 0;
animRef.current = Animated.loop(
Animated.sequence([
Animated.timing(barPos, { toValue: 1, duration: speed, useNativeDriver: false }),
@@ -45,11 +53,11 @@ export default function TimingTap({ currentDay, onComplete }) {
}, [speed]);
useEffect(() => {
if (playing && round < ROUNDS) {
if (playing && round < ROUNDS && !waiting) {
startSweep();
}
return () => animRef.current?.stop();
}, [round, playing]);
}, [round, playing, waiting]);
useEffect(() => {
if (round >= ROUNDS && results.length >= ROUNDS) {
@@ -63,38 +71,33 @@ export default function TimingTap({ currentDay, onComplete }) {
}, [results]);
const handleTap = () => {
if (!playing || round >= ROUNDS) return;
if (!playing || round >= ROUNDS || waiting) return;
// Get current position (0-1)
// Stop animation and read current position
animRef.current?.stop();
let currentPos = 0;
barPos.addListener(({ value }) => { currentPos = value; });
// Read current value
const id = barPos.addListener(({ value }) => { currentPos = value; });
barPos.removeListener(id);
setWaiting(true);
// Hacky but works: extract current value
barPos.stopAnimation((value) => {
const zone = zonePositions[round];
const zoneCenter = zone + zoneWidth / 2;
const distance = Math.abs(value - zoneCenter);
const value = currentValue.current;
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';
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]);
setLastResult(result);
setResults((prev) => [...prev, result]);
resultScale.setValue(0);
Animated.spring(resultScale, { toValue: 1, useNativeDriver: true, speed: 20, bounciness: 10 }).start();
resultScale.setValue(0);
Animated.spring(resultScale, { toValue: 1, useNativeDriver: true, speed: 20, bounciness: 10 }).start();
setTimeout(() => {
setLastResult(null);
setRound((prev) => prev + 1);
}, 600);
});
setTimeout(() => {
setLastResult(null);
setWaiting(false);
setRound((prev) => prev + 1);
}, 600);
};
const indicatorLeft = barPos.interpolate({
@@ -103,7 +106,6 @@ export default function TimingTap({ currentDay, onComplete }) {
});
const currentZone = round < ROUNDS ? zonePositions[round] : 0.4;
const resultColor = lastResult === 'perfect' ? colors.accent : lastResult === 'good' ? colors.success : colors.error;
return (
@@ -147,10 +149,10 @@ export default function TimingTap({ currentDay, onComplete }) {
<View style={styles.barLine} />
</Pressable>
<Text style={styles.tapHint}>TAP when the line hits the zone</Text>
<Text style={styles.tapHint}>Tap at just the right moment</Text>
</View>
<Text style={styles.hint}>Hit the glowing zone with perfect timing</Text>
<Text style={styles.hint}>Find the rhythm. Hit the glow.</Text>
</View>
);
}
+4 -2
View File
@@ -1,2 +1,4 @@
// API Keys — move to environment variables for production
export const GEMINI_API_KEY = 'AIzaSyBmCdxsw9zeDI-KzyRAE1dtFflo9rhKcBc';
// Backend & API Configuration
export const BACKEND_URL = 'https://acil.imola.ai';
export const AI_SERVICE_URL = 'http://182.23.12.142:3106';
export const AI_MODEL = 'claude-haiku-4-5-20251001';
+22 -16
View File
@@ -9,10 +9,10 @@ import SplashScreen from '../screens/SplashScreen';
import OnboardingScreen from '../screens/OnboardingScreen';
import LoginScreen from '../screens/LoginScreen';
import RegisterScreen from '../screens/RegisterScreen';
import ForgotPasswordScreen from '../screens/ForgotPasswordScreen';
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';
@@ -21,6 +21,9 @@ import CompletionScreen from '../screens/CompletionScreen';
import ProfileScreen from '../screens/ProfileScreen';
import DailyJournalScreen from '../screens/DailyJournalScreen';
import JournalResultScreen from '../screens/JournalResultScreen';
import EditProfileScreen from '../screens/EditProfileScreen';
import PrivacyPolicyScreen from '../screens/PrivacyPolicyScreen';
import TermsScreen from '../screens/TermsScreen';
import useAuthStore from '../store/useAuthStore';
import * as authService from '../services/authService';
@@ -30,13 +33,15 @@ const Stack = createNativeStackNavigator();
const Tab = createBottomTabNavigator();
function TabIcon({ label, focused }) {
const icons = { Home: '', Daily: '', Stats: '', Profile: '◆' };
const icons = { Daily: '', Game: '', Stats: '', Profile: '◆' };
const activeColor = colors.primary;
const inactiveColor = colors.textSecondary; // #8A8FB5 — brighter than textMuted
return (
<View style={tabStyles.iconContainer}>
<Text numberOfLines={1} style={[tabStyles.label, { color: focused ? colors.primary : colors.textMuted }]}>
<Text numberOfLines={1} style={[tabStyles.label, { color: focused ? activeColor : inactiveColor }]}>
{label}
</Text>
<Text style={[tabStyles.icon, { color: focused ? colors.primary : colors.textMuted }]}>
<Text style={[tabStyles.icon, { color: focused ? activeColor : inactiveColor }]}>
{icons[label] || '●'}
</Text>
</View>
@@ -44,9 +49,9 @@ function TabIcon({ label, focused }) {
}
const tabStyles = StyleSheet.create({
iconContainer: { alignItems: 'center', justifyContent: 'center', paddingTop: 6, width: 70 },
label: { fontSize: 9, fontWeight: '600', marginBottom: 3 },
icon: { fontSize: 18 },
iconContainer: { alignItems: 'center', justifyContent: 'center', paddingTop: 10, width: 80 },
label: { fontSize: 12, fontWeight: '600', marginBottom: 4 },
icon: { fontSize: 22 },
});
function MainTabs() {
@@ -58,18 +63,19 @@ function MainTabs() {
backgroundColor: colors.surface,
borderTopColor: colors.border,
borderTopWidth: 1,
height: 85,
paddingBottom: 24,
height: 70,
paddingBottom: 10,
paddingTop: 6,
},
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="Game" component={MiniGameScreen}
options={{ tabBarIcon: ({ focused }) => <TabIcon label="Game" focused={focused} /> }} />
<Tab.Screen name="Profile" component={ProfileScreen}
options={{ tabBarIcon: ({ focused }) => <TabIcon label="Profile" focused={focused} /> }} />
</Tab.Navigator>
@@ -85,9 +91,7 @@ export default function AppNavigator() {
setSession(session);
});
return () => subscription.unsubscribe();
} catch (_) {
// Network unavailable — auth listener will be inactive
}
} catch (_) {}
}, []);
return (
@@ -99,20 +103,22 @@ export default function AppNavigator() {
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="ForgotPassword" component={ForgotPasswordScreen} />
<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.Screen name="EditProfile" component={EditProfileScreen} />
<Stack.Screen name="PrivacyPolicy" component={PrivacyPolicyScreen} />
<Stack.Screen name="Terms" component={TermsScreen} />
</Stack.Navigator>
</NavigationContainer>
);
+14 -8
View File
@@ -11,6 +11,7 @@ import useIdentityStore from '../store/useIdentityStore';
import useHabitStore from '../store/useHabitStore';
import useAuthStore from '../store/useAuthStore';
import { generateTransformationStory } from '../services/storyService';
import { trackJourneyCompleted, trackShare } from '../services/analytics';
import { colors, fonts, spacing, borderRadius } from '../utils/theme';
export default function CompletionScreen({ navigation }) {
@@ -47,6 +48,10 @@ export default function CompletionScreen({ navigation }) {
}
setLoadingStory(false);
Animated.timing(storyFade, { toValue: 1, duration: 600, useNativeDriver: true }).start();
// Track completion
const total = (s?.discipline_score || 0) + (s?.focus_score || 0) + (s?.consistency_score || 0);
trackJourneyCompleted(total, l.length);
};
load();
}, []);
@@ -64,11 +69,12 @@ export default function CompletionScreen({ navigation }) {
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`,
});
trackShare('completion_story');
} catch (_) { /* cancelled */ }
};
const handleNewJourney = () => {
showAlert('New Journey', 'Ready to transform again?', [
showAlert('Ready for another round?', 'Want to start fresh with a new identity?', [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Begin',
@@ -92,15 +98,15 @@ export default function CompletionScreen({ navigation }) {
{/* 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>
<Text style={styles.congrats}>You did it</Text>
<Text style={styles.headerTitle}>40 days. You showed up. You changed.</Text>
</View>
{/* Transformation Story */}
{loadingStory ? (
<View style={styles.storyLoading}>
<ActivityIndicator size="small" color={colors.primary} />
<Text style={styles.storyLoadingText}>Writing your story...</Text>
<Text style={styles.storyLoadingText}>Putting your journey into words...</Text>
</View>
) : story ? (
<Animated.View style={[styles.storyCard, { opacity: storyFade }]}>
@@ -117,13 +123,13 @@ export default function CompletionScreen({ navigation }) {
{/* Identity Card */}
<View style={styles.card}>
<Text style={styles.cardLabel}>YOUR IDENTITY</Text>
<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>
<Text style={styles.cardLabel}>Your final stats</Text>
<View style={styles.statGrid}>
<View style={styles.statItem}>
<Text style={[styles.statValue, { color: colors.primary }]}>
@@ -154,9 +160,9 @@ export default function CompletionScreen({ navigation }) {
{/* Actions */}
<View style={styles.actions}>
<Button title="Share Your Story" onPress={handleShare} style={styles.actionBtn} />
<Button title="Share My Story" onPress={handleShare} style={styles.actionBtn} />
<Button
title="Start New Journey"
title="Begin Again"
variant="secondary"
onPress={handleNewJourney}
style={styles.actionBtn}
+12 -12
View File
@@ -75,7 +75,7 @@ export default function CreateIdentityScreen({ navigation }) {
const handleCreate = async () => {
const trimmedTitle = title.trim();
if (!trimmedTitle) {
showAlert('Missing Identity', 'Please enter who you want to become.');
showAlert('One thing missing', 'Tell us who you want to become.');
return;
}
@@ -84,14 +84,14 @@ export default function CreateIdentityScreen({ navigation }) {
.filter(Boolean);
if (validHabits.length === 0) {
showAlert('No Habits', 'Please add at least 1 habit.');
showAlert('No habits yet', 'Add at least one habit to start.');
return;
}
// Check for duplicates
const unique = [...new Set(validHabits.map((h) => h.toLowerCase()))];
if (unique.length < validHabits.length) {
showAlert('Duplicates', 'Please remove duplicate habits.');
showAlert('Already added', 'Looks like you have a duplicate. Remove it and try again.');
return;
}
@@ -123,21 +123,21 @@ export default function CreateIdentityScreen({ navigation }) {
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>
<Text style={styles.title}>Who do you want to be?</Text>
<Text style={styles.subtitle}>Describe the person you're becoming, then add the habits that'll get you there.</Text>
{/* Identity Input */}
<Input
label="New Identity"
label="Your identity"
value={title}
onChangeText={setTitle}
placeholder="e.g., A disciplined, healthy person"
placeholder="e.g., Someone who shows up every day"
/>
{/* Habit Section */}
<View style={styles.habitSection}>
<View style={styles.habitHeader}>
<Text style={styles.sectionTitle}>Your Habits</Text>
<Text style={styles.sectionTitle}>Your daily habits</Text>
<Text style={styles.habitCounter}>{validCount} / {MAX_HABITS}</Text>
</View>
@@ -160,7 +160,7 @@ export default function CreateIdentityScreen({ navigation }) {
onPress={() => addHabit()}
>
<Text style={styles.addBtnIcon}>+</Text>
<Text style={styles.addBtnText}>Add Habit</Text>
<Text style={styles.addBtnText}>Add another</Text>
</Pressable>
)}
</View>
@@ -168,7 +168,7 @@ export default function CreateIdentityScreen({ navigation }) {
{/* Smart Suggestions */}
{suggestions.length > 0 && (
<View style={styles.suggestionsSection}>
<Text style={styles.suggestionsTitle}>Suggested habits</Text>
<Text style={styles.suggestionsTitle}>Ideas for you</Text>
<View style={styles.suggestionsGrid}>
{suggestions.map((s) => (
<Pressable
@@ -188,7 +188,7 @@ export default function CreateIdentityScreen({ navigation }) {
{/* Submit */}
<View style={styles.submitArea}>
<Button
title="Start 40-Day Journey"
title="Begin My Journey"
onPress={handleCreate}
loading={loading}
disabled={!title.trim() || validCount === 0}
@@ -196,7 +196,7 @@ export default function CreateIdentityScreen({ navigation }) {
/>
<Text style={styles.submitHint}>
{validCount === 0
? 'Add at least 1 habit to begin'
? 'Add at least one habit to get started'
: `${validCount} habit${validCount > 1 ? 's' : ''} ready. Let's go.`}
</Text>
</View>
+308 -354
View File
@@ -3,74 +3,116 @@ import {
View, Text, StyleSheet, Pressable,
TouchableOpacity, Animated, KeyboardAvoidingView, Platform,
} from 'react-native';
import { useFocusEffect } from '@react-navigation/native';
let ViewShot, Sharing, MediaLibrary, Print;
import { useFocusEffect, CommonActions } from '@react-navigation/native';
let ViewShot, Sharing;
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
}
} catch (_) {}
import { showAlert } from '../components/NovaAlert';
import ScreenWrapper from '../components/ScreenWrapper';
import Button from '../components/Button';
import Input from '../components/Input';
import FadeIn from '../components/FadeIn';
import NovaAd from '../components/NovaAd';
import useIdentityStore from '../store/useIdentityStore';
import useHabitStore from '../store/useHabitStore';
import { generateDailyReflection, saveDailyJournal, getJournalEntry } from '../services/journalService';
import { trackDayCompleted, trackJournalSaved } from '../services/analytics';
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' },
{ key: 'great', emoji: '🔥', label: 'Amazing' },
{ key: 'good', emoji: '😊', label: 'Good' },
{ key: 'okay', emoji: '😌', label: 'Okay' },
{ key: 'bad', emoji: '😔', label: 'Tough' },
{ key: 'terrible', emoji: '💀', label: 'Hard' },
];
const MOOD_EMOJIS = { great: '🔥', good: '😊', okay: '😌', bad: '😔', terrible: '💀' };
const MOOD_EMOJIS = { great: '😄', good: '🙂', okay: '😐', bad: '😔', terrible: '😞' };
// Progress ring for day counter
function DayRing({ current, total }) {
const pct = Math.min(current / total, 1);
return (
<View style={ringStyles.container}>
<View style={ringStyles.track} />
<View style={[ringStyles.fill, { width: `${pct * 100}%` }]} />
<View style={ringStyles.center}>
<Text style={ringStyles.day}>{current}</Text>
<Text style={ringStyles.of}>/ {total}</Text>
</View>
</View>
);
}
const ringStyles = StyleSheet.create({
container: { alignSelf: 'center', width: 80, height: 80, borderRadius: 40, backgroundColor: colors.surface, borderWidth: 2, borderColor: colors.border, alignItems: 'center', justifyContent: 'center', marginBottom: spacing.md, overflow: 'hidden' },
track: { ...StyleSheet.absoluteFillObject, backgroundColor: colors.surface },
fill: { position: 'absolute', left: 0, top: 0, bottom: 0, backgroundColor: 'rgba(108, 99, 255, 0.15)' },
center: { alignItems: 'center' },
day: { color: colors.text, fontSize: fonts.sizes.xl, fontWeight: fonts.weights.bold },
of: { color: colors.textMuted, fontSize: fonts.sizes.xs },
});
function HabitItem({ habit, completed, onToggle, disabled }) {
function formatMin(min) {
if (!min || min <= 0) return null;
if (min >= 60) return `${Math.floor(min / 60)}h${min % 60 > 0 ? `${min % 60}m` : ''}`;
return `${min}m`;
}
function HabitItem({ habit, completed, onToggle }) {
const dur = formatMin(habit.duration);
const hasTime = habit.best_time && habit.best_time.length >= 4;
const hasMeta = hasTime || dur;
return (
<TouchableOpacity
style={[styles.habitItem, completed && styles.habitCompleted]}
style={[s.habitItem, completed && s.habitDone]}
onPress={onToggle}
activeOpacity={0.7}
disabled={disabled}
>
<View style={[styles.checkbox, completed && styles.checkboxChecked]}>
{completed && <Text style={styles.checkmark}></Text>}
<View style={[s.habitCheck, completed && s.habitCheckDone]}>
{completed && <Text style={s.habitCheckIcon}>{'\u2713'}</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 style={s.habitBody}>
<Text style={[s.habitText, completed && s.habitTextDone]} numberOfLines={2}>{habit.title}</Text>
{hasMeta && <View style={s.habitSep} />}
{hasMeta && (
<View style={s.habitMeta}>
{hasTime && (
<View style={s.habitMetaItem}>
<View style={[s.habitMetaDot, { backgroundColor: colors.accent }]} />
<Text style={s.habitMetaLabel}>{habit.best_time}</Text>
</View>
)}
{dur && (
<View style={s.habitMetaItem}>
<View style={[s.habitMetaDot, { backgroundColor: colors.primary }]} />
<Text style={s.habitMetaLabel}>{dur}</Text>
</View>
)}
</View>
)}
</View>
{completed && <Text style={s.habitDoneTag}>Done</Text>}
</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);
export default function DailyScreen({ navigation }) {
const identity = useIdentityStore((st) => st.identity);
const currentDay = useIdentityStore((st) => st.currentDay);
const fetchIdentity = useIdentityStore((st) => st.fetchIdentity);
const habits = useHabitStore((st) => st.habits);
const habitLogs = useHabitStore((st) => st.habitLogs);
const toggleHabit = useHabitStore((st) => st.toggleHabit);
const loadTodayLogs = useHabitStore((st) => st.loadTodayLogs);
const [initialLoading, setInitialLoading] = useState(true);
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 [focusCount, setFocusCount] = useState(0);
const fadeAnim = useRef(new Animated.Value(0)).current;
const viewShotRef = useRef(null);
@@ -78,33 +120,24 @@ export default function DailyScreen() {
useFocusEffect(
useCallback(() => {
const init = async () => {
await fetchIdentity();
const id = useIdentityStore.getState().identity;
const day = useIdentityStore.getState().currentDay;
if (!id) { setInitialLoading(false); navigation.dispatch(CommonActions.reset({ index: 0, routes: [{ name: 'IdentityStory' }] })); return; }
if (day > 40) { navigation.dispatch(CommonActions.reset({ index: 0, routes: [{ name: 'Completion' }] })); return; }
setInitialLoading(false);
setFocusCount((c) => c + 1);
loadTodayLogs();
// Check if today is already completed
if (identity?.id) {
const existing = await getJournalEntry(identity.id);
if (id?.id) {
const existing = await getJournalEntry(id.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 || '');
setCompleted(true); setSavedEntry(existing);
setIdentityCheck(existing.identity_check || ''); setMood(existing.mood || '');
} else {
setCompleted(false);
setSavedEntry(null);
setIdentityCheck('');
setMood('');
setWin('');
setStruggle('');
setHighlight('');
setNote('');
setCompleted(false); setSavedEntry(null);
setIdentityCheck(''); setMood('');
}
}
Animated.timing(fadeAnim, { toValue: 1, duration: 600, useNativeDriver: true }).start();
};
init();
@@ -112,355 +145,276 @@ export default function DailyScreen() {
);
const habitsCompleted = Object.values(habitLogs).filter(Boolean).length;
const habitsTotal = habits.length;
const allHabitsDone = habitsTotal > 0 && habitsCompleted === habitsTotal;
const habitsPct = habitsTotal > 0 ? Math.round((habitsCompleted / habitsTotal) * 100) : 0;
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;
}
if (!identityCheck) { showAlert('Hold on', 'How did you show up as your new self today?'); return; }
if (!mood) { showAlert('One more thing', 'How are you feeling right now?'); return; }
setSaving(true);
try {
const ai = await generateDailyReflection({ mood, win, struggle, highlight, note });
const ai = await generateDailyReflection({ mood });
const saved = await saveDailyJournal(identity.id, currentDay, {
identityCheck,
habitsCompleted,
mood, win, struggle, highlight, note,
aiTitle: ai.title,
aiSummary: ai.summary,
aiQuote: ai.quote,
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 });
trackDayCompleted(currentDay, identityCheck, mood);
trackJournalSaved(currentDay);
} catch (error) {
console.warn('DailyScreen save error:', error);
showAlert('Error', error?.message || 'Failed to save. Try again.');
} finally {
setSaving(false);
}
showAlert('Oops', error?.message || 'Something went wrong. Give it another try.');
} 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 handleShare = async () => { try { const uri = await viewShotRef.current.capture(); await Sharing.shareAsync(uri, { mimeType: 'image/png' }); } catch (_) { showAlert('Error', 'Could not share.'); } };
const narrative = getDayNarrative(currentDay);
if (!identity) {
if (!identity || initialLoading) {
return (<ScreenWrapper><View style={s.loadingWrap}><Text style={s.loadingText}>Loading your journey...</Text></View></ScreenWrapper>);
}
// ====== COMPLETED STATE ======
if (completed && savedEntry?.ai_title) {
const JournalCard = () => (
<View style={s.journalCard}>
<Text style={s.jBrand}>NOVA40</Text>
<Text style={s.jEmoji}>{MOOD_EMOJIS[savedEntry.mood] || '\u2728'}</Text>
<Text style={s.jTitle}>{savedEntry.ai_title}</Text>
<Text style={s.jSummary}>{savedEntry.ai_summary}</Text>
<View style={s.jDivider} />
<Text style={s.jQuote}>"{savedEntry.ai_quote}"</Text>
<View style={s.jFooter}>
<Text style={s.jDay}>Day {currentDay}</Text>
<Text style={s.jDate}>{new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}</Text>
</View>
{identity?.title && <Text style={s.jIdentity}>{identity.title}</Text>}
</View>
);
return (
<ScreenWrapper>
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>Loading...</Text>
</View>
<Animated.ScrollView style={[s.flex, { opacity: fadeAnim }]} contentContainerStyle={s.scrollContent} showsVerticalScrollIndicator={false}>
{/* Success header */}
<View style={s.doneHeader}>
<Text style={s.doneEmoji}>{'\u2728'}</Text>
<Text style={s.doneTitle}>Day {currentDay} Complete!</Text>
<Text style={s.doneSubtitle}>You showed up. That matters.</Text>
</View>
{ViewShot ? (<ViewShot ref={viewShotRef} options={{ format: 'png', quality: 1 }}><JournalCard /></ViewShot>) : (<JournalCard />)}
<View style={s.actions}>
<Button title="Share This" onPress={handleShare} style={s.actionBtn} />
</View>
{/* Entries removed */}
</Animated.ScrollView>
</ScreenWrapper>
);
}
// ====== FORM STATE ======
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>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={s.flex}>
<Animated.ScrollView style={[s.flex, { opacity: fadeAnim }]} contentContainerStyle={s.scrollContent} showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled">
{/* === COMPLETED STATE: show journal card + actions === */}
{completed && savedEntry?.ai_title ? (
<>
{/* Completion badge */}
<View style={styles.completedBadge}>
<Text style={styles.completedBadgeText}> Day {currentDay} Complete</Text>
{/* Hero section */}
<FadeIn delay={0} trigger={focusCount}><View style={s.hero}>
<DayRing current={currentDay} total={40} />
<Text style={s.heroGreet}>
{currentDay <= 3 ? "Let's build momentum!" : currentDay <= 20 ? 'Keep pushing forward!' : currentDay <= 35 ? "You're in the zone!" : 'The finish line is close!'}
</Text>
<Text style={s.heroNarrative}>{narrative}</Text>
</View></FadeIn>
{/* ── STEP 1: Habits ── */}
<FadeIn delay={100} trigger={focusCount}><View style={s.section}>
<View style={s.sectionHeader}>
<Text style={s.sectionIcon}>{allHabitsDone ? '🔥' : '📋'}</Text>
<View style={s.sectionHeaderText}>
<Text style={s.sectionTitle}>Your habits for today</Text>
<Text style={s.sectionMeta}>{habitsCompleted}/{habitsTotal} done</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>
{/* Percentage badge */}
<View style={[s.pctBadge, allHabitsDone && s.pctBadgeDone]}>
<Text style={[s.pctText, allHabitsDone && s.pctTextDone]}>{habitsPct}%</Text>
</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}
{/* Progress bar */}
<View style={s.progressBar}>
<Animated.View style={[s.progressFill, { width: `${habitsPct}%` }]} />
</View>
{habits.map((habit, i) => (
<FadeIn key={habit.id} delay={150 + i * 80} trigger={focusCount}>
<HabitItem habit={habit} completed={!!habitLogs[habit.id]} onToggle={() => toggleHabit(habit.id)} />
</FadeIn>
))}
{allHabitsDone && (
<View style={s.allDoneBanner}>
<Text style={s.allDoneEmoji}>🎉</Text>
<Text style={s.allDoneText}>All habits done! You're crushing it today!</Text>
</View>
</>
) : (
<>
{/* === FORM STATE: habits + journal inputs === */}
)}
</View></FadeIn>
{/* 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>
<NovaAd />
{/* ── STEP 2: Identity Check ── */}
<FadeIn delay={200} trigger={focusCount}><View style={s.section}>
<View style={s.sectionHeader}>
<Text style={s.sectionIcon}>🪞</Text>
<View>
<Text style={s.sectionTitle}>How did you show up?</Text>
<Text style={s.sectionMeta}>Did you live as "{identity?.title}" today?</Text>
</View>
</View>
<View style={s.identityRow}>
{[
{ key: 'yes', emoji: '', label: 'Yes!', color: colors.success },
{ key: 'almost', emoji: '🔶', label: 'Almost', color: colors.warning },
{ key: 'no', emoji: '', label: 'Not yet', color: colors.error },
].map((opt) => (
<Pressable
key={opt.key}
style={[s.identityBtn, identityCheck === opt.key && { borderColor: opt.color, backgroundColor: `${opt.color}12` }]}
onPress={() => setIdentityCheck(opt.key)}
>
<Text style={s.identityEmoji}>{opt.emoji}</Text>
<Text style={[s.identityLabel, identityCheck === opt.key && { color: opt.color, fontWeight: fonts.weights.bold }]}>{opt.label}</Text>
</Pressable>
))}
</View>
</View></FadeIn>
{/* 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>
{/* ── STEP 3: Mood ── */}
<FadeIn delay={300} trigger={focusCount}><View style={s.section}>
<View style={s.sectionHeader}>
<Text style={s.sectionIcon}>💭</Text>
<Text style={s.sectionTitle}>How's your mood?</Text>
</View>
<View style={s.moodRow}>
{MOODS.map((m) => (
<Pressable key={m.key} style={[s.moodBtn, mood === m.key && s.moodBtnActive]} onPress={() => setMood(m.key)}>
<Text style={s.moodEmoji}>{m.emoji}</Text>
<Text style={[s.moodLabel, mood === m.key && s.moodLabelActive]}>{m.label}</Text>
</Pressable>
))}
</View>
</View></FadeIn>
{/* 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}
/>
</>
{/* ── Submit ── */}
<FadeIn delay={400} trigger={focusCount}>
<Button
title={saving ? 'Writing your reflection...' : allHabitsDone ? "Finish My Day — You crushed it! 🔥" : 'Finish My Day'}
onPress={handleCompleteDay}
loading={saving}
disabled={!identityCheck || !mood}
style={s.submitBtn}
/>
{(!identityCheck || !mood) && (
<Text style={s.submitHint}>
{!identityCheck ? 'Select your identity check above' : 'Select your mood above'}
</Text>
)}
</FadeIn>
</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({
const s = StyleSheet.create({
flex: { flex: 1 },
loadingContainer: { flex: 1, alignItems: 'center', justifyContent: 'center' },
loadingWrap: { 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 },
scrollContent: { padding: spacing.md, paddingBottom: spacing.xxl * 2 },
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 },
// Hero
hero: { alignItems: 'center', marginBottom: spacing.md, paddingTop: spacing.xs },
heroGreet: { color: colors.accent, fontSize: fonts.sizes.xs, fontWeight: fonts.weights.semibold, letterSpacing: 1, marginBottom: spacing.xs },
heroNarrative: { color: colors.text, fontSize: fonts.sizes.md, fontWeight: fonts.weights.semibold, textAlign: 'center', lineHeight: 22 },
// Sections
section: { marginBottom: spacing.md },
sectionHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: spacing.sm, gap: spacing.sm },
sectionIcon: { fontSize: 18 },
sectionTitle: { color: colors.text, fontSize: fonts.sizes.sm, fontWeight: fonts.weights.bold },
sectionMeta: { color: colors.textMuted, fontSize: 10, marginTop: 1 },
sectionHeaderText: { flex: 1 },
pctBadge: { backgroundColor: colors.surface, borderRadius: borderRadius.full, paddingHorizontal: spacing.md, paddingVertical: spacing.xs, borderWidth: 1, borderColor: colors.border },
pctBadgeDone: { backgroundColor: 'rgba(0,230,118,0.1)', borderColor: 'rgba(0,230,118,0.3)' },
pctText: { color: colors.textSecondary, fontSize: fonts.sizes.sm, fontWeight: fonts.weights.bold },
pctTextDone: { color: colors.success },
// Progress bar
progressBar: { height: 3, backgroundColor: colors.surface, borderRadius: 2, marginBottom: spacing.sm, overflow: 'hidden' },
progressFill: { height: '100%', backgroundColor: colors.primary, borderRadius: 2 },
// 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 },
habitItem: { flexDirection: 'row', alignItems: 'flex-start', backgroundColor: colors.surface, borderRadius: borderRadius.sm, paddingVertical: spacing.sm, paddingHorizontal: spacing.md, marginBottom: 6, borderWidth: 1, borderColor: colors.border },
habitDone: { borderColor: 'rgba(0,230,118,0.3)', backgroundColor: 'rgba(0,230,118,0.04)' },
habitCheck: { width: 22, height: 22, borderRadius: 11, borderWidth: 2, borderColor: colors.textMuted, alignItems: 'center', justifyContent: 'center', marginRight: spacing.sm, marginTop: 2 },
habitCheckDone: { borderColor: colors.success, backgroundColor: colors.success },
habitCheckIcon: { color: '#fff', fontSize: 11, fontWeight: fonts.weights.bold },
habitBody: { flex: 1 },
habitText: { color: colors.text, fontSize: fonts.sizes.sm },
habitTextDone: { color: colors.textSecondary, textDecorationLine: 'line-through' },
habitSep: { height: 1, backgroundColor: colors.border, marginVertical: 5 },
habitMeta: { flexDirection: 'row', gap: spacing.lg },
habitMetaItem: { flexDirection: 'row', alignItems: 'center' },
habitMetaDot: { width: 5, height: 5, borderRadius: 2.5, marginRight: 5 },
habitMetaLabel: { color: colors.textSecondary, fontSize: 11 },
habitDoneTag: { color: colors.success, fontSize: 10, fontWeight: fonts.weights.bold, marginLeft: spacing.sm, marginTop: 3 },
allDoneBanner: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(0,230,118,0.08)', borderRadius: borderRadius.sm, padding: spacing.sm, marginTop: 4, borderWidth: 1, borderColor: 'rgba(0,230,118,0.2)' },
allDoneEmoji: { fontSize: 14, marginRight: spacing.sm },
allDoneText: { color: colors.success, fontSize: fonts.sizes.xs, fontWeight: fonts.weights.semibold },
// 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 },
// Identity
identityRow: { flexDirection: 'row', gap: 6 },
identityBtn: { flex: 1, alignItems: 'center', paddingVertical: spacing.sm, borderRadius: borderRadius.md, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.surface },
identityEmoji: { fontSize: 18, marginBottom: 2 },
identityLabel: { color: colors.textSecondary, fontSize: 10, 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 },
moodRow: { flexDirection: 'row', justifyContent: 'space-between', gap: 4 },
moodBtn: { alignItems: 'center', paddingVertical: spacing.sm, borderRadius: borderRadius.md, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.surface, flex: 1 },
moodBtnActive: { borderColor: colors.primary, backgroundColor: 'rgba(108,99,255,0.12)' },
moodEmoji: { fontSize: 20, marginBottom: 2 },
moodLabel: { color: colors.textMuted, fontSize: 8, fontWeight: fonts.weights.medium },
moodLabelActive: { color: colors.primary, fontWeight: fonts.weights.bold },
completeBtn: { marginTop: spacing.md },
// Submit
submitBtn: { marginTop: spacing.sm },
submitHint: { color: colors.textMuted, fontSize: fonts.sizes.xs, textAlign: 'center', marginTop: spacing.sm },
// === 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,
},
// ====== Completed state ======
doneHeader: { alignItems: 'center', paddingTop: spacing.lg, marginBottom: spacing.xl },
doneEmoji: { fontSize: 48, marginBottom: spacing.md },
doneTitle: { color: colors.text, fontSize: fonts.sizes.xxl, fontWeight: fonts.weights.bold, marginBottom: spacing.xs },
doneSubtitle: { color: colors.textSecondary, fontSize: fonts.sizes.md },
// 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' },
journalCard: { backgroundColor: colors.background, borderRadius: borderRadius.xl, padding: spacing.xl, borderWidth: 1, borderColor: 'rgba(108,99,255,0.25)', alignItems: 'center' },
jBrand: { color: colors.primary, fontSize: fonts.sizes.xs, fontWeight: fonts.weights.bold, letterSpacing: 5, marginBottom: spacing.lg },
jEmoji: { fontSize: 36, marginBottom: spacing.md },
jTitle: { color: colors.text, fontSize: fonts.sizes.xl, fontWeight: fonts.weights.bold, textAlign: 'center', lineHeight: 30, marginBottom: spacing.md },
jSummary: { color: colors.textSecondary, fontSize: fonts.sizes.md, textAlign: 'center', lineHeight: 24, marginBottom: spacing.lg, paddingHorizontal: spacing.sm },
jDivider: { width: 40, height: 2, backgroundColor: colors.primary, borderRadius: 1, marginBottom: spacing.lg },
jQuote: { color: colors.accent, fontSize: fonts.sizes.md, fontStyle: 'italic', textAlign: 'center', lineHeight: 24, marginBottom: spacing.lg },
jFooter: { flexDirection: 'row', justifyContent: 'center', gap: spacing.md, marginBottom: spacing.sm },
jDay: { color: colors.textMuted, fontSize: fonts.sizes.sm, fontWeight: fonts.weights.semibold },
jDate: { color: colors.textMuted, fontSize: fonts.sizes.sm },
jIdentity: { 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 },
});
+296
View File
@@ -0,0 +1,296 @@
import React, { useState, useEffect, useRef } from 'react';
import {
View, Text, StyleSheet, Image, Pressable, Animated,
KeyboardAvoidingView, Platform, ScrollView,
} from 'react-native';
import * as ImagePicker from 'expo-image-picker';
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 { getProfile, saveProfile } from '../services/profileService';
import { colors, fonts, spacing, borderRadius } from '../utils/theme';
export default function EditProfileScreen({ navigation }) {
const user = useAuthStore((s) => s.user);
const [nickname, setNickname] = useState('');
const [fullName, setFullName] = useState('');
const [photoUri, setPhotoUri] = useState(null);
const [loading, setLoading] = useState(false);
const [initialLoading, setInitialLoading] = useState(true);
const fadeAnim = useRef(new Animated.Value(0)).current;
useEffect(() => {
const load = async () => {
if (user?.id) {
const profile = await getProfile(user.id);
if (profile) {
setNickname(profile.nickname || '');
setFullName(profile.fullName || '');
setPhotoUri(profile.photoUri || null);
}
}
setInitialLoading(false);
Animated.timing(fadeAnim, { toValue: 1, duration: 500, useNativeDriver: true }).start();
};
load();
}, []);
const pickFromGallery = async () => {
try {
const result = await ImagePicker.launchImageLibraryAsync({
allowsEditing: true,
aspect: [1, 1],
quality: 0.5,
exif: false,
});
if (!result.canceled && result.assets?.[0]?.uri) {
setPhotoUri(result.assets[0].uri);
}
} catch (_) {}
};
const takePhoto = async () => {
try {
const result = await ImagePicker.launchCameraAsync({
allowsEditing: true,
aspect: [1, 1],
quality: 0.5,
exif: false,
});
if (!result.canceled && result.assets?.[0]?.uri) {
setPhotoUri(result.assets[0].uri);
}
} catch (_) {}
};
const handlePickPhoto = () => {
pickFromGallery();
};
const handleSave = async () => {
if (!nickname.trim() && !fullName.trim()) {
showAlert('Almost there', 'Fill in at least your nickname or full name.');
return;
}
setLoading(true);
try {
await saveProfile(user.id, {
nickname: nickname.trim(),
fullName: fullName.trim(),
photoUri,
});
showAlert('Saved', 'Your profile has been updated.', [
{ text: 'OK', onPress: () => navigation.goBack() },
]);
} catch (e) {
showAlert('Oops', e.message || 'Could not save your profile.');
} finally {
setLoading(false);
}
};
const displayInitial = nickname?.[0] || fullName?.[0] || user?.email?.[0] || 'N';
if (initialLoading) return <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.title}>Edit Profile</Text>
<Text style={styles.subtitle}>Make it yours this is how we'll greet you.</Text>
{/* Photo */}
<Pressable style={styles.photoSection} onPress={handlePickPhoto}>
{photoUri ? (
<Image source={{ uri: photoUri }} style={styles.photo} />
) : (
<View style={styles.photoPlaceholder}>
<Text style={styles.photoInitial}>{displayInitial.toUpperCase()}</Text>
</View>
)}
</Pressable>
<View style={styles.photoActions}>
<Pressable style={({ pressed }) => [styles.photoBtn, pressed && { opacity: 0.6 }]} onPress={pickFromGallery}>
<Text style={styles.photoBtnText}>Gallery</Text>
</Pressable>
<Pressable style={({ pressed }) => [styles.photoBtn, pressed && { opacity: 0.6 }]} onPress={takePhoto}>
<Text style={styles.photoBtnText}>Camera</Text>
</Pressable>
</View>
{/* Fields */}
<View style={styles.form}>
<Input
label="Nickname"
value={nickname}
onChangeText={setNickname}
placeholder="What should we call you?"
/>
<Input
label="Full name"
value={fullName}
onChangeText={setFullName}
placeholder="Your real name (optional)"
autoCapitalize="words"
/>
{/* Email (read-only) */}
<View style={styles.readonlyField}>
<Text style={styles.readonlyLabel}>Email</Text>
<Text style={styles.readonlyValue}>{user?.email || ''}</Text>
</View>
</View>
<Button
title={loading ? 'Saving...' : 'Save Profile'}
onPress={handleSave}
loading={loading}
style={styles.saveBtn}
/>
<Button
title="Cancel"
onPress={() => navigation.goBack()}
variant="secondary"
style={styles.cancelBtn}
/>
</Animated.ScrollView>
</KeyboardAvoidingView>
</ScreenWrapper>
);
}
const styles = StyleSheet.create({
flex: { flex: 1 },
scrollContent: {
paddingHorizontal: spacing.lg,
paddingTop: spacing.xxl,
paddingBottom: spacing.xxl * 2,
alignItems: 'center',
},
title: {
color: colors.text,
fontSize: fonts.sizes.xxl,
fontWeight: fonts.weights.bold,
marginBottom: spacing.xs,
alignSelf: 'flex-start',
},
subtitle: {
color: colors.textSecondary,
fontSize: fonts.sizes.md,
marginBottom: spacing.xl,
alignSelf: 'flex-start',
},
// Photo
photoSection: {
position: 'relative',
marginBottom: spacing.sm,
},
photo: {
width: 100,
height: 100,
borderRadius: 50,
borderWidth: 3,
borderColor: colors.primary,
},
photoPlaceholder: {
width: 100,
height: 100,
borderRadius: 50,
backgroundColor: colors.primary,
alignItems: 'center',
justifyContent: 'center',
shadowColor: colors.primary,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.4,
shadowRadius: 12,
elevation: 6,
},
photoInitial: {
color: colors.text,
fontSize: fonts.sizes.hero,
fontWeight: fonts.weights.bold,
},
photoBadge: {
position: 'absolute',
bottom: 0,
right: 0,
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: colors.surface,
borderWidth: 2,
borderColor: colors.border,
alignItems: 'center',
justifyContent: 'center',
},
photoBadgeText: {
fontSize: 14,
},
photoActions: {
flexDirection: 'row',
gap: spacing.md,
marginTop: spacing.sm,
marginBottom: spacing.xl,
},
photoBtn: {
paddingVertical: spacing.xs + 2,
paddingHorizontal: spacing.lg,
borderRadius: borderRadius.full,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.surface,
},
photoBtnText: {
color: colors.textSecondary,
fontSize: fonts.sizes.xs,
fontWeight: fonts.weights.medium,
},
// Form
form: {
width: '100%',
marginBottom: spacing.lg,
},
readonlyField: {
backgroundColor: colors.surface,
borderRadius: borderRadius.md,
padding: spacing.md,
borderWidth: 1,
borderColor: colors.border,
marginBottom: spacing.md,
},
readonlyLabel: {
color: colors.textMuted,
fontSize: fonts.sizes.xs,
fontWeight: fonts.weights.medium,
marginBottom: spacing.xs,
},
readonlyValue: {
color: colors.textSecondary,
fontSize: fonts.sizes.md,
},
saveBtn: {
width: '100%',
marginBottom: spacing.sm,
},
cancelBtn: {
width: '100%',
},
});
+195
View File
@@ -0,0 +1,195 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, 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 ForgotPasswordScreen({ navigation }) {
const [step, setStep] = useState(1); // 1 = email, 2 = new password
const [email, setEmail] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const resetPassword = useAuthStore((s) => s.resetPassword);
const handleCheckEmail = () => {
if (!email.trim()) {
showAlert('Oops', 'Enter the email you signed up with.');
return;
}
setStep(2);
};
const handleReset = async () => {
if (!newPassword.trim()) {
showAlert('Oops', 'Enter your new password.');
return;
}
if (newPassword.length < 6) {
showAlert('Too short', 'Your password needs at least 6 characters.');
return;
}
if (newPassword !== confirmPassword) {
showAlert('Oops', "Those passwords don't match.");
return;
}
setLoading(true);
try {
const result = await resetPassword(email.trim(), newPassword);
if (result.method === 'email') {
showAlert(
'Check your email',
"We sent you a link to reset your password. Open it and you're good to go.",
[{ text: 'OK', onPress: () => navigation.goBack() }]
);
} else {
showAlert(
'Password updated',
'Your password has been changed. You can sign in now.',
[{ text: 'Sign In', onPress: () => navigation.goBack() }]
);
}
} catch (e) {
showAlert('Something went wrong', e.message);
} finally {
setLoading(false);
}
};
return (
<ScreenWrapper>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.container}
>
{/* Header */}
<View style={styles.header}>
<Text style={styles.title}>
{step === 1 ? 'Forgot your password?' : 'Create a new password'}
</Text>
<Text style={styles.subtitle}>
{step === 1
? "No worries — it happens to everyone. Enter your email and we'll help you get back in."
: 'Pick something you\'ll remember this time.'}
</Text>
</View>
{/* Step 1: Email */}
{step === 1 && (
<View style={styles.form}>
<Input
label="Your email"
value={email}
onChangeText={setEmail}
placeholder="The one you signed up with"
keyboardType="email-address"
/>
<Button
title="Continue"
onPress={handleCheckEmail}
disabled={!email.trim()}
style={styles.btn}
/>
</View>
)}
{/* Step 2: New password */}
{step === 2 && (
<View style={styles.form}>
<View style={styles.emailConfirm}>
<Text style={styles.emailLabel}>Resetting password for</Text>
<Text style={styles.emailValue}>{email}</Text>
</View>
<Input
label="New password"
value={newPassword}
onChangeText={setNewPassword}
placeholder="Make it strong (6+ characters)"
secureTextEntry={!showPassword}
rightIcon={showPassword ? '◉' : '◎'}
onRightIconPress={() => setShowPassword((p) => !p)}
/>
<Input
label="Confirm new password"
value={confirmPassword}
onChangeText={setConfirmPassword}
placeholder="One more time"
secureTextEntry={!showPassword}
/>
<Button
title={loading ? 'Updating...' : 'Reset My Password'}
onPress={handleReset}
loading={loading}
disabled={!newPassword.trim() || !confirmPassword.trim()}
style={styles.btn}
/>
</View>
)}
{/* Back */}
<Button
title="Back to sign in"
onPress={() => navigation.goBack()}
variant="secondary"
style={styles.backBtn}
/>
</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.sm,
},
subtitle: {
color: colors.textSecondary,
fontSize: fonts.sizes.md,
lineHeight: 22,
},
form: {
marginBottom: spacing.lg,
},
emailConfirm: {
backgroundColor: colors.surface,
borderRadius: 12,
padding: spacing.md,
borderWidth: 1,
borderColor: colors.border,
marginBottom: spacing.lg,
},
emailLabel: {
color: colors.textMuted,
fontSize: fonts.sizes.xs,
marginBottom: spacing.xs,
},
emailValue: {
color: colors.text,
fontSize: fonts.sizes.md,
fontWeight: fonts.weights.semibold,
},
btn: {
marginTop: spacing.sm,
},
backBtn: {
marginTop: spacing.sm,
},
});
+228 -106
View File
@@ -9,19 +9,69 @@ import Button from '../components/Button';
import useIdentityStore from '../store/useIdentityStore';
import { colors, fonts, spacing, borderRadius } from '../utils/theme';
const MAX_HABITS = 5;
const CAT_ICONS = { Mind: '🧠', Body: '💪', Emotion: '❤️', Social: '👥', Skill: '⚡', Discipline: '🎯', Health: '🌿', Creative: '🎨' };
function HabitChip({ text, selected, onToggle, disabled }) {
function formatDuration(min) {
if (!min || min <= 0) return null;
if (min >= 60) return `${Math.floor(min / 60)}h${min % 60 > 0 ? ` ${min % 60}m` : ''}`;
return `${min} min`;
}
function HabitChip({ habit, selected, onToggle, isPriority }) {
const dur = formatDuration(habit.duration);
const hasTime = habit.best_time && habit.best_time.length >= 4 && habit.best_time !== '--';
const hasMeta = hasTime || dur;
return (
<Pressable
style={[styles.chip, selected && styles.chipSelected, disabled && !selected && styles.chipDisabled]}
style={[styles.chip, selected && styles.chipSelected]}
onPress={onToggle}
disabled={disabled && !selected}
>
<Text style={[styles.chipCheck, selected && styles.chipCheckSelected]}>
{selected ? '✓' : '+'}
</Text>
<Text style={[styles.chipText, selected && styles.chipTextSelected]}>{text}</Text>
{/* Checkbox */}
<View style={styles.chipLeft}>
<Text style={[styles.chipCheck, selected && styles.chipCheckSelected]}>
{selected ? '\u2713' : '+'}
</Text>
{isPriority && selected && (
<View style={styles.priorityBadge}>
<Text style={styles.priorityBadgeText}>{'\u2605'}</Text>
</View>
)}
</View>
{/* Content */}
<View style={styles.chipBody}>
{/* Title */}
<Text style={[styles.chipText, selected && styles.chipTextSelected]}>{habit.title}</Text>
{/* Description / Why */}
{habit.why ? <Text style={styles.chipWhy}>{habit.why}</Text> : null}
{/* ---- Separator ---- */}
{hasMeta && <View style={styles.chipSeparator} />}
{/* Time & Duration */}
{hasMeta && (
<View style={styles.chipTimeRow}>
{hasTime && (
<View style={styles.chipTimeItem}>
<Text style={styles.chipTimeIcon}>{'\u{1F554}'}</Text>
<Text style={styles.chipTimeLabel}>{habit.best_time}</Text>
</View>
)}
{dur && (
<View style={styles.chipTimeItem}>
<Text style={styles.chipTimeIcon}>{'\u23F1'}</Text>
<Text style={styles.chipTimeLabel}>{dur}</Text>
</View>
)}
</View>
)}
</View>
{/* Category icon */}
{habit.category && (
<Text style={styles.catBadge}>{CAT_ICONS[habit.category] || ''}</Text>
)}
</Pressable>
);
}
@@ -30,7 +80,13 @@ export default function HabitSelectionScreen({ navigation, route }) {
const { aiResult, story } = route.params;
const createIdentity = useIdentityStore((s) => s.createIdentity);
const [selectedHabits, setSelectedHabits] = useState([]);
const priorityHabits = aiResult.priority_habits || [];
const additionalHabits = aiResult.suggested_habits || [];
// Pre-select the 5 priority habit titles
const [selectedTitles, setSelectedTitles] = useState(
() => priorityHabits.map((h) => h.title)
);
const [customHabit, setCustomHabit] = useState('');
const [customHabits, setCustomHabits] = useState([]);
const [editingTitle, setEditingTitle] = useState(false);
@@ -43,26 +99,23 @@ export default function HabitSelectionScreen({ navigation, route }) {
Animated.timing(fadeAnim, { toValue: 1, duration: 500, useNativeDriver: true }).start();
}, []);
const allSelected = [...selectedHabits, ...customHabits];
const atMax = allSelected.length >= MAX_HABITS;
const allSelectedCount = selectedTitles.length + customHabits.length;
const allHabitsMap = [...priorityHabits, ...additionalHabits];
const toggleHabit = (habit) => {
setSelectedHabits((prev) =>
prev.includes(habit)
? prev.filter((h) => h !== habit)
: atMax ? prev : [...prev, habit]
const toggleHabit = (habitTitle) => {
setSelectedTitles((prev) =>
prev.includes(habitTitle)
? prev.filter((t) => t !== habitTitle)
: [...prev, habitTitle]
);
};
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.');
const allTitles = [...selectedTitles, ...customHabits];
if (allTitles.some((t) => t.toLowerCase() === trimmed.toLowerCase())) {
showAlert('Already added', "You've already got that one.");
return;
}
setCustomHabits((prev) => [...prev, trimmed]);
@@ -74,14 +127,21 @@ export default function HabitSelectionScreen({ navigation, route }) {
};
const handleStart = async () => {
if (allSelected.length === 0) {
showAlert('No Habits', 'Please select at least 1 habit.');
if (allSelectedCount === 0) {
showAlert('No habits yet', 'Choose at least one habit to begin your journey.');
return;
}
setLoading(true);
try {
const habits = allSelected.map((h) => ({ title: h }));
// Build habits array — rich objects for AI habits, simple for custom
const selectedRich = selectedTitles.map((t) => {
const found = allHabitsMap.find((h) => h.title === t);
return found || { title: t };
});
const customRich = customHabits.map((t) => ({ title: t }));
const habits = [...selectedRich, ...customRich];
const identityTitle = title.trim() || aiResult.identity_title;
const description = aiResult.identity_summary || '';
await createIdentity(identityTitle, description, habits, story || '');
@@ -110,7 +170,7 @@ export default function HabitSelectionScreen({ navigation, route }) {
{/* Identity Title */}
<View style={styles.identityCard}>
<Text style={styles.identityLabel}>YOUR NEW IDENTITY</Text>
<Text style={styles.identityLabel}>Your new identity</Text>
{editingTitle ? (
<TextInput
style={styles.titleInput}
@@ -123,7 +183,7 @@ export default function HabitSelectionScreen({ navigation, route }) {
) : (
<Pressable onPress={() => setEditingTitle(true)}>
<Text style={styles.identityTitle}>{title}</Text>
<Text style={styles.editHint}>Tap to edit</Text>
<Text style={styles.editHint}>Tap to change this</Text>
</Pressable>
)}
</View>
@@ -131,76 +191,95 @@ export default function HabitSelectionScreen({ navigation, route }) {
{/* 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>
{/* Priority Habits (pre-selected) */}
{priorityHabits.length > 0 && (
<View style={styles.habitSection}>
<View style={styles.habitHeader}>
<Text style={styles.sectionTitle}> Top Priority</Text>
<Text style={styles.counter}>{allSelectedCount} selected</Text>
</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"
{priorityHabits.map((habit) => (
<HabitChip
key={habit.title}
habit={habit}
selected={selectedTitles.includes(habit.title)}
onToggle={() => toggleHabit(habit.title)}
isPriority
/>
<Pressable
style={[styles.addBtn, !customHabit.trim() && styles.addBtnDisabled]}
onPress={addCustomHabit}
disabled={!customHabit.trim()}
>
<Text style={styles.addBtnText}>+</Text>
</Pressable>
</View>
)}
))}
</View>
)}
{/* Additional Habits */}
{additionalHabits.length > 0 && (
<View style={styles.habitSection}>
<Text style={styles.sectionTitle}>More Recommendations</Text>
{additionalHabits.map((habit) => (
<HabitChip
key={habit.title}
habit={habit}
selected={selectedTitles.includes(habit.title)}
onToggle={() => toggleHabit(habit.title)}
isPriority={false}
/>
))}
</View>
)}
{/* Custom habits */}
{customHabits.length > 0 && (
<View style={styles.habitSection}>
<Text style={styles.sectionTitle}>Your Custom Habits</Text>
{customHabits.map((habit) => (
<View key={habit} style={styles.customChipRow}>
<View style={[styles.chip, styles.chipSelected, styles.customChip]}>
<Text style={[styles.chipCheck, styles.chipCheckSelected]}></Text>
<View style={styles.chipBody}>
<Text style={[styles.chipText, styles.chipTextSelected]}>{habit}</Text>
</View>
</View>
<Pressable onPress={() => removeCustom(habit)} style={styles.removeBtn}>
<Text style={styles.removeBtnText}></Text>
</Pressable>
</View>
))}
</View>
)}
{/* Add custom */}
<View style={styles.addRow}>
<TextInput
style={styles.addInput}
value={customHabit}
onChangeText={setCustomHabit}
placeholder="Or add something personal..."
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>
{/* Start button */}
<Button
title="Start 40-Day Journey"
title="Begin My Journey"
onPress={handleStart}
loading={loading}
disabled={allSelected.length === 0}
disabled={allSelectedCount === 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.`}
{allSelectedCount === 0
? 'Pick at least one habit to get started'
: `${allSelectedCount} habit${allSelectedCount > 1 ? 's' : ''} selected. Ready to transform.`}
</Text>
</Animated.ScrollView>
</ScreenWrapper>
@@ -212,10 +291,9 @@ const styles = StyleSheet.create({
scrollContent: {
paddingHorizontal: spacing.lg,
paddingTop: spacing.xl,
paddingBottom: spacing.xxl,
paddingBottom: spacing.xxl * 2,
},
// Source badge
sourceBadge: {
alignSelf: 'flex-start',
backgroundColor: 'rgba(108, 99, 255, 0.08)',
@@ -232,7 +310,6 @@ const styles = StyleSheet.create({
fontWeight: fonts.weights.medium,
},
// Identity card
identityCard: {
backgroundColor: colors.surface,
borderRadius: borderRadius.lg,
@@ -267,8 +344,6 @@ const styles = StyleSheet.create({
fontSize: fonts.sizes.xs,
marginTop: spacing.xs,
},
// Summary
summary: {
color: colors.textSecondary,
fontSize: fonts.sizes.md,
@@ -278,7 +353,7 @@ const styles = StyleSheet.create({
// Habits
habitSection: {
marginBottom: spacing.xl,
marginBottom: spacing.lg,
},
habitHeader: {
flexDirection: 'row',
@@ -288,22 +363,19 @@ const styles = StyleSheet.create({
},
sectionTitle: {
color: colors.text,
fontSize: fonts.sizes.lg,
fontSize: fonts.sizes.md,
fontWeight: fonts.weights.semibold,
marginBottom: spacing.sm,
},
counter: {
color: colors.textMuted,
color: colors.accent,
fontSize: fonts.sizes.sm,
fontWeight: fonts.weights.medium,
},
counterFull: {
color: colors.warning,
},
// Chips
chip: {
flexDirection: 'row',
alignItems: 'center',
alignItems: 'flex-start',
backgroundColor: colors.surface,
borderRadius: borderRadius.md,
paddingVertical: spacing.md,
@@ -316,8 +388,10 @@ const styles = StyleSheet.create({
borderColor: colors.primary,
backgroundColor: 'rgba(108, 99, 255, 0.08)',
},
chipDisabled: {
opacity: 0.4,
chipLeft: {
alignItems: 'center',
marginRight: spacing.md,
paddingTop: 2,
},
chipCheck: {
width: 24,
@@ -329,7 +403,6 @@ const styles = StyleSheet.create({
lineHeight: 22,
fontSize: 12,
color: colors.textMuted,
marginRight: spacing.md,
flexShrink: 0,
},
chipCheckSelected: {
@@ -337,18 +410,66 @@ const styles = StyleSheet.create({
backgroundColor: colors.primary,
color: colors.text,
},
chipBody: {
flex: 1,
},
chipText: {
color: colors.text,
fontSize: fonts.sizes.md,
flex: 1,
flexWrap: 'wrap',
},
chipTextSelected: {
color: colors.text,
fontWeight: fonts.weights.semibold,
},
chipWhy: {
color: colors.textSecondary,
fontSize: fonts.sizes.xs,
marginTop: spacing.xs,
lineHeight: 17,
},
chipSeparator: {
height: 1,
backgroundColor: colors.border,
marginTop: spacing.sm,
marginBottom: spacing.sm,
},
chipTimeRow: {
flexDirection: 'row',
gap: spacing.lg,
},
chipTimeItem: {
flexDirection: 'row',
alignItems: 'center',
},
chipTimeIcon: {
fontSize: 13,
marginRight: spacing.xs,
},
chipTimeLabel: {
color: colors.accent,
fontSize: fonts.sizes.xs,
fontWeight: fonts.weights.medium,
},
catBadge: {
fontSize: 16,
marginLeft: spacing.sm,
alignSelf: 'flex-start',
marginTop: 2,
},
priorityBadge: {
backgroundColor: 'rgba(0, 229, 255, 0.15)',
borderRadius: borderRadius.full,
paddingHorizontal: 6,
paddingVertical: 2,
marginLeft: spacing.sm,
},
priorityBadgeText: {
color: colors.accent,
fontSize: 10,
},
// Custom chips
// Custom
customChipRow: {
flexDirection: 'row',
alignItems: 'center',
@@ -374,7 +495,7 @@ const styles = StyleSheet.create({
addRow: {
flexDirection: 'row',
alignItems: 'center',
marginTop: spacing.xs,
marginBottom: spacing.xl,
},
addInput: {
flex: 1,
@@ -407,8 +528,9 @@ const styles = StyleSheet.create({
lineHeight: 24,
},
// Start
startBtn: {},
startBtn: {
marginTop: spacing.sm,
},
startHint: {
color: colors.textMuted,
fontSize: fonts.sizes.sm,
+6 -6
View File
@@ -62,11 +62,11 @@ export default function HomeScreen({ navigation }) {
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.`,
'Important Day',
`Day ${currentDay} is one of the toughest. Many people quit here — but not you. Want to train your focus?`,
[
{ text: 'Later', style: 'cancel' },
{ text: 'Take Challenge', onPress: () => navigation.navigate('MiniGame') },
{ text: 'Take Challenge', onPress: () => navigation.navigate('Game') },
]
);
}
@@ -83,7 +83,7 @@ export default function HomeScreen({ navigation }) {
return (
<ScreenWrapper>
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>Loading your universe...</Text>
<Text style={styles.loadingText}>Getting things ready...</Text>
</View>
</ScreenWrapper>
);
@@ -149,13 +149,13 @@ export default function HomeScreen({ navigation }) {
{isCriticalDay(currentDay) && (
<View style={styles.criticalBadge}>
<View style={styles.criticalDot} />
<Text style={styles.criticalText}>CRITICAL DAY Stay focused</Text>
<Text style={styles.criticalText}>This is a turning point. Stay with it.</Text>
</View>
)}
{/* Start Button */}
<Button
title="Start Today"
title="Let's Do This"
onPress={() => navigation.navigate('Daily')}
style={styles.startButton}
/>
+12 -12
View File
@@ -37,7 +37,7 @@ export default function IdentityStoryScreen({ navigation }) {
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.`);
showAlert('Just a bit more', 'Write a few more sentences — it helps us understand you better.');
return;
}
@@ -46,8 +46,8 @@ export default function IdentityStoryScreen({ navigation }) {
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.',
'We went a different route',
"AI wasn't available, but we still created something for you. You can change anything on the next screen.",
[{ text: 'Continue', onPress: () => navigation.navigate('HabitSelection', { aiResult: result, story: trimmed }) }]
);
} else {
@@ -81,15 +81,15 @@ export default function IdentityStoryScreen({ navigation }) {
>
<Animated.View style={{ opacity: fadeAnim, transform: [{ translateY: slideAnim }] }}>
{/* Header */}
<Text style={styles.title}>Your Story</Text>
<Text style={styles.title}>Tell us about you</Text>
<Text style={styles.subtitle}>
Tell us about yourself who you are now, who you want to become, and why this matters to you.
Who are you right now? And who do you want to become? Write it down this is just for 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>
<Text style={styles.aiBadgeText}>We'll turn your story into a personal plan</Text>
</View>
{/* Story input */}
@@ -111,15 +111,15 @@ export default function IdentityStoryScreen({ navigation }) {
{/* 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>
<Text style={styles.tipsTitle}>Not sure what to write?</Text>
<Text style={styles.tipItem}>• What feels hard in your life right now?</Text>
<Text style={styles.tipItem}>• Who do you wish you could be?</Text>
<Text style={styles.tipItem}>• What would be different if you made the change?</Text>
</View>
{/* Actions */}
<Button
title={loading ? 'Generating...' : 'Generate My Path'}
title={loading ? 'Reading your story...' : 'Show Me My Path'}
onPress={handleGenerate}
loading={loading}
disabled={!isValid}
@@ -127,7 +127,7 @@ export default function IdentityStoryScreen({ navigation }) {
/>
<Button
title="Skip — I'll create manually"
title="I'll set it up myself"
onPress={handleSkip}
variant="secondary"
disabled={loading}
+41 -25
View File
@@ -54,11 +54,11 @@ export default function LoginScreen({ navigation }) {
const handleLogin = async () => {
if (!email.trim()) {
showAlert('Validation Error', 'Please enter your email address.');
showAlert('Oops', 'We need your email to sign you in.');
return;
}
if (!password.trim()) {
showAlert('Validation Error', 'Please enter your password.');
showAlert('Oops', "Don't forget your password!");
return;
}
@@ -74,7 +74,7 @@ export default function LoginScreen({ navigation }) {
await login(email.trim(), password);
goToApp();
} catch (error) {
showAlert('Login Failed', error.message);
showAlert("Couldn't sign in", error.message);
} finally {
setLoading(false);
}
@@ -86,7 +86,7 @@ export default function LoginScreen({ navigation }) {
await loginAsDemo();
goToApp();
} catch (error) {
showAlert('Demo Login Failed', error.message);
showAlert("Demo didn't work", error.message);
} finally {
setDemoLoading(false);
}
@@ -101,7 +101,7 @@ export default function LoginScreen({ navigation }) {
await Linking.openURL(data.url);
}
} catch (error) {
showAlert('Google Login Failed', error.message);
showAlert('Google sign-in failed', error.message);
} finally {
setGoogleLoading(false);
}
@@ -117,8 +117,8 @@ export default function LoginScreen({ navigation }) {
>
{/* Header */}
<View style={styles.header}>
<Text style={styles.title}>Welcome Back</Text>
<Text style={styles.subtitle}>Continue your transformation</Text>
<Text style={styles.title}>Hey, welcome back</Text>
<Text style={styles.subtitle}>Ready to pick up where you left off?</Text>
</View>
{/* Form */}
@@ -127,7 +127,7 @@ export default function LoginScreen({ navigation }) {
label="Email"
value={email}
onChangeText={setEmail}
placeholder="your@email.com"
placeholder="you@example.com"
keyboardType="email-address"
/>
@@ -135,22 +135,28 @@ export default function LoginScreen({ navigation }) {
label="Password"
value={password}
onChangeText={setPassword}
placeholder="Enter your password"
placeholder="Your secret password"
secureTextEntry={!showPassword}
rightIcon={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>
{/* Remember Me + Forgot */}
<View style={styles.optionsRow}>
<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}>Keep me signed in</Text>
</Pressable>
<TouchableOpacity onPress={() => navigation.navigate('ForgotPassword')}>
<Text style={styles.forgotText}>Forgot password?</Text>
</TouchableOpacity>
</View>
{/* Sign In */}
<Button
@@ -180,7 +186,7 @@ export default function LoginScreen({ navigation }) {
{/* Demo Login */}
<Button
title="Login as Demo User"
title="Try the Demo"
onPress={handleDemoLogin}
loading={demoLoading}
disabled={anyLoading && !demoLoading}
@@ -191,9 +197,9 @@ export default function LoginScreen({ navigation }) {
{/* Footer */}
<View style={styles.footer}>
<Text style={styles.footerText}>Don't have an account? </Text>
<Text style={styles.footerText}>New here? </Text>
<TouchableOpacity onPress={() => navigation.navigate('Register')}>
<Text style={styles.footerLink}>Create one</Text>
<Text style={styles.footerLink}>Join Nova40</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
@@ -224,13 +230,23 @@ const styles = StyleSheet.create({
marginBottom: spacing.xl,
},
// Remember Me
rememberRow: {
// Options row
optionsRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.md,
marginTop: spacing.xs,
},
rememberRow: {
flexDirection: 'row',
alignItems: 'center',
},
forgotText: {
color: colors.primary,
fontSize: fonts.sizes.sm,
fontWeight: fonts.weights.medium,
},
checkbox: {
width: 22,
height: 22,
+145 -41
View File
@@ -1,14 +1,18 @@
import React, { useState, useCallback } from 'react';
import { View, Text, StyleSheet, Pressable, Animated, ScrollView } from 'react-native';
import { View, Text, StyleSheet, Pressable, ScrollView } from 'react-native';
import { useFocusEffect } from '@react-navigation/native';
import { showAlert } from '../components/NovaAlert';
import ScreenWrapper from '../components/ScreenWrapper';
import FadeIn from '../components/FadeIn';
import NovaAd from '../components/NovaAd';
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 { saveGameSession, getGameScores } from '../services/gameService';
import { trackGamePlayed, logScreenView } from '../services/analytics';
import { colors, fonts, spacing, borderRadius } from '../utils/theme';
const GAMES = [
@@ -16,7 +20,7 @@ const GAMES = [
id: 'reflex_tap',
name: 'Reflex Tap',
icon: '⚡',
desc: 'Train quick action',
desc: 'Sharpen your reflexes',
stat: 'Discipline',
color: colors.accent,
},
@@ -24,7 +28,7 @@ const GAMES = [
id: 'focus_hold',
name: 'Focus Hold',
icon: '🎯',
desc: 'Train focus & stability',
desc: 'Practice being still',
stat: 'Focus',
color: colors.primary,
},
@@ -32,7 +36,7 @@ const GAMES = [
id: 'temptation_choice',
name: 'Temptation',
icon: '🧠',
desc: 'Real-life decisions',
desc: 'Face real temptations',
stat: 'Consistency',
color: colors.success,
},
@@ -40,7 +44,7 @@ const GAMES = [
id: 'timing_tap',
name: 'Timing Tap',
icon: '⏱',
desc: 'Precision & awareness',
desc: 'Find your rhythm',
stat: 'Focus',
color: colors.warning,
},
@@ -54,7 +58,17 @@ export default function MiniGameScreen({ navigation, route }) {
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 [focusCount, setFocusCount] = useState(0);
const [gameScores, setGameScores] = useState({});
useFocusEffect(
useCallback(() => {
setFocusCount((c) => c + 1);
if (identity?.id) {
getGameScores(identity.id).then(setGameScores);
}
}, [identity?.id])
);
const selectGame = (gameId) => {
setActiveGame(gameId);
@@ -62,22 +76,25 @@ export default function MiniGameScreen({ navigation, route }) {
setResult(null);
};
const handleGameComplete = useCallback((score, details) => {
const handleGameComplete = useCallback(async (score, details) => {
setResult({ score, details });
setPhase('result');
}, []);
trackGamePlayed(activeGame, score);
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);
// Auto-save score immediately
if (identity?.id && activeGame) {
try {
await saveGameSession(identity.id, activeGame, score);
const updated = await getGameScores(identity.id);
setGameScores(updated);
} catch (_) {}
}
}, [identity?.id, activeGame]);
const handleExit = () => {
setPhase('select');
setActiveGame(null);
setResult(null);
};
const handleRetry = () => {
@@ -90,25 +107,35 @@ export default function MiniGameScreen({ navigation, route }) {
return (
<ScreenWrapper>
<View style={styles.selectContainer}>
<Text style={styles.selectTitle}>Mind Training</Text>
<Text style={styles.selectSubtitle}>Choose your challenge</Text>
<FadeIn delay={0} trigger={focusCount}>
<Text style={styles.selectTitle}>Train Your Mind</Text>
<Text style={styles.selectSubtitle}>Pick a quick challenge</Text>
</FadeIn>
<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>
{GAMES.map((game, i) => (
<FadeIn key={game.id} delay={100 + i * 80} trigger={focusCount} style={styles.gameCardWrap}>
<Pressable
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>
{gameScores[game.id] && (
<View style={styles.scoreRow}>
<Text style={styles.scoreLast}>Last: {gameScores[game.id].last}</Text>
<Text style={[styles.scoreBest, { color: game.color }]}>Best: {gameScores[game.id].best}</Text>
</View>
)}
<View style={[styles.statBadge, { borderColor: game.color }]}>
<Text style={[styles.statBadgeText, { color: game.color }]}>+{game.stat}</Text>
</View>
</Pressable>
</FadeIn>
))}
</ScrollView>
<NovaAd />
</View>
</ScreenWrapper>
);
@@ -122,9 +149,8 @@ export default function MiniGameScreen({ navigation, route }) {
gameType={activeGame}
score={result.score}
details={result.details}
onSave={handleSave}
onExit={handleExit}
onRetry={handleRetry}
saving={saving}
/>
</ScreenWrapper>
);
@@ -143,9 +169,24 @@ export default function MiniGameScreen({ navigation, route }) {
return null;
}
const gameName = GAMES.find((g) => g.id === activeGame)?.name || 'Game';
return (
<ScreenWrapper>
<View style={styles.playContainer}>
{/* Exit header */}
<View style={styles.playHeader}>
<Pressable style={({ pressed }) => [styles.exitBtn, pressed && styles.exitBtnPressed]} onPress={handleExit}>
<View style={styles.exitIconWrap}>
<Text style={styles.exitIcon}>{'\u2715'}</Text>
</View>
</Pressable>
<View style={styles.playTitleWrap}>
<Text style={styles.playTitleEmoji}>{GAMES.find((g) => g.id === activeGame)?.icon || '🎮'}</Text>
<Text style={styles.playTitle}>{gameName}</Text>
</View>
<View style={styles.exitPlaceholder} />
</View>
<GameComponent
currentDay={currentDay}
onComplete={handleGameComplete}
@@ -177,17 +218,21 @@ const styles = StyleSheet.create({
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'space-between',
gap: spacing.md,
paddingBottom: spacing.xxl,
},
gameCardWrap: {
width: '48%',
marginBottom: spacing.md,
},
gameCard: {
width: '47%',
backgroundColor: colors.surface,
borderRadius: borderRadius.lg,
padding: spacing.lg,
padding: spacing.md,
borderWidth: 1,
borderColor: colors.border,
alignItems: 'center',
justifyContent: 'center',
height: 160,
},
gameCardPressed: {
opacity: 0.7,
@@ -207,7 +252,21 @@ const styles = StyleSheet.create({
color: colors.textSecondary,
fontSize: fonts.sizes.xs,
textAlign: 'center',
marginBottom: spacing.md,
marginBottom: spacing.sm,
},
scoreRow: {
flexDirection: 'row',
gap: spacing.sm,
marginBottom: spacing.sm,
},
scoreLast: {
color: colors.textMuted,
fontSize: 10,
fontWeight: fonts.weights.medium,
},
scoreBest: {
fontSize: 10,
fontWeight: fonts.weights.bold,
},
statBadge: {
borderWidth: 1,
@@ -224,6 +283,51 @@ const styles = StyleSheet.create({
playContainer: {
flex: 1,
paddingHorizontal: spacing.lg,
paddingTop: spacing.lg,
paddingTop: spacing.sm,
},
playHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: spacing.md,
},
exitBtn: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: 'rgba(255,82,82,0.1)',
borderWidth: 1,
borderColor: 'rgba(255,82,82,0.25)',
alignItems: 'center',
justifyContent: 'center',
},
exitBtnPressed: {
backgroundColor: 'rgba(255,82,82,0.25)',
transform: [{ scale: 0.92 }],
},
exitIconWrap: {
alignItems: 'center',
justifyContent: 'center',
},
exitIcon: {
color: colors.error,
fontSize: 14,
fontWeight: fonts.weights.bold,
},
playTitleWrap: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
playTitleEmoji: {
fontSize: 16,
},
playTitle: {
color: colors.text,
fontSize: fonts.sizes.sm,
fontWeight: fonts.weights.semibold,
},
exitPlaceholder: {
width: 36,
},
});
+12 -12
View File
@@ -58,7 +58,7 @@ export default function MirrorScreen() {
if (loading) {
return (
<ScreenWrapper>
<View style={styles.loadingContainer}><Text style={styles.loadingText}>Loading your mirror...</Text></View>
<View style={styles.loadingContainer}><Text style={styles.loadingText}>Looking back...</Text></View>
</ScreenWrapper>
);
}
@@ -66,37 +66,37 @@ export default function MirrorScreen() {
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>
<Text style={styles.title}>Then vs. Now</Text>
<Text style={styles.subtitle}>Look at how much you've grown</Text>
{identity && (
<View style={styles.identityCard}>
<Text style={styles.identityLabel}>YOUR IDENTITY</Text>
<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} />
<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>
<Text style={styles.arrowLabel}>{currentDay - 1} days of becoming</Text>
</View>
<DayCard label="WHERE YOU ARE NOW" day={currentDay} log={todayLog} color={colors.accent} />
<DayCard label="Where you are now" day={currentDay} log={todayLog} color={colors.accent} />
<View style={styles.insightCard}>
<Text style={styles.insightTitle}>Journey Insights</Text>
<Text style={styles.insightTitle}>The bigger picture</Text>
<View style={styles.insightRow}>
<Text style={styles.insightLabel}>Days Completed</Text>
<Text style={styles.insightLabel}>Days you showed up</Text>
<Text style={styles.insightValue}>{logs.length} / {currentDay}</Text>
</View>
<View style={styles.insightRow}>
<Text style={styles.insightLabel}>Completion Rate</Text>
<Text style={styles.insightLabel}>Consistency</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>
<Text style={styles.insightLabel}>Days fully aligned</Text>
<Text style={styles.insightValue}>{logs.filter((l) => l.identity_check === 'yes').length} days as your best self</Text>
</View>
</View>
</Animated.ScrollView>
+177 -194
View File
@@ -1,59 +1,75 @@
import React, { useRef, useState, useEffect, useCallback } from 'react';
import {
View, Text, Image, TextInput, StyleSheet, FlatList,
View, Text, Image, 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 { width, height } = 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,
glowIntensity: 0.4,
headline: 'Become\nsomeone new',
body: 'Nova40 is a 40-day journey that helps you\ntransform your identity through daily habits,\njournaling, and real self-reflection.',
accent: colors.primary,
},
{
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,
iconSize: 90,
glowIntensity: 0.6,
headline: 'Your story.\nYour plan.\nYour growth.',
body: null,
accent: colors.accent,
bullets: [
'Write your story — AI builds your personal path',
'Track habits and mood every single day',
'Train your mind with focus challenges',
'Watch yourself grow on a 40-day map',
],
},
{
id: '3',
title: 'Your Nova Awaits',
text: 'Small actions. Massive transformation.',
iconSize: 110,
iconSize: 90,
glowIntensity: 1,
showOrbit: true,
headline: '40 days is\nall it takes.',
body: "You don't need to be perfect.\nJust show up — one day at a time.\nThat's the whole secret.",
accent: colors.success,
isLast: true,
},
];
function PageContent({ item, isActive }) {
const fadeAnim = useRef(new Animated.Value(0)).current;
const slideAnim = useRef(new Animated.Value(30)).current;
const slideAnim = useRef(new Animated.Value(40)).current;
const iconFade = useRef(new Animated.Value(0)).current;
const iconScale = useRef(new Animated.Value(0.8)).current;
const pulse = useRef(new Animated.Value(1)).current;
useEffect(() => {
if (isActive) {
iconFade.setValue(0);
iconScale.setValue(0.8);
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 }),
slideAnim.setValue(40);
Animated.sequence([
Animated.parallel([
Animated.timing(iconFade, { toValue: 1, duration: 600, useNativeDriver: true }),
Animated.spring(iconScale, { toValue: 1, useNativeDriver: true, speed: 12, bounciness: 6 }),
]),
Animated.parallel([
Animated.timing(fadeAnim, { toValue: 1, duration: 500, useNativeDriver: true }),
Animated.timing(slideAnim, { toValue: 0, duration: 500, useNativeDriver: true }),
]),
]).start();
}
}, [isActive]);
@@ -61,8 +77,8 @@ function PageContent({ item, 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 }),
Animated.timing(pulse, { toValue: 1.1, duration: 2500, useNativeDriver: true }),
Animated.timing(pulse, { toValue: 1, duration: 2500, useNativeDriver: true }),
])
);
anim.start();
@@ -71,43 +87,52 @@ function PageContent({ item, isActive }) {
return (
<View style={styles.page}>
{/* Icon area */}
<View style={styles.iconArea}>
{/* Logo */}
<Animated.View style={[styles.iconArea, { opacity: iconFade, transform: [{ scale: iconScale }] }]}>
<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,
width: item.iconSize * 2.4, height: item.iconSize * 2.4,
borderRadius: item.iconSize * 1.2, opacity: item.glowIntensity * 0.35,
transform: [{ scale: pulse }],
},
]}
/>
{item.showOrbit && (
<View
style={[
styles.orbit,
{
width: item.iconSize * 2.6,
height: item.iconSize * 2.6,
borderRadius: item.iconSize * 1.3,
},
]}
/>
<View style={[styles.orbit, {
width: item.iconSize * 2.8, height: item.iconSize * 2.8,
borderRadius: item.iconSize * 1.4,
}]} />
)}
<Image
source={require('../../assets/icon.png')}
style={{ width: item.iconSize * 1.2, height: item.iconSize * 1.2 }}
style={{ width: item.iconSize, height: item.iconSize }}
resizeMode="contain"
/>
</View>
</Animated.View>
{/* Text */}
<Animated.View style={{ opacity: fadeAnim, transform: [{ translateY: slideAnim }] }}>
<Text style={styles.pageTitle}>{item.title}</Text>
<Text style={styles.pageText}>{item.text}</Text>
{/* Content */}
<Animated.View style={[styles.textArea, { opacity: fadeAnim, transform: [{ translateY: slideAnim }] }]}>
{/* Accent line */}
<View style={[styles.accentLine, { backgroundColor: item.accent }]} />
<Text style={styles.headline}>{item.headline}</Text>
{item.body && (
<Text style={styles.body}>{item.body}</Text>
)}
{item.bullets && (
<View style={styles.bulletList}>
{item.bullets.map((text, i) => (
<View key={i} style={styles.bulletRow}>
<View style={[styles.bulletDot, { backgroundColor: item.accent }]} />
<Text style={styles.bulletText}>{text}</Text>
</View>
))}
</View>
)}
</Animated.View>
</View>
);
@@ -116,50 +141,32 @@ function PageContent({ item, isActive }) {
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 buttonSlide = useRef(new Animated.Value(20)).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 }),
Animated.parallel([
Animated.timing(buttonFade, { toValue: 1, duration: 500, delay: 300, useNativeDriver: true }),
Animated.timing(buttonSlide, { toValue: 0, duration: 500, delay: 300, useNativeDriver: true }),
]).start();
} else {
buttonFade.setValue(0);
inputFade.setValue(0);
buttonSlide.setValue(20);
}
}, [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]);
}, [navigation]);
const onViewableItemsChanged = useRef(({ viewableItems }) => {
if (viewableItems.length > 0) {
@@ -169,86 +176,50 @@ export default function OnboardingScreen({ navigation }) {
return (
<View style={styles.container}>
<StarField count={60} />
<StarField count={35} />
{/* Skip button — hidden on last page */}
{/* Skip */}
{!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} />
)}
/>
)}
<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>
)}
{/* Bottom */}
<View style={styles.bottom}>
{isLastPage && (
<Animated.View style={[styles.ctaWrapper, { opacity: buttonFade, transform: [{ translateY: buttonSlide }] }]}>
<Button title="Let's Begin" onPress={completeOnboarding} />
</Animated.View>
)}
{/* 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>
)}
{/* Dots */}
<View style={styles.indicators}>
{pages.map((_, i) => (
<Animated.View
key={i}
style={[
styles.dot,
currentIndex === i && [styles.dotActive, { backgroundColor: pages[currentIndex].accent }],
]}
/>
))}
</View>
</KeyboardAvoidingView>
</View>
</View>
);
}
@@ -263,12 +234,14 @@ const styles = StyleSheet.create({
top: 58,
right: 24,
zIndex: 10,
paddingVertical: spacing.xs,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: borderRadius.full,
backgroundColor: 'rgba(255,255,255,0.06)',
},
skipText: {
color: colors.textSecondary,
fontSize: fonts.sizes.md,
fontSize: fonts.sizes.sm,
fontWeight: fonts.weights.medium,
},
page: {
@@ -277,12 +250,16 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: spacing.xl,
paddingBottom: 130,
},
// Logo
iconArea: {
alignItems: 'center',
justifyContent: 'center',
width: 300,
height: 300,
alignSelf: 'center',
width: 280,
height: 220,
marginBottom: spacing.xl,
},
glow: {
@@ -295,78 +272,84 @@ const styles = StyleSheet.create({
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,
// Text
textArea: {
alignItems: 'center',
paddingHorizontal: spacing.xs,
},
pageText: {
accentLine: {
width: 32,
height: 3,
borderRadius: 2,
marginBottom: spacing.lg,
},
headline: {
color: colors.text,
fontSize: fonts.sizes.xl,
fontWeight: fonts.weights.semibold,
fontSize: 30,
fontWeight: fonts.weights.bold,
lineHeight: 40,
marginBottom: spacing.lg,
letterSpacing: -0.5,
textAlign: 'center',
lineHeight: 34,
},
bottomKAV: {
body: {
color: colors.textSecondary,
fontSize: fonts.sizes.md,
lineHeight: 26,
textAlign: 'center',
},
// Bullets
bulletList: {
marginTop: spacing.xs,
alignItems: 'center',
},
bulletRow: {
flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: spacing.md,
maxWidth: 300,
},
bulletDot: {
width: 6,
height: 6,
borderRadius: 3,
marginTop: 8,
marginRight: spacing.md,
},
bulletText: {
color: colors.textSecondary,
fontSize: fonts.sizes.md,
lineHeight: 24,
flex: 1,
},
// Bottom
bottom: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
},
bottom: {
paddingHorizontal: spacing.lg,
paddingBottom: spacing.xl,
paddingHorizontal: spacing.xl,
paddingBottom: spacing.xl + 10,
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,
marginBottom: spacing.lg,
},
indicators: {
flexDirection: 'row',
justifyContent: 'center',
gap: spacing.sm,
paddingBottom: spacing.sm,
},
dot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: colors.textMuted,
backgroundColor: 'rgba(255,255,255,0.15)',
},
dotActive: {
backgroundColor: colors.primary,
width: 24,
width: 28,
},
});
+131
View File
@@ -0,0 +1,131 @@
import React from 'react';
import { View, Text, StyleSheet, ScrollView } from 'react-native';
import ScreenWrapper from '../components/ScreenWrapper';
import { colors, fonts, spacing, borderRadius } from '../utils/theme';
function Section({ title, children }) {
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{title}</Text>
{children}
</View>
);
}
function Para({ children }) {
return <Text style={styles.para}>{children}</Text>;
}
function Bullet({ children }) {
return (
<View style={styles.bulletRow}>
<Text style={styles.bulletDot}></Text>
<Text style={styles.bulletText}>{children}</Text>
</View>
);
}
export default function PrivacyPolicyScreen() {
return (
<ScreenWrapper>
<ScrollView style={styles.scroll} contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
<Text style={styles.title}>Privacy Policy</Text>
<Text style={styles.updated}>Last updated: April 29, 2026</Text>
<Section title="1. Introduction">
<Para>
Nova40 ("we", "our", "us") is committed to protecting your privacy. This Privacy Policy explains how we collect, use, and safeguard your information when you use our mobile application.
</Para>
</Section>
<Section title="2. Information We Collect">
<Para>We collect the following types of information:</Para>
<Bullet>Account information (email address, password)</Bullet>
<Bullet>Identity and habit data you create within the app</Bullet>
<Bullet>Daily journal entries, mood data, and reflections</Bullet>
<Bullet>Game scores and progress statistics</Bullet>
<Bullet>Device information for crash reporting (via Firebase Crashlytics)</Bullet>
</Section>
<Section title="3. How We Use Your Information">
<Para>Your information is used to:</Para>
<Bullet>Provide and maintain the Nova40 service</Bullet>
<Bullet>Generate AI-powered identity reflections and habit suggestions</Bullet>
<Bullet>Track your 40-day transformation progress</Bullet>
<Bullet>Improve app stability through crash reports</Bullet>
<Bullet>Send important service notifications</Bullet>
</Section>
<Section title="4. AI-Generated Content">
<Para>
Nova40 uses artificial intelligence (Google Gemini / Claude AI) to generate personalized identity statements, habit suggestions, and daily reflections. Your story and journal entries are sent to AI services for processing. We do not store AI-processed data on third-party servers beyond the generation session.
</Para>
</Section>
<Section title="5. Data Storage">
<Para>
Your data is stored securely using Supabase (cloud database) and locally on your device via AsyncStorage. Data stored locally remains on your device and is not transmitted unless you explicitly use cloud features.
</Para>
</Section>
<Section title="6. Data Sharing">
<Para>We do not sell, trade, or share your personal information with third parties, except:</Para>
<Bullet>Firebase (Google) for crash reporting and analytics</Bullet>
<Bullet>AI service providers for generating personalized content</Bullet>
<Bullet>When required by law or legal process</Bullet>
</Section>
<Section title="7. Data Security">
<Para>
We implement industry-standard security measures to protect your data, including encrypted connections (HTTPS), secure authentication, and row-level security policies on our database.
</Para>
</Section>
<Section title="8. Your Rights">
<Para>You have the right to:</Para>
<Bullet>Access your personal data</Bullet>
<Bullet>Delete your account and all associated data</Bullet>
<Bullet>Export your journal and progress data</Bullet>
<Bullet>Opt out of analytics collection</Bullet>
</Section>
<Section title="9. Data Retention">
<Para>
We retain your data as long as your account is active. When you delete your account or reset your journey, associated data is permanently removed from our systems.
</Para>
</Section>
<Section title="10. Children's Privacy">
<Para>
Nova40 is not intended for children under 13 years of age. We do not knowingly collect information from children under 13.
</Para>
</Section>
<Section title="11. Changes to This Policy">
<Para>
We may update this Privacy Policy from time to time. Changes will be posted within the app and take effect immediately upon posting.
</Para>
</Section>
<Section title="12. Contact Us">
<Para>
If you have questions about this Privacy Policy, please contact us at: support@nova40.app
</Para>
</Section>
</ScrollView>
</ScreenWrapper>
);
}
const styles = StyleSheet.create({
scroll: { flex: 1 },
content: { padding: spacing.lg, paddingBottom: spacing.xxl * 2 },
title: { color: colors.text, fontSize: fonts.sizes.xxl, fontWeight: fonts.weights.bold, marginBottom: spacing.xs },
updated: { color: colors.textMuted, fontSize: fonts.sizes.sm, marginBottom: spacing.xl },
section: { marginBottom: spacing.xl },
sectionTitle: { color: colors.text, fontSize: fonts.sizes.md, fontWeight: fonts.weights.bold, marginBottom: spacing.sm },
para: { color: colors.textSecondary, fontSize: fonts.sizes.sm, lineHeight: 22, marginBottom: spacing.sm },
bulletRow: { flexDirection: 'row', paddingLeft: spacing.sm, marginBottom: spacing.xs },
bulletDot: { color: colors.primary, fontSize: fonts.sizes.sm, marginRight: spacing.sm, lineHeight: 22 },
bulletText: { color: colors.textSecondary, fontSize: fonts.sizes.sm, lineHeight: 22, flex: 1 },
});
+357 -83
View File
@@ -1,15 +1,50 @@
import React, { useRef, useEffect } from 'react';
import { View, Text, StyleSheet, Animated } from 'react-native';
import { CommonActions } from '@react-navigation/native';
import React, { useRef, useState, useCallback } from 'react';
import { View, Text, TextInput, Image, StyleSheet, Animated, Pressable } from 'react-native';
import { CommonActions, useFocusEffect } from '@react-navigation/native';
import { showAlert } from '../components/NovaAlert';
import ScreenWrapper from '../components/ScreenWrapper';
import Button from '../components/Button';
import FadeIn from '../components/FadeIn';
import NovaAd from '../components/NovaAd';
import useAuthStore from '../store/useAuthStore';
import useIdentityStore from '../store/useIdentityStore';
import useHabitStore from '../store/useHabitStore';
import { getProfile } from '../services/profileService';
import { colors, fonts, spacing, borderRadius } from '../utils/theme';
import { formatDate } from '../utils/helpers';
// --- Reusable guide components ---
function Section({ icon, title, children }) {
return (
<View style={gs.box}>
<View style={gs.boxHead}>
<Text style={gs.boxIcon}>{icon}</Text>
<Text style={gs.boxTitle}>{title}</Text>
</View>
{children}
</View>
);
}
function Rule({ label, value, color }) {
return (
<View style={gs.rule}>
<View style={[gs.dot, { backgroundColor: color }]} />
<Text style={gs.ruleLabel} numberOfLines={1}>{label}</Text>
<Text style={[gs.ruleVal, { color }]} numberOfLines={1}>{value}</Text>
</View>
);
}
const gs = StyleSheet.create({
box: { backgroundColor: colors.surface, borderRadius: borderRadius.sm, padding: spacing.sm, marginBottom: 6, borderWidth: 1, borderColor: colors.border },
boxHead: { flexDirection: 'row', alignItems: 'center', marginBottom: 6 },
boxIcon: { fontSize: 14, marginRight: 6 },
boxTitle: { color: colors.text, fontSize: 12, fontWeight: fonts.weights.bold },
rule: { flexDirection: 'row', alignItems: 'center', paddingVertical: 3 },
dot: { width: 5, height: 5, borderRadius: 3, marginRight: 6 },
ruleLabel: { color: colors.textSecondary, fontSize: 11, flex: 1 },
ruleVal: { fontSize: 10, fontWeight: fonts.weights.bold },
});
export default function ProfileScreen({ navigation }) {
const user = useAuthStore((s) => s.user);
const identity = useIdentityStore((s) => s.identity);
@@ -17,25 +52,64 @@ export default function ProfileScreen({ navigation }) {
const logout = useAuthStore((s) => s.logout);
const resetIdentity = useIdentityStore((s) => s.reset);
const resetHabits = useHabitStore((s) => s.reset);
const resetJourney = useIdentityStore((s) => s.resetJourney);
const fadeAnim = useRef(new Animated.Value(0)).current;
const [showGuide, setShowGuide] = useState(false);
const [showReset, setShowReset] = useState(false);
const [resetReason, setResetReason] = useState('');
const [resetting, setResetting] = useState(false);
const [focusCount, setFocusCount] = useState(0);
const [profile, setProfile] = useState(null);
useEffect(() => {
Animated.timing(fadeAnim, { toValue: 1, duration: 500, useNativeDriver: true }).start();
}, []);
useFocusEffect(
useCallback(() => {
setFocusCount((c) => c + 1);
Animated.timing(fadeAnim, { toValue: 1, duration: 500, useNativeDriver: true }).start();
// Load profile
if (user?.id) {
getProfile(user.id).then((p) => setProfile(p));
}
}, [user?.id])
);
const handleReset = () => {
if (!resetReason.trim()) {
showAlert('Tell us why', "We'd like to understand why. It helps us help you.");
return;
}
showAlert(
'Are you sure?',
"Everything you've built will be gone. This can't be undone.",
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Yes, Reset',
style: 'destructive',
onPress: async () => {
setResetting(true);
try {
await resetJourney(resetReason.trim());
setShowReset(false);
setResetReason('');
navigation.dispatch(CommonActions.reset({ index: 0, routes: [{ name: 'IdentityStory' }] }));
} catch (e) {
showAlert('Error', e?.message || 'Failed to reset.');
} finally { setResetting(false); }
},
},
]
);
};
const handleSignOut = () => {
showAlert('Sign Out', 'Are you sure you want to sign out?', [
showAlert('Leaving already?', 'You can always come back.', [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Sign Out',
style: 'destructive',
onPress: async () => {
resetIdentity();
resetHabits();
await logout();
navigation.dispatch(
CommonActions.reset({ index: 0, routes: [{ name: 'Login' }] })
);
resetIdentity(); resetHabits(); await logout();
navigation.dispatch(CommonActions.reset({ index: 0, routes: [{ name: 'Login' }] }));
},
},
]);
@@ -43,93 +117,293 @@ export default function ProfileScreen({ navigation }) {
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>
<Animated.ScrollView
style={[st.flex, { opacity: fadeAnim }]}
contentContainerStyle={st.scroll}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
{/* ═══ Profile Header ═══ */}
<FadeIn delay={0} trigger={focusCount}><View style={st.profileHeader}>
<Pressable onPress={() => navigation.navigate('EditProfile')}>
{profile?.photoUri ? (
<Image source={{ uri: profile.photoUri }} style={st.avatarPhoto} />
) : (
<View style={st.avatar}>
<Text style={st.avatarText}>
{(profile?.nickname?.[0] || profile?.fullName?.[0] || user?.email?.[0] || 'N').toUpperCase()}
</Text>
</View>
)}
</Pressable>
{profile?.nickname || profile?.fullName ? (
<>
<Text style={st.displayName}>{profile.nickname || profile.fullName}</Text>
{profile?.fullName && profile?.nickname ? (
<Text style={st.fullName}>{profile.fullName}</Text>
) : null}
<Text style={st.email} numberOfLines={1}>{user?.email}</Text>
</>
) : (
<Text style={st.email} numberOfLines={1} adjustsFontSizeToFit>{user?.email}</Text>
)}
<Pressable
style={({ pressed }) => [st.editBtn, pressed && st.editBtnPressed]}
onPress={() => navigation.navigate('EditProfile')}
>
<Text style={st.editBtnText}>Edit Profile</Text>
</Pressable>
</View></FadeIn>
{/* ═══ Active Identity ═══ */}
{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>
<FadeIn delay={100} trigger={focusCount}><View style={st.identityCard}>
<Text style={st.identityLabel}>Your identity</Text>
<Text style={st.identityTitle}>{identity.title}</Text>
<View style={st.identitySep} />
<View style={st.identityRow}>
<Text style={st.identityMeta}>Day {currentDay}/40</Text>
<Text style={st.identityMeta}>Started {formatDate(identity.start_date)}</Text>
</View>
</View></FadeIn>
)}
<NovaAd />
{/* ═══ Scoring Guide ═══ */}
<FadeIn delay={200} trigger={focusCount}><Pressable
style={({ pressed }) => [st.accordion, st.accordionGuide, pressed && st.accordionPressed]}
onPress={() => setShowGuide((p) => !p)}
>
<Text style={st.accordionIcon}>📘</Text>
<Text style={st.accordionText}>How your score works</Text>
<Text style={[st.accordionArrow, { color: colors.primary }]}>{showGuide ? '▲' : '▼'}</Text>
</Pressable></FadeIn>
{showGuide && (
<View style={st.accordionBody}>
<Section icon="🪞" title="Identity Check">
<Rule label='Answer "Yes"' value="+3 pts" color={colors.success} />
<Rule label='Answer "Almost"' value="+1 pt" color={colors.warning} />
<Rule label='Answer "No"' value="+0 pts" color={colors.error} />
</Section>
<Section icon="📊" title="Score Categories">
<Rule label="Discipline" value="Yes +3 / Almost +1" color={colors.primary} />
<Rule label="Focus" value="Yes +3 / Almost +1" color={colors.accent} />
<Rule label="Consistency" value="Yes +2 / Almost +1" color={colors.success} />
</Section>
<Section icon="🔥" title="Streak">
<Text style={st.desc}>Consecutive "Yes" or "Almost" days. Resets on "No" or missed day.</Text>
</Section>
<Section icon="🗺️" title="40-Day Map Colors">
<Rule label="Green" value="Yes" color={colors.success} />
<Rule label="Yellow" value="Almost" color={colors.warning} />
<Rule label="Red" value="No" color={colors.error} />
<Rule label="Cyan border" value="Today" color={colors.accent} />
</Section>
<Section icon="🎮" title="Game Bonus">
<Rule label="Reflex Tap" value="Disc ×1.0" color={colors.primary} />
<Rule label="Focus Hold" value="Focus ×1.0" color={colors.accent} />
<Rule label="Temptation" value="Cons ×1.0" color={colors.success} />
<Rule label="Timing Tap" value="Focus ×0.8" color={colors.warning} />
</Section>
<Section icon="🏆" title="Max Score">
<Text style={st.desc}>8 pts/day × 40 days = 320 max (before game bonus)</Text>
</Section>
<Section icon="⚡" title="Critical Days">
<Text style={st.desc}>Day 3, 10, 21, 30 turning points where most give up.</Text>
</Section>
<Section icon="🌙" title="Phases">
<Rule label="17 Awakening" value="Foundation" color={colors.textSecondary} />
<Rule label="814 Building" value="Momentum" color={colors.primary} />
<Rule label="1521 Testing" value="Challenge" color={colors.warning} />
<Rule label="2230 Strengthening" value="Solidifying" color={colors.accent} />
<Rule label="3140 Transcending" value="Nova" color={colors.success} />
</Section>
</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>
{/* ═══ Legal ═══ */}
<FadeIn delay={300} trigger={focusCount}>
<View style={st.divider} />
<View style={st.legalRow}>
<Pressable
style={({ pressed }) => [st.legalBtn, pressed && st.legalBtnPressed]}
onPress={() => navigation.navigate('PrivacyPolicy')}
>
<Text style={st.legalIcon}>🔒</Text>
<Text style={st.legalText}>Privacy Policy</Text>
<Text style={st.legalArrow}></Text>
</Pressable>
<Pressable
style={({ pressed }) => [st.legalBtn, pressed && st.legalBtnPressed]}
onPress={() => navigation.navigate('Terms')}
>
<Text style={st.legalIcon}>📄</Text>
<Text style={st.legalText}>Terms & Conditions</Text>
<Text style={st.legalArrow}></Text>
</Pressable>
</View>
</FadeIn>
{/* ═══ Reset Journey ═══ */}
{identity && (
<FadeIn delay={350} trigger={focusCount}>
<View style={st.divider} />
<Pressable
style={({ pressed }) => [st.accordion, st.accordionReset, pressed && st.accordionPressed]}
onPress={() => setShowReset((p) => !p)}
>
<Text style={st.accordionIcon}>🔄</Text>
<Text style={[st.accordionText, { color: colors.error }]}>Reset Current Journey</Text>
<Text style={[st.accordionArrow, { color: colors.error }]}>{showReset ? '▲' : '▼'}</Text>
</Pressable>
{showReset && (
<View style={st.resetBody}>
<View style={st.resetWarningBox}>
<Text style={st.resetWarningIcon}></Text>
<Text style={st.resetWarningText}>This will erase everything your identity, habits, journal entries, stats, and game scores. There's no going back.</Text>
</View>
<Text style={st.resetLabel}>Before you go — why the reset?</Text>
<TextInput
style={st.resetInput}
value={resetReason}
onChangeText={setResetReason}
placeholder="I'm resetting because..."
placeholderTextColor={colors.textMuted}
multiline
selectionColor={colors.error}
/>
<Button
title={resetting ? 'Resetting...' : 'Reset Journey'}
onPress={handleReset}
loading={resetting}
disabled={!resetReason.trim()}
variant="danger"
/>
</View>
)}
</FadeIn>
)}
{/* ═══ Sign Out ═══ */}
<FadeIn delay={400} trigger={focusCount}>
<View style={st.divider} />
<Button title="Sign Out" onPress={handleSignOut} variant="secondary" style={st.signOutBtn} />
</FadeIn>
{/* ═══ Footer ═══ */}
<FadeIn delay={500} trigger={focusCount}>
<Text style={st.version}>Nova40 v1.0.0</Text>
</FadeIn>
</Animated.ScrollView>
</ScreenWrapper>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingHorizontal: spacing.lg,
paddingTop: spacing.xxl,
},
const st = StyleSheet.create({
flex: { flex: 1 },
scroll: { paddingHorizontal: spacing.lg, paddingTop: spacing.xl, paddingBottom: spacing.xxl * 2 },
// Profile header
profileHeader: { alignItems: 'center', marginBottom: spacing.lg },
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,
width: 80, height: 80, borderRadius: 40, backgroundColor: colors.primary,
alignItems: 'center', justifyContent: 'center', marginBottom: spacing.sm,
shadowColor: colors.primary, shadowOffset: { width: 0, height: 0 }, shadowOpacity: 0.4, shadowRadius: 12, elevation: 6,
},
avatarText: {
color: colors.text,
fontSize: fonts.sizes.xxl,
fontWeight: fonts.weights.bold,
avatarPhoto: {
width: 80, height: 80, borderRadius: 40, marginBottom: spacing.sm,
borderWidth: 3, borderColor: colors.primary,
},
email: {
color: colors.textSecondary,
fontSize: fonts.sizes.md,
marginBottom: spacing.xl,
textAlign: 'center',
},
card: {
backgroundColor: colors.surface,
borderRadius: borderRadius.md,
padding: spacing.lg,
avatarText: { color: colors.text, fontSize: fonts.sizes.xxl, fontWeight: fonts.weights.bold },
displayName: { color: colors.text, fontSize: fonts.sizes.lg, fontWeight: fonts.weights.bold, marginBottom: 2 },
fullName: { color: colors.textSecondary, fontSize: fonts.sizes.sm, marginBottom: 2 },
email: { color: colors.textMuted, fontSize: fonts.sizes.xs },
editBtn: {
marginTop: spacing.sm,
paddingVertical: spacing.xs + 2,
paddingHorizontal: spacing.lg,
borderRadius: borderRadius.full,
borderWidth: 1,
borderColor: colors.border,
marginBottom: spacing.xl,
borderColor: colors.primary,
},
cardLabel: {
color: colors.primary,
fontSize: fonts.sizes.xs,
fontWeight: fonts.weights.bold,
letterSpacing: 2,
marginBottom: spacing.sm,
editBtnPressed: { opacity: 0.6 },
editBtnText: { color: colors.primary, fontSize: fonts.sizes.xs, fontWeight: fonts.weights.semibold },
// Identity card
identityCard: {
backgroundColor: colors.surface, borderRadius: borderRadius.md,
padding: spacing.md, marginBottom: spacing.lg,
borderWidth: 1, borderColor: 'rgba(108,99,255,0.2)',
},
cardTitle: {
color: colors.text,
fontSize: fonts.sizes.lg,
fontWeight: fonts.weights.semibold,
marginBottom: spacing.xs,
identityLabel: { color: colors.primary, fontSize: 9, fontWeight: fonts.weights.bold, letterSpacing: 2, marginBottom: 6 },
identityTitle: { color: colors.text, fontSize: fonts.sizes.md, fontWeight: fonts.weights.semibold },
identitySep: { height: 1, backgroundColor: colors.border, marginVertical: spacing.sm },
identityRow: { flexDirection: 'row', justifyContent: 'space-between' },
identityMeta: { color: colors.textMuted, fontSize: 11 },
// Accordion shared
accordion: {
flexDirection: 'row', alignItems: 'center',
borderRadius: borderRadius.md, padding: spacing.md,
marginBottom: 6,
},
cardMeta: {
color: colors.textMuted,
fontSize: fonts.sizes.sm,
accordionGuide: { backgroundColor: 'rgba(108,99,255,0.06)', borderWidth: 1, borderColor: 'rgba(108,99,255,0.15)' },
accordionReset: { backgroundColor: 'rgba(255,82,82,0.05)', borderWidth: 1, borderColor: 'rgba(255,82,82,0.12)' },
accordionPressed: { opacity: 0.7 },
accordionIcon: { fontSize: 16, marginRight: spacing.sm },
accordionText: { color: colors.text, fontSize: fonts.sizes.sm, fontWeight: fonts.weights.semibold, flex: 1 },
accordionArrow: { fontSize: 10 },
// Accordion body
accordionBody: { marginBottom: spacing.md },
desc: { color: colors.textSecondary, fontSize: 11, lineHeight: 16 },
// Divider
divider: { height: 1, backgroundColor: colors.border, marginVertical: spacing.md },
// Reset
resetBody: {
backgroundColor: colors.surface, borderRadius: borderRadius.md,
padding: spacing.lg, marginBottom: spacing.sm,
borderWidth: 1, borderColor: 'rgba(255,82,82,0.15)',
},
actions: {},
actionBtn: {
marginBottom: spacing.sm,
resetWarningBox: {
flexDirection: 'row', alignItems: 'flex-start',
backgroundColor: 'rgba(255,82,82,0.06)', borderRadius: borderRadius.sm,
padding: spacing.sm, marginBottom: spacing.lg,
},
signOutBtn: {
borderColor: colors.error,
marginTop: spacing.md,
resetWarningIcon: { fontSize: 16, marginRight: spacing.sm, marginTop: 1 },
resetWarningText: { color: colors.error, fontSize: 11, lineHeight: 17, flex: 1 },
resetLabel: { color: colors.text, fontSize: fonts.sizes.sm, fontWeight: fonts.weights.semibold, marginBottom: spacing.sm },
resetInput: {
backgroundColor: colors.background, borderRadius: borderRadius.md,
paddingHorizontal: spacing.md, paddingVertical: spacing.md,
color: colors.text, fontSize: fonts.sizes.sm,
borderWidth: 1, borderColor: colors.border,
minHeight: 90, textAlignVertical: 'top', marginBottom: spacing.lg,
},
// Sign out
// Legal
legalRow: { gap: spacing.sm },
legalBtn: {
flexDirection: 'row', alignItems: 'center',
backgroundColor: colors.surface, borderRadius: borderRadius.md,
padding: spacing.md, borderWidth: 1, borderColor: colors.border,
},
legalBtnPressed: { opacity: 0.6 },
legalIcon: { fontSize: 16, marginRight: spacing.md },
legalText: { color: colors.text, fontSize: fonts.sizes.sm, fontWeight: fonts.weights.medium, flex: 1 },
legalArrow: { color: colors.textMuted, fontSize: fonts.sizes.lg },
signOutBtn: { borderColor: colors.error },
// Footer
version: { color: colors.textMuted, fontSize: 9, textAlign: 'center', marginTop: spacing.xl },
});
+349 -46
View File
@@ -1,109 +1,328 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, KeyboardAvoidingView, Platform } from 'react-native';
import React, { useState, useRef, useEffect } from 'react';
import {
View, Text, StyleSheet, TouchableOpacity, Pressable,
KeyboardAvoidingView, Platform, TextInput, Animated,
} from 'react-native';
import { showAlert } from '../components/NovaAlert';
import ScreenWrapper from '../components/ScreenWrapper';
import Input from '../components/Input';
import Button from '../components/Button';
import PasswordStrength from '../components/PasswordStrength';
import useAuthStore from '../store/useAuthStore';
import { colors, fonts, spacing } from '../utils/theme';
import { colors, fonts, spacing, borderRadius } from '../utils/theme';
const OTP_LENGTH = 6;
export default function RegisterScreen({ navigation }) {
const [email, setEmail] = useState('');
// Method: 'email' or 'phone'
const [method, setMethod] = useState('email');
// Shared fields
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
// Email fields
const [email, setEmail] = useState('');
// Phone fields
const [phone, setPhone] = useState('');
// OTP
const [step, setStep] = useState('form'); // 'form' | 'otp'
const [otp, setOtp] = useState(['', '', '', '', '', '']);
const [otpSent, setOtpSent] = useState(false);
const [resendTimer, setResendTimer] = useState(0);
const otpRefs = useRef([]);
const [loading, setLoading] = useState(false);
const register = useAuthStore((s) => s.register);
const registerWithPhone = useAuthStore((s) => s.registerWithPhone);
const verifyOtp = useAuthStore((s) => s.verifyOtp);
const setSession = useAuthStore((s) => s.setSession);
const fadeAnim = useRef(new Animated.Value(1)).current;
// Resend countdown
useEffect(() => {
if (resendTimer <= 0) return;
const t = setTimeout(() => setResendTimer((p) => p - 1), 1000);
return () => clearTimeout(t);
}, [resendTimer]);
const goToOnboarding = () => {
navigation.reset({ index: 0, routes: [{ name: 'Onboarding' }] });
};
const handleRegister = async () => {
if (!email.trim() || !password.trim()) {
showAlert('Error', 'Please fill in all fields');
const identifier = method === 'email' ? email.trim() : phone.trim();
// --- Validate and send OTP ---
const handleContinue = async () => {
if (!identifier) {
showAlert('Hold on', method === 'email' ? 'Enter your email to continue.' : 'Enter your phone number.');
return;
}
if (password !== confirmPassword) {
showAlert('Error', 'Passwords do not match');
if (method === 'email' && !identifier.includes('@')) {
showAlert('Oops', "That doesn't look like an email address.");
return;
}
if (method === 'phone' && identifier.length < 8) {
showAlert('Oops', 'Enter a valid phone number.');
return;
}
if (!password.trim()) {
showAlert('Hold on', 'Create a password for your account.');
return;
}
if (password.length < 6) {
showAlert('Error', 'Password must be at least 6 characters');
showAlert('Too short', 'Your password needs at least 6 characters.');
return;
}
if (password !== confirmPassword) {
showAlert('Oops', "Those passwords don't match.");
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();
if (method === 'email') {
const data = await register(identifier, password);
if (data?.session) {
setSession(data.session);
goToOnboarding();
} else {
// Need email verification — show OTP
setStep('otp');
setOtpSent(true);
setResendTimer(60);
}
} else {
showAlert(
'Account Created',
'Check your email to confirm your account, then sign in.',
[{ text: 'OK', onPress: () => navigation.navigate('Login') }]
);
// Phone registration — sends OTP
await registerWithPhone(identifier, password);
setStep('otp');
setOtpSent(true);
setResendTimer(60);
}
} catch (error) {
showAlert('Registration Failed', error.message);
} catch (e) {
showAlert('Something went wrong', e.message);
} finally {
setLoading(false);
}
};
// --- Verify OTP ---
const handleVerifyOtp = async () => {
const code = otp.join('');
if (code.length < OTP_LENGTH) {
showAlert('Incomplete', 'Enter the full verification code.');
return;
}
setLoading(true);
try {
const data = await verifyOtp(identifier, code, method);
if (data?.session) {
setSession(data.session);
goToOnboarding();
} else {
showAlert('Verified!', 'Your account is ready. Sign in to continue.', [
{ text: 'Sign In', onPress: () => navigation.navigate('Login') },
]);
}
} catch (e) {
showAlert('Invalid code', e.message || 'The code you entered is incorrect. Try again.');
} finally {
setLoading(false);
}
};
// --- Resend OTP ---
const handleResend = async () => {
if (resendTimer > 0) return;
setLoading(true);
try {
if (method === 'email') {
await register(identifier, password);
} else {
await registerWithPhone(identifier, password);
}
setResendTimer(60);
showAlert('Code sent', `We sent a new code to your ${method}.`);
} catch (e) {
showAlert('Error', e.message);
} finally {
setLoading(false);
}
};
// --- OTP Input handlers ---
const handleOtpChange = (value, index) => {
const newOtp = [...otp];
// Handle paste of full code
if (value.length > 1) {
const chars = value.replace(/\D/g, '').split('').slice(0, OTP_LENGTH);
chars.forEach((c, i) => { if (i < OTP_LENGTH) newOtp[i] = c; });
setOtp(newOtp);
const lastFilled = Math.min(chars.length, OTP_LENGTH) - 1;
otpRefs.current[lastFilled]?.focus();
return;
}
newOtp[index] = value.replace(/\D/g, '');
setOtp(newOtp);
if (value && index < OTP_LENGTH - 1) {
otpRefs.current[index + 1]?.focus();
}
};
const handleOtpKeyPress = (e, index) => {
if (e.nativeEvent.key === 'Backspace' && !otp[index] && index > 0) {
otpRefs.current[index - 1]?.focus();
}
};
// ==================
// OTP SCREEN
// ==================
if (step === 'otp') {
return (
<ScreenWrapper>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>Check your {method === 'email' ? 'inbox' : 'phone'}</Text>
<Text style={styles.subtitle}>
We sent a {OTP_LENGTH}-digit code to{'\n'}
<Text style={styles.identifierText}>{identifier}</Text>
</Text>
</View>
{/* OTP Boxes */}
<View style={styles.otpRow}>
{otp.map((digit, i) => (
<TextInput
key={i}
ref={(ref) => { otpRefs.current[i] = ref; }}
style={[styles.otpBox, digit && styles.otpBoxFilled]}
value={digit}
onChangeText={(v) => handleOtpChange(v, i)}
onKeyPress={(e) => handleOtpKeyPress(e, i)}
keyboardType="number-pad"
maxLength={i === 0 ? OTP_LENGTH : 1}
selectionColor={colors.primary}
autoFocus={i === 0}
/>
))}
</View>
<Button
title={loading ? 'Verifying...' : 'Verify Code'}
onPress={handleVerifyOtp}
loading={loading}
disabled={otp.join('').length < OTP_LENGTH}
style={styles.verifyBtn}
/>
{/* Resend */}
<View style={styles.resendRow}>
<Text style={styles.resendText}>Didn't get the code? </Text>
{resendTimer > 0 ? (
<Text style={styles.resendTimer}>Resend in {resendTimer}s</Text>
) : (
<TouchableOpacity onPress={handleResend} disabled={loading}>
<Text style={styles.resendLink}>Resend code</Text>
</TouchableOpacity>
)}
</View>
<Button
title="Back"
onPress={() => { setStep('form'); setOtp(['', '', '', '', '', '']); }}
variant="secondary"
style={styles.backBtn}
/>
</KeyboardAvoidingView>
</ScreenWrapper>
);
}
// ==================
// REGISTRATION FORM
// ==================
return (
<ScreenWrapper>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.container}
>
<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>
<Text style={styles.title}>Let's get started</Text>
<Text style={styles.subtitle}>A new chapter begins here</Text>
</View>
{/* Method Toggle */}
<View style={styles.toggle}>
<Pressable
style={[styles.toggleBtn, method === 'email' && styles.toggleBtnActive]}
onPress={() => setMethod('email')}
>
<Text style={[styles.toggleText, method === 'email' && styles.toggleTextActive]}>Email</Text>
</Pressable>
<Pressable
style={[styles.toggleBtn, method === 'phone' && styles.toggleBtnActive]}
onPress={() => setMethod('phone')}
>
<Text style={[styles.toggleText, method === 'phone' && styles.toggleTextActive]}>Phone</Text>
</Pressable>
</View>
<View style={styles.form}>
<Input
label="Email"
value={email}
onChangeText={setEmail}
placeholder="your@email.com"
keyboardType="email-address"
/>
{/* Email or Phone input */}
{method === 'email' ? (
<Input
label="Email"
value={email}
onChangeText={setEmail}
placeholder="you@example.com"
keyboardType="email-address"
/>
) : (
<Input
label="Phone number"
value={phone}
onChangeText={setPhone}
placeholder="+62 812 3456 7890"
keyboardType="phone-pad"
/>
)}
<Input
label="Password"
value={password}
onChangeText={setPassword}
placeholder="At least 6 characters"
secureTextEntry
placeholder="Make it strong (6+ characters)"
secureTextEntry={!showPassword}
rightIcon={showPassword ? '◉' : '◎'}
onRightIconPress={() => setShowPassword((p) => !p)}
/>
<PasswordStrength password={password} />
<Input
label="Confirm Password"
value={confirmPassword}
onChangeText={setConfirmPassword}
placeholder="Repeat your password"
secureTextEntry
placeholder="One more time, just to be sure"
secureTextEntry={!showPassword}
/>
<Button
title="Create Account"
onPress={handleRegister}
title={loading ? 'Creating account...' : 'Create My Account'}
onPress={handleContinue}
loading={loading}
style={styles.button}
/>
</View>
<View style={styles.footer}>
<Text style={styles.footerText}>Already have an account? </Text>
<Text style={styles.footerText}>Been here before? </Text>
<TouchableOpacity onPress={() => navigation.goBack()}>
<Text style={styles.footerLink}>Sign In</Text>
<Text style={styles.footerLink}>Sign in</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
@@ -118,7 +337,7 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.lg,
},
header: {
marginBottom: spacing.xxl,
marginBottom: spacing.xl,
},
title: {
color: colors.text,
@@ -129,13 +348,50 @@ const styles = StyleSheet.create({
subtitle: {
color: colors.textSecondary,
fontSize: fonts.sizes.md,
lineHeight: 22,
},
identifierText: {
color: colors.accent,
fontWeight: fonts.weights.semibold,
},
// Toggle
toggle: {
flexDirection: 'row',
backgroundColor: colors.surface,
borderRadius: borderRadius.md,
padding: 4,
marginBottom: spacing.xl,
borderWidth: 1,
borderColor: colors.border,
},
toggleBtn: {
flex: 1,
paddingVertical: spacing.sm + 2,
borderRadius: borderRadius.md - 2,
alignItems: 'center',
},
toggleBtnActive: {
backgroundColor: colors.primary,
},
toggleText: {
color: colors.textMuted,
fontSize: fonts.sizes.sm,
fontWeight: fonts.weights.semibold,
},
toggleTextActive: {
color: colors.text,
},
// Form
form: {
marginBottom: spacing.xl,
},
button: {
marginTop: spacing.md,
},
// Footer
footer: {
flexDirection: 'row',
justifyContent: 'center',
@@ -149,4 +405,51 @@ const styles = StyleSheet.create({
fontSize: fonts.sizes.sm,
fontWeight: fonts.weights.semibold,
},
// OTP
otpRow: {
flexDirection: 'row',
justifyContent: 'center',
gap: spacing.sm,
marginBottom: spacing.xl,
},
otpBox: {
width: 48,
height: 56,
backgroundColor: colors.surface,
borderRadius: borderRadius.md,
borderWidth: 1.5,
borderColor: colors.border,
color: colors.text,
fontSize: fonts.sizes.xl,
fontWeight: fonts.weights.bold,
textAlign: 'center',
},
otpBoxFilled: {
borderColor: colors.primary,
backgroundColor: 'rgba(108, 99, 255, 0.06)',
},
verifyBtn: {
marginBottom: spacing.lg,
},
resendRow: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
marginBottom: spacing.lg,
},
resendText: {
color: colors.textSecondary,
fontSize: fonts.sizes.sm,
},
resendTimer: {
color: colors.textMuted,
fontSize: fonts.sizes.sm,
},
resendLink: {
color: colors.primary,
fontSize: fonts.sizes.sm,
fontWeight: fonts.weights.semibold,
},
backBtn: {},
});
+8 -4
View File
@@ -32,10 +32,14 @@ export default function SplashScreen({ navigation }) {
try {
const start = Date.now();
// Check onboarding + session in parallel
await Promise.all([
useAppStore.getState().initApp(),
useAuthStore.getState().fetchSession(),
// Check onboarding + session in parallel (with timeout to prevent hang)
const timeout = new Promise((r) => setTimeout(r, 5000));
await Promise.race([
Promise.all([
useAppStore.getState().initApp(),
useAuthStore.getState().fetchSession(),
]),
timeout,
]);
const onboardingDone = useAppStore.getState().onboardingDone;
+409 -88
View File
@@ -1,33 +1,206 @@
import React, { useState, useRef, useCallback } from 'react';
import { View, Text, StyleSheet, Animated } from 'react-native';
import { View, Text, StyleSheet, Animated, Dimensions, Pressable, Modal } from 'react-native';
import { useFocusEffect } from '@react-navigation/native';
import ScreenWrapper from '../components/ScreenWrapper';
import FadeIn from '../components/FadeIn';
import NovaAd from '../components/NovaAd';
import useIdentityStore from '../store/useIdentityStore';
import useHabitStore from '../store/useHabitStore';
import { colors, fonts, spacing, borderRadius } from '../utils/theme';
import { getDayPhase } from '../utils/helpers';
import { getDayPhase, formatDate } from '../utils/helpers';
function StatCard({ label, value, maxValue, color }) {
const percentage = maxValue > 0 ? Math.min((value / maxValue) * 100, 100) : 0;
// Motivational messages based on performance
function getMotivation(pct, streak) {
if (pct >= 90) return { emoji: '🔥', msg: "You're on fire. Keep this energy!" };
if (pct >= 70) return { emoji: '💪', msg: 'Solid progress. You are becoming someone new.' };
if (pct >= 50) return { emoji: '🌱', msg: "Halfway there. Don't stop now." };
if (streak >= 3) return { emoji: '⚡', msg: `${streak}-day streak! Momentum is building.` };
if (pct >= 20) return { emoji: '🚀', msg: 'Every step forward counts. Keep going.' };
return { emoji: '✨', msg: 'Your journey has begun. Show up today.' };
}
// Circular progress indicator
function CircleProgress({ value, max, size, color, label, icon }) {
const pct = max > 0 ? Math.min(value / max, 1) : 0;
const displayPct = Math.round(pct * 100);
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 style={[cp.container, { width: size }]}>
<View style={[cp.ring, { width: size, height: size, borderRadius: size / 2, borderColor: `${color}20` }]}>
{/* Fill indicator */}
<View style={[cp.fillBg, { width: size - 8, height: size - 8, borderRadius: (size - 8) / 2 }]}>
<Text style={cp.icon}>{icon}</Text>
<Text style={[cp.pct, { color }]}>{displayPct}%</Text>
</View>
</View>
<Text style={cp.label}>{label}</Text>
<Text style={[cp.value, { color }]}>{value}</Text>
</View>
);
}
const cp = StyleSheet.create({
container: { alignItems: 'center' },
ring: { borderWidth: 3, alignItems: 'center', justifyContent: 'center', marginBottom: 6 },
fillBg: { backgroundColor: colors.surface, alignItems: 'center', justifyContent: 'center' },
icon: { fontSize: 16, marginBottom: 2 },
pct: { fontSize: fonts.sizes.sm, fontWeight: fonts.weights.bold },
label: { color: colors.textSecondary, fontSize: 10, fontWeight: fonts.weights.medium },
value: { fontSize: fonts.sizes.sm, fontWeight: fonts.weights.bold, marginTop: 1 },
});
const SCREEN_W = Dimensions.get('window').width;
const HEAT_PAD = spacing.md * 2 + spacing.md * 2; // scroll padding + card padding
const HEAT_GAP = 5;
const HEAT_COLS = 7;
const HEAT_CELL = Math.floor((SCREEN_W - HEAT_PAD - HEAT_GAP * (HEAT_COLS - 1)) / HEAT_COLS);
const MOOD_EMOJIS = { great: '😄', good: '🙂', okay: '😐', bad: '😔', terrible: '😞' };
const CHECK_LABELS = { yes: { text: 'Yes', color: colors.success }, almost: { text: 'Almost', color: colors.warning }, no: { text: 'No', color: colors.error } };
function HeatCell({ dayNum, log, currentDay, onPress }) {
const check = log?.identity_check;
const isCurrent = dayNum === currentDay;
const isFuture = dayNum > currentDay;
const hasData = !!check;
const bg = check === 'yes' ? colors.success
: check === 'almost' ? colors.warning
: check === 'no' ? colors.error
: isFuture ? 'rgba(28,35,69,0.4)' : colors.surfaceLight;
const cell = (
<View style={[hm.cell, { width: HEAT_CELL, height: HEAT_CELL, backgroundColor: bg }, isCurrent && hm.cellCurrent]}>
<Text style={[hm.cellDay, isCurrent && hm.cellDayCurrent]}>{dayNum}</Text>
{check && <Text style={hm.cellIcon}>{check === 'yes' ? '✓' : check === 'almost' ? '~' : '✗'}</Text>}
</View>
);
if (hasData && onPress) {
return <Pressable onPress={() => onPress(dayNum, log)} style={({ pressed }) => pressed && { opacity: 0.6 }}>{cell}</Pressable>;
}
return cell;
}
function WeekRow({ logs, weekNum, currentDay, onDayPress }) {
return (
<View style={hm.row}>
{Array.from({ length: HEAT_COLS }, (_, i) => {
const dayNum = weekNum * 7 + i + 1;
if (dayNum > 40) return <View key={dayNum} style={[hm.cell, { width: HEAT_CELL, height: HEAT_CELL, backgroundColor: 'transparent' }]} />;
const log = logs.find((l) => l.day_number === dayNum);
return <HeatCell key={dayNum} dayNum={dayNum} log={log} currentDay={currentDay} onPress={onDayPress} />;
})}
</View>
);
}
function DayDetailModal({ visible, log, onClose }) {
if (!log) return null;
const check = CHECK_LABELS[log.identity_check] || { text: '—', color: colors.textMuted };
return (
<Modal visible={visible} transparent animationType="fade" statusBarTranslucent onRequestClose={onClose}>
<Pressable style={dm.overlay} onPress={onClose}>
<Pressable style={dm.card} onPress={(e) => e.stopPropagation()}>
{/* Header */}
<View style={dm.header}>
<Text style={dm.dayLabel}>DAY {log.day_number}</Text>
<Text style={dm.date}>{log.date}</Text>
</View>
{/* Identity Check */}
<View style={dm.row}>
<Text style={dm.label}>Identity Check</Text>
<Text style={[dm.value, { color: check.color }]}>{check.text}</Text>
</View>
{/* Mood */}
{log.mood && (
<View style={dm.row}>
<Text style={dm.label}>Mood</Text>
<Text style={dm.value}>{MOOD_EMOJIS[log.mood] || ''} {log.mood}</Text>
</View>
)}
{/* AI Reflection */}
{log.ai_title && (
<View style={dm.aiCard}>
<Text style={dm.aiLabel}> AI Reflection</Text>
<Text style={dm.aiTitle}>{log.ai_title}</Text>
{log.ai_summary && <Text style={dm.aiSummary}>{log.ai_summary}</Text>}
{log.ai_quote && <Text style={dm.aiQuote}>"{log.ai_quote}"</Text>}
</View>
)}
{/* Journal entries */}
{log.win && <JournalRow label="What went well" value={log.win} />}
{log.struggle && <JournalRow label="What was difficult" value={log.struggle} />}
{log.highlight && <JournalRow label="Highlight" value={log.highlight} />}
{log.note && <JournalRow label="Reflection" value={log.note} />}
{/* No journal data */}
{!log.ai_title && !log.win && !log.note && (
<Text style={dm.noData}>No journal entry for this day.</Text>
)}
{/* Close */}
<Pressable style={dm.closeBtn} onPress={onClose}>
<Text style={dm.closeBtnText}>Close</Text>
</Pressable>
</Pressable>
</Pressable>
</Modal>
);
}
function JournalRow({ label, value }) {
return (
<View style={dm.journalRow}>
<Text style={dm.journalLabel}>{label}</Text>
<Text style={dm.journalValue}>{value}</Text>
</View>
);
}
const dm = StyleSheet.create({
overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.7)', alignItems: 'center', justifyContent: 'center', padding: spacing.lg },
card: { backgroundColor: colors.surface, borderRadius: borderRadius.xl, padding: spacing.lg, width: '100%', maxWidth: 360, maxHeight: '80%', borderWidth: 1, borderColor: colors.border },
header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: spacing.lg },
dayLabel: { color: colors.accent, fontSize: fonts.sizes.md, fontWeight: fonts.weights.bold, letterSpacing: 2 },
date: { color: colors.textMuted, fontSize: fonts.sizes.sm },
row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: spacing.sm, borderBottomWidth: 1, borderBottomColor: colors.border },
label: { color: colors.textSecondary, fontSize: fonts.sizes.sm },
value: { color: colors.text, fontSize: fonts.sizes.sm, fontWeight: fonts.weights.semibold },
aiCard: { backgroundColor: 'rgba(108,99,255,0.06)', borderRadius: borderRadius.md, padding: spacing.md, marginTop: spacing.md, borderWidth: 1, borderColor: 'rgba(108,99,255,0.15)' },
aiLabel: { color: colors.primaryLight, fontSize: 10, fontWeight: fonts.weights.medium, marginBottom: spacing.sm },
aiTitle: { color: colors.text, fontSize: fonts.sizes.md, fontWeight: fonts.weights.bold, marginBottom: spacing.xs },
aiSummary: { color: colors.textSecondary, fontSize: fonts.sizes.sm, lineHeight: 20, marginBottom: spacing.sm },
aiQuote: { color: colors.accent, fontSize: fonts.sizes.sm, fontStyle: 'italic', lineHeight: 20 },
journalRow: { marginTop: spacing.md },
journalLabel: { color: colors.textMuted, fontSize: 10, fontWeight: fonts.weights.medium, marginBottom: 3 },
journalValue: { color: colors.textSecondary, fontSize: fonts.sizes.sm, lineHeight: 20 },
noData: { color: colors.textMuted, fontSize: fonts.sizes.sm, fontStyle: 'italic', textAlign: 'center', marginTop: spacing.md },
closeBtn: { marginTop: spacing.lg, backgroundColor: colors.primary, borderRadius: borderRadius.md, paddingVertical: spacing.md, alignItems: 'center' },
closeBtnText: { color: colors.text, fontSize: fonts.sizes.md, fontWeight: fonts.weights.semibold },
});
const hm = StyleSheet.create({
row: { flexDirection: 'row', gap: HEAT_GAP, marginBottom: HEAT_GAP },
cell: { borderRadius: 8, alignItems: 'center', justifyContent: 'center' },
cellCurrent: { borderWidth: 2, borderColor: colors.accent },
cellDay: { color: 'rgba(255,255,255,0.7)', fontSize: 10, fontWeight: fonts.weights.medium },
cellDayCurrent: { color: colors.accent, fontWeight: fonts.weights.bold },
cellIcon: { color: 'rgba(255,255,255,0.9)', fontSize: 8, marginTop: 1 },
});
export default function StatsScreen() {
const currentDay = useIdentityStore((s) => s.currentDay);
const loadStats = useIdentityStore((s) => s.loadStats);
const loadDailyLogs = useIdentityStore((s) => s.loadDailyLogs);
const identity = useIdentityStore((st) => st.identity);
const currentDay = useIdentityStore((st) => st.currentDay);
const loadStats = useIdentityStore((st) => st.loadStats);
const loadDailyLogs = useIdentityStore((st) => st.loadDailyLogs);
const habits = useHabitStore((st) => st.habits);
const [stats, setStats] = useState(null);
const [dailyLogs, setDailyLogs] = useState([]);
const [loading, setLoading] = useState(true);
const [focusCount, setFocusCount] = useState(0);
const [selectedLog, setSelectedLog] = useState(null);
const fadeAnim = useRef(new Animated.Value(0)).current;
useFocusEffect(
@@ -37,9 +210,8 @@ export default function StatsScreen() {
setLoading(true);
const [s, logs] = await Promise.all([loadStats(), loadDailyLogs()]);
if (active) {
setStats(s);
setDailyLogs(logs);
setLoading(false);
setStats(s); setDailyLogs(logs); setLoading(false);
setFocusCount((c) => c + 1);
Animated.timing(fadeAnim, { toValue: 1, duration: 500, useNativeDriver: true }).start();
}
};
@@ -48,107 +220,256 @@ export default function StatsScreen() {
}, [])
);
const handleDayPress = useCallback((_dayNum, log) => {
setSelectedLog(log);
}, []);
if (loading) {
return (
<ScreenWrapper>
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>Loading stats...</Text>
</View>
</ScreenWrapper>
);
return (<ScreenWrapper><View style={st.loadWrap}><Text style={st.loadText}>Loading your progress...</Text></View></ScreenWrapper>);
}
const maxScore = currentDay * 3;
const disc = stats?.discipline_score || 0;
const foc = stats?.focus_score || 0;
const cons = stats?.consistency_score || 0;
const maxScore = Math.max(currentDay * 3, 1);
const totalScore = disc + foc + cons;
const totalMax = maxScore * 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 logged = dailyLogs.length;
const loggedPct = currentDay > 0 ? Math.round((logged / currentDay) * 100) : 0;
const alignPct = logged > 0 ? Math.round((yesCount / logged) * 100) : 0;
const sorted = [...dailyLogs].sort((a, b) => b.day_number - a.day_number);
let streakDays = 0;
let streak = 0;
for (const log of sorted) {
if (log.identity_check === 'yes' || log.identity_check === 'almost') streakDays++;
if (log.identity_check === 'yes' || log.identity_check === 'almost') streak++;
else break;
}
const phase = getDayPhase(currentDay);
const motivation = getMotivation(alignPct, streak);
const weeks = Math.ceil(40 / 7);
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>
<Animated.ScrollView style={[st.flex, { opacity: fadeAnim }]} contentContainerStyle={st.scroll} showsVerticalScrollIndicator={false}>
<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} />
{/* ── Header ── */}
<FadeIn delay={0} trigger={focusCount}>
<View style={st.header}>
<Text style={st.phase}>{phase.toUpperCase()} PHASE</Text>
<Text style={st.title}>Day {currentDay} of 40</Text>
{identity?.title && <Text style={st.identity}>{identity.title}</Text>}
</View>
</FadeIn>
{/* Total */}
<View style={styles.totalCard}>
<Text style={styles.totalLabel}>Total Score</Text>
<Text style={styles.totalValue}>{totalScore}</Text>
</View>
{/* ── Motivation ── */}
<FadeIn delay={80} trigger={focusCount}>
<View style={st.motivCard}>
<Text style={st.motivEmoji}>{motivation.emoji}</Text>
<Text style={st.motivMsg}>{motivation.msg}</Text>
</View>
</FadeIn>
{/* 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>
{/* ── Score Rings ── */}
<FadeIn delay={160} trigger={focusCount}><View style={st.ringsRow}>
<CircleProgress value={disc} max={maxScore} size={85} color={colors.primary} label="Discipline" icon="🎯" />
<CircleProgress value={foc} max={maxScore} size={85} color={colors.accent} label="Focus" icon="🧠" />
<CircleProgress value={cons} max={Math.max(currentDay * 2, 1)} size={85} color={colors.success} label="Consistency" icon="⚡" />
</View></FadeIn>
{/* ── Total Score ── */}
<FadeIn delay={240} trigger={focusCount}><View style={st.totalCard}>
<View>
<Text style={st.totalLabel}>Total Score</Text>
<Text style={st.totalSub}>{Math.round((totalScore / totalMax) * 100)}% of maximum</Text>
</View>
<Text style={st.totalValue}>{totalScore}</Text>
</View></FadeIn>
{/* ── Quick Stats Grid ── */}
<FadeIn delay={320} trigger={focusCount}><View style={st.gridRow}>
<View style={st.gridItem}>
<Text style={st.gridEmoji}>🔥</Text>
<Text style={st.gridValue}>{streak}</Text>
<Text style={st.gridLabel}>Streak</Text>
</View>
<View style={st.gridItem}>
<Text style={st.gridEmoji}>📅</Text>
<Text style={st.gridValue}>{logged}</Text>
<Text style={st.gridLabel}>Days Logged</Text>
</View>
<View style={st.gridItem}>
<Text style={st.gridEmoji}>🎯</Text>
<Text style={st.gridValue}>{loggedPct}%</Text>
<Text style={st.gridLabel}>Show-up Rate</Text>
</View>
<View style={st.gridItem}>
<Text style={st.gridEmoji}>📋</Text>
<Text style={st.gridValue}>{habits.length}</Text>
<Text style={st.gridLabel}>Habits</Text>
</View>
</View></FadeIn>
<NovaAd />
{/* ── Identity Alignment ── */}
<FadeIn delay={400} trigger={focusCount}><View style={st.alignCard}>
<Text style={st.alignTitle}>How aligned are you?</Text>
<View style={st.alignBar}>
{yesCount > 0 && <View style={[st.alignSeg, { flex: yesCount, backgroundColor: colors.success }]} />}
{almostCount > 0 && <View style={[st.alignSeg, { flex: almostCount, backgroundColor: colors.warning }]} />}
{noCount > 0 && <View style={[st.alignSeg, { flex: noCount, backgroundColor: colors.error }]} />}
{logged === 0 && <View style={[st.alignSeg, { flex: 1, backgroundColor: colors.surfaceLight }]} />}
</View>
<View style={st.alignLegend}>
<View style={st.alignLegendItem}>
<View style={[st.alignDot, { backgroundColor: colors.success }]} />
<Text style={st.alignLegendText}>Yes {yesCount}</Text>
</View>
<View style={styles.summaryItem}>
<Text style={[styles.summaryValue, { color: colors.warning }]}>{almostCount}</Text>
<Text style={styles.summaryLabel}>Almost</Text>
<View style={st.alignLegendItem}>
<View style={[st.alignDot, { backgroundColor: colors.warning }]} />
<Text style={st.alignLegendText}>Almost {almostCount}</Text>
</View>
<View style={styles.summaryItem}>
<Text style={[styles.summaryValue, { color: colors.error }]}>{noCount}</Text>
<Text style={styles.summaryLabel}>No</Text>
<View style={st.alignLegendItem}>
<View style={[st.alignDot, { backgroundColor: colors.error }]} />
<Text style={st.alignLegendText}>No {noCount}</Text>
</View>
</View>
</View>
</View></FadeIn>
{/* ── 40-Day Heatmap ── */}
<FadeIn delay={480} trigger={focusCount}><View style={st.heatCard}>
<View style={st.heatHeader}>
<Text style={st.heatTitle}>Your 40-Day Map</Text>
<Text style={st.heatProgress}>Day {currentDay}/40</Text>
</View>
{/* Day labels */}
<View style={[hm.row, { marginBottom: 2 }]}>
{['M', 'T', 'W', 'T', 'F', 'S', 'S'].map((d, i) => (
<View key={i} style={{ width: HEAT_CELL, alignItems: 'center' }}>
<Text style={st.heatDayLabel}>{d}</Text>
</View>
))}
</View>
{/* Grid */}
{Array.from({ length: weeks }, (_, w) => (
<WeekRow key={w} logs={dailyLogs} weekNum={w} currentDay={currentDay} onDayPress={handleDayPress} />
))}
{/* Legend */}
<View style={st.heatLegend}>
<View style={st.heatLegendItem}>
<View style={[st.heatLegendBox, { backgroundColor: colors.surfaceLight }]} />
<Text style={st.heatLegendText}>Pending</Text>
</View>
<View style={st.heatLegendItem}>
<View style={[st.heatLegendBox, { backgroundColor: colors.success }]} />
<Text style={st.heatLegendText}>Yes</Text>
</View>
<View style={st.heatLegendItem}>
<View style={[st.heatLegendBox, { backgroundColor: colors.warning }]} />
<Text style={st.heatLegendText}>Almost</Text>
</View>
<View style={st.heatLegendItem}>
<View style={[st.heatLegendBox, { backgroundColor: colors.error }]} />
<Text style={st.heatLegendText}>No</Text>
</View>
<View style={st.heatLegendItem}>
<View style={[st.heatLegendBox, { borderWidth: 1.5, borderColor: colors.accent, backgroundColor: 'transparent' }]} />
<Text style={st.heatLegendText}>Today</Text>
</View>
</View>
</View></FadeIn>
{/* ── Journey Info ── */}
{identity && (
<FadeIn delay={560} trigger={focusCount}><View style={st.journeyCard}>
<Text style={st.journeyLabel}>Your journey</Text>
<Text style={st.journeyIdentity}>{identity.title}</Text>
<View style={st.journeySep} />
<View style={st.journeyRow}>
<Text style={st.journeyMeta}>Started {formatDate(identity.start_date)}</Text>
<Text style={st.journeyMeta}>{40 - currentDay} days to go</Text>
</View>
</View></FadeIn>
)}
{/* Streak */}
<View style={styles.streakCard}>
<Text style={styles.streakNumber}>{streakDays}</Text>
<Text style={styles.streakLabel}>Day Streak</Text>
</View>
</Animated.ScrollView>
<DayDetailModal
visible={!!selectedLog}
log={selectedLog}
onClose={() => setSelectedLog(null)}
/>
</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 },
const st = StyleSheet.create({
flex: { flex: 1 },
scroll: { padding: spacing.md, paddingBottom: spacing.xxl * 2 },
loadWrap: { flex: 1, alignItems: 'center', justifyContent: 'center' },
loadText: { color: colors.textSecondary, fontSize: fonts.sizes.md },
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 },
// Header
header: { alignItems: 'center', marginBottom: spacing.md },
phase: { color: colors.accent, fontSize: 10, fontWeight: fonts.weights.bold, letterSpacing: 3 },
title: { color: colors.text, fontSize: fonts.sizes.xl, fontWeight: fonts.weights.bold, marginTop: spacing.xs },
identity: { color: colors.textMuted, fontSize: fonts.sizes.xs, fontStyle: 'italic', marginTop: 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 },
// Motivation
motivCard: { flexDirection: 'row', alignItems: 'center', backgroundColor: 'rgba(108,99,255,0.08)', borderRadius: borderRadius.md, padding: spacing.md, marginBottom: spacing.md, borderWidth: 1, borderColor: 'rgba(108,99,255,0.15)' },
motivEmoji: { fontSize: 24, marginRight: spacing.sm },
motivMsg: { color: colors.text, fontSize: fonts.sizes.sm, flex: 1, lineHeight: 20 },
// Rings
ringsRow: { flexDirection: 'row', justifyContent: 'space-around', marginBottom: spacing.md },
// Total
totalCard: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', backgroundColor: colors.surface, borderRadius: borderRadius.md, padding: spacing.md, marginBottom: spacing.md, borderWidth: 1, borderColor: colors.accent },
totalLabel: { color: colors.text, fontSize: fonts.sizes.sm, fontWeight: fonts.weights.semibold },
totalSub: { color: colors.textMuted, fontSize: 10, marginTop: 1 },
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 },
// Grid
gridRow: { flexDirection: 'row', gap: 6, marginBottom: spacing.md },
gridItem: { flex: 1, backgroundColor: colors.surface, borderRadius: borderRadius.md, padding: spacing.sm, alignItems: 'center', borderWidth: 1, borderColor: colors.border },
gridEmoji: { fontSize: 18, marginBottom: 4 },
gridValue: { color: colors.text, fontSize: fonts.sizes.md, fontWeight: fonts.weights.bold },
gridLabel: { color: colors.textMuted, fontSize: 8, marginTop: 2 },
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 },
// Alignment
alignCard: { backgroundColor: colors.surface, borderRadius: borderRadius.md, padding: spacing.md, marginBottom: spacing.md, borderWidth: 1, borderColor: colors.border },
alignTitle: { color: colors.text, fontSize: fonts.sizes.sm, fontWeight: fonts.weights.semibold, marginBottom: spacing.sm },
alignBar: { flexDirection: 'row', height: 8, borderRadius: 4, overflow: 'hidden', marginBottom: spacing.sm },
alignSeg: { height: '100%' },
alignLegend: { flexDirection: 'row', justifyContent: 'center', gap: spacing.md },
alignLegendItem: { flexDirection: 'row', alignItems: 'center' },
alignDot: { width: 8, height: 8, borderRadius: 4, marginRight: 4 },
alignLegendText: { color: colors.textMuted, fontSize: 10 },
// Heatmap
heatCard: { backgroundColor: colors.surface, borderRadius: borderRadius.md, padding: spacing.md, marginBottom: spacing.md, borderWidth: 1, borderColor: colors.border },
heatHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: spacing.sm },
heatTitle: { color: colors.text, fontSize: fonts.sizes.md, fontWeight: fonts.weights.bold },
heatProgress: { color: colors.accent, fontSize: fonts.sizes.xs, fontWeight: fonts.weights.semibold },
heatDayLabel: { color: colors.textMuted, fontSize: 9, fontWeight: fonts.weights.medium },
heatLegend: { flexDirection: 'row', justifyContent: 'center', gap: spacing.md, marginTop: spacing.md },
heatLegendItem: { flexDirection: 'row', alignItems: 'center', gap: 4 },
heatLegendText: { color: colors.textMuted, fontSize: 9 },
heatLegendBox: { width: 12, height: 12, borderRadius: 3 },
// Journey
journeyCard: { backgroundColor: colors.surface, borderRadius: borderRadius.md, padding: spacing.md, borderWidth: 1, borderColor: 'rgba(108,99,255,0.2)' },
journeyLabel: { color: colors.primary, fontSize: 9, fontWeight: fonts.weights.bold, letterSpacing: 2, marginBottom: spacing.sm },
journeyIdentity: { color: colors.text, fontSize: fonts.sizes.md, fontWeight: fonts.weights.semibold },
journeySep: { height: 1, backgroundColor: colors.border, marginVertical: spacing.sm },
journeyRow: { flexDirection: 'row', justifyContent: 'space-between' },
journeyMeta: { color: colors.textMuted, fontSize: 10 },
});
+149
View File
@@ -0,0 +1,149 @@
import React from 'react';
import { View, Text, StyleSheet, ScrollView } from 'react-native';
import ScreenWrapper from '../components/ScreenWrapper';
import { colors, fonts, spacing } from '../utils/theme';
function Section({ title, children }) {
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{title}</Text>
{children}
</View>
);
}
function Para({ children }) {
return <Text style={styles.para}>{children}</Text>;
}
function Bullet({ children }) {
return (
<View style={styles.bulletRow}>
<Text style={styles.bulletDot}></Text>
<Text style={styles.bulletText}>{children}</Text>
</View>
);
}
export default function TermsScreen() {
return (
<ScreenWrapper>
<ScrollView style={styles.scroll} contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
<Text style={styles.title}>Terms & Conditions</Text>
<Text style={styles.updated}>Last updated: April 29, 2026</Text>
<Section title="1. Acceptance of Terms">
<Para>
By downloading, installing, or using Nova40, you agree to be bound by these Terms and Conditions. If you do not agree, please do not use the application.
</Para>
</Section>
<Section title="2. Description of Service">
<Para>
Nova40 is a 40-day identity transformation app that helps users build habits, track progress, and reflect on personal growth through AI-powered insights, journaling, and gamification.
</Para>
</Section>
<Section title="3. User Accounts">
<Para>To use Nova40, you must:</Para>
<Bullet>Create an account with a valid email address</Bullet>
<Bullet>Maintain the security of your account credentials</Bullet>
<Bullet>Be at least 13 years of age</Bullet>
<Para>
You are responsible for all activity that occurs under your account.
</Para>
</Section>
<Section title="4. User Content">
<Para>
You retain ownership of all content you create in Nova40, including your stories, journal entries, and reflections. By using the AI features, you grant us permission to process your content through third-party AI services for the purpose of generating personalized responses.
</Para>
</Section>
<Section title="5. AI-Generated Content">
<Para>
Nova40 uses artificial intelligence to generate identity statements, habit suggestions, reflections, and transformation stories. You acknowledge that:
</Para>
<Bullet>AI-generated content is for guidance only, not professional advice</Bullet>
<Bullet>AI responses may not always be accurate or appropriate</Bullet>
<Bullet>You should not rely solely on AI content for mental health decisions</Bullet>
</Section>
<Section title="6. Acceptable Use">
<Para>You agree not to:</Para>
<Bullet>Use the app for any illegal or unauthorized purpose</Bullet>
<Bullet>Attempt to hack, reverse engineer, or disrupt the service</Bullet>
<Bullet>Submit content that is harmful, abusive, or violates others' rights</Bullet>
<Bullet>Create multiple accounts to abuse free features</Bullet>
</Section>
<Section title="7. Intellectual Property">
<Para>
The Nova40 app, including its design, code, logos, and branding, is owned by Nova40 and protected by intellectual property laws. You may not copy, modify, or distribute any part of the application without written permission.
</Para>
</Section>
<Section title="8. Disclaimer of Warranties">
<Para>
Nova40 is provided "as is" without warranties of any kind. We do not guarantee that the app will be uninterrupted, error-free, or that AI-generated content will meet your expectations.
</Para>
</Section>
<Section title="9. Limitation of Liability">
<Para>
To the maximum extent permitted by law, Nova40 shall not be liable for any indirect, incidental, or consequential damages arising from your use of the application.
</Para>
</Section>
<Section title="10. Health Disclaimer">
<Para>
Nova40 is a personal development tool and is not a substitute for professional medical, psychological, or therapeutic advice. If you are experiencing mental health issues, please consult a qualified professional.
</Para>
</Section>
<Section title="11. Data and Privacy">
<Para>
Your use of Nova40 is also governed by our Privacy Policy. Please review it to understand how we collect and use your information.
</Para>
</Section>
<Section title="12. Termination">
<Para>
We reserve the right to suspend or terminate your account at any time for violation of these terms. You may delete your account at any time through the app settings.
</Para>
</Section>
<Section title="13. Changes to Terms">
<Para>
We may update these Terms and Conditions from time to time. Continued use of the app after changes constitutes acceptance of the updated terms.
</Para>
</Section>
<Section title="14. Governing Law">
<Para>
These terms are governed by the laws of the Republic of Indonesia. Any disputes shall be resolved in the courts of Indonesia.
</Para>
</Section>
<Section title="15. Contact">
<Para>
For questions about these Terms, contact us at: support@nova40.app
</Para>
</Section>
</ScrollView>
</ScreenWrapper>
);
}
const styles = StyleSheet.create({
scroll: { flex: 1 },
content: { padding: spacing.lg, paddingBottom: spacing.xxl * 2 },
title: { color: colors.text, fontSize: fonts.sizes.xxl, fontWeight: fonts.weights.bold, marginBottom: spacing.xs },
updated: { color: colors.textMuted, fontSize: fonts.sizes.sm, marginBottom: spacing.xl },
section: { marginBottom: spacing.xl },
sectionTitle: { color: colors.text, fontSize: fonts.sizes.md, fontWeight: fonts.weights.bold, marginBottom: spacing.sm },
para: { color: colors.textSecondary, fontSize: fonts.sizes.sm, lineHeight: 22, marginBottom: spacing.sm },
bulletRow: { flexDirection: 'row', paddingLeft: spacing.sm, marginBottom: spacing.xs },
bulletDot: { color: colors.primary, fontSize: fonts.sizes.sm, marginRight: spacing.sm, lineHeight: 22 },
bulletText: { color: colors.textSecondary, fontSize: fonts.sizes.sm, lineHeight: 22, flex: 1 },
});
+80 -71
View File
@@ -1,9 +1,6 @@
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';
import { AI_SERVICE_URL, AI_MODEL } from '../config/keys';
function buildPrompt(story) {
return `You are a thoughtful and emotionally intelligent personal growth coach.
@@ -15,6 +12,15 @@ Your job is to deeply understand the person and reflect their intention back in
---
LANGUAGE RULE (CRITICAL):
Detect the language of the user's story.
Your ENTIRE output (identity_title, identity_summary, ALL habits) MUST be in the SAME language as the user's story.
If the story is in Indonesian, respond in Indonesian.
If the story is in English, respond in English.
If the story is in any other language, respond in that language.
---
INSTRUCTIONS:
1. Read the user's story carefully.
@@ -28,7 +34,6 @@ INSTRUCTIONS:
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
@@ -36,25 +41,25 @@ B. Identity Summary
- 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:
C. Suggested Habits — generate exactly 20 habit objects
- The FIRST 5 are TOP PRIORITY (most important, most aligned with story)
- The remaining 15 are additional recommendations
- All 20 must be simple, actionable, and doable daily
- Habits should feel like "small proof of identity"
- Not tasks, but expressions of who they want to become
- Make each habit unique — no duplicates
Each habit MUST be an object with these fields:
- "title": short habit name (max 8 words)
- "best_time": recommended time of day to do it (e.g. "06:00", "07:30", "12:00", "18:00", "21:00", "05:30"). Use 24-hour format. Pick a realistic time that fits the activity.
- "duration": how long per session in minutes as a number (e.g. 5, 10, 15, 20, 30, 45, 60). Must be a number, not a string.
- "frequency": how often (e.g. "Every day", "3x per week", "Every morning", "Every evening", "Weekdays only")
- "category": one of: "Mind", "Body", "Emotion", "Social", "Skill", "Discipline", "Health", "Creative"
- "difficulty": one of: "Easy", "Medium", "Hard"
- "why": 1 short sentence explaining why this habit matters for their identity
D. Tone Style:
- Warm
- Supportive
- Slightly motivational
- Never robotic
- Never overly formal
- Warm, supportive, slightly motivational
- Never robotic or overly formal
- Avoid buzzwords like "optimize", "maximize"
---
@@ -65,86 +70,78 @@ Return ONLY valid JSON. No explanation, no markdown, no extra text.
{
"identity_title": "",
"identity_summary": "",
"priority_habits": [
{
"title": "",
"best_time": "06:00",
"duration": 15,
"frequency": "",
"category": "",
"difficulty": "",
"why": ""
}
],
"suggested_habits": [
"",
"",
"",
"",
""
{
"title": "",
"best_time": "07:00",
"duration": 10,
"frequency": "",
"category": "",
"difficulty": "",
"why": ""
}
]
}
---
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}`, {
async function callAI(prompt) {
const response = await fetch(`${AI_SERVICE_URL}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: buildPrompt(story) }] }],
generationConfig: {
temperature: 0.7,
topP: 0.9,
maxOutputTokens: 1024,
},
model: AI_MODEL,
max_tokens: 2048,
messages: [{ role: 'user', content: prompt }],
}),
});
// 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);
console.warn('AI API error:', response.status, errText);
throw new Error(`AI request failed (${response.status})`);
}
return response.json();
const json = await response.json();
return json?.choices?.[0]?.message?.content || '';
}
/**
* Generate identity + habits from user's personal story using Gemini AI.
* Retries on rate limit. Falls back to local generation if AI fails.
* Generate identity + habits from user's personal story using AI.
* 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 rawText = await callAI(buildPrompt(story));
const parsed = safeParseAI(rawText);
if (
parsed.identity_title &&
parsed.identity_summary &&
parsed.suggested_habits.length > 0
(parsed.priority_habits?.length > 0 || parsed.suggested_habits?.length > 0)
) {
return {
...parsed,
suggested_habits: parsed.suggested_habits.slice(0, 8),
identity_title: parsed.identity_title,
identity_summary: parsed.identity_summary,
priority_habits: (parsed.priority_habits || []).slice(0, 5),
suggested_habits: (parsed.suggested_habits || []).slice(0, 15),
source: 'ai',
};
}
@@ -189,18 +186,30 @@ function generateFallback(story) {
}
}
// Generate varied habits from the story text
let habits = getSuggestionsForIdentity(words);
if (habits.length < 3) {
// Also try with the extracted title
// Generate varied habits from the story text and convert to rich objects
let habitTitles = getSuggestionsForIdentity(words);
if (habitTitles.length < 8) {
const titleHabits = getSuggestionsForIdentity(title);
titleHabits.forEach((h) => { if (!habits.includes(h)) habits.push(h); });
titleHabits.forEach((h) => { if (!habitTitles.includes(h)) habitTitles.push(h); });
}
const times = ['06:00', '06:30', '07:00', '07:30', '08:00', '12:00', '17:00', '18:00', '19:00', '20:00', '21:00', '21:30'];
const durations = [5, 10, 15, 20, 30, 10, 15, 20, 5, 10];
const toRichHabit = (t, i) => ({
title: t,
best_time: times[i % times.length],
duration: durations[i % durations.length],
frequency: i < 5 ? 'Every day' : ['Every day', '3x per week', 'Every morning', 'Every evening', 'Weekdays only'][i % 5],
category: ['Discipline', 'Body', 'Mind', 'Emotion', 'Health', 'Skill', 'Social', 'Creative'][i % 8],
difficulty: i < 3 ? 'Easy' : i < 7 ? 'Medium' : 'Hard',
why: '',
});
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),
priority_habits: habitTitles.slice(0, 5).map(toRichHabit),
suggested_habits: habitTitles.slice(5, 20).map((t, i) => toRichHabit(t, i + 5)),
source: 'fallback',
};
}
+94
View File
@@ -0,0 +1,94 @@
let analytics = null;
try {
analytics = require('@react-native-firebase/analytics').default;
analytics();
} catch (_) {
analytics = null;
}
const isAvailable = !!analytics;
/**
* Log a screen view.
*/
export function logScreenView(screenName) {
if (!isAvailable) return;
try {
analytics().logScreenView({ screen_name: screenName, screen_class: screenName });
} catch (_) {}
}
/**
* Log a custom event.
*/
export function logEvent(name, params = {}) {
if (!isAvailable) return;
try {
analytics().logEvent(name, params);
} catch (_) {}
}
/**
* Set user ID for analytics.
*/
export function setAnalyticsUser(userId) {
if (!isAvailable) return;
try {
analytics().setUserId(userId);
} catch (_) {}
}
/**
* Set user property.
*/
export function setUserProperty(name, value) {
if (!isAvailable) return;
try {
analytics().setUserProperty(name, value);
} catch (_) {}
}
// ============================
// Pre-defined event helpers
// ============================
export function trackSignUp(method) {
logEvent('sign_up', { method });
}
export function trackLogin(method) {
logEvent('login', { method });
}
export function trackIdentityCreated(title, source) {
logEvent('identity_created', { title, source });
}
export function trackHabitCompleted(habitTitle) {
logEvent('habit_completed', { habit: habitTitle });
}
export function trackDayCompleted(dayNumber, identityCheck, mood) {
logEvent('day_completed', { day: dayNumber, identity_check: identityCheck, mood });
}
export function trackGamePlayed(gameType, score) {
logEvent('game_played', { game_type: gameType, score });
}
export function trackJournalSaved(dayNumber) {
logEvent('journal_saved', { day: dayNumber });
}
export function trackJourneyCompleted(totalScore, daysLogged) {
logEvent('journey_completed', { total_score: totalScore, days_logged: daysLogged });
}
export function trackShare(contentType) {
logEvent('share', { content_type: contentType });
}
export function trackJourneyReset(reason) {
logEvent('journey_reset', { reason });
}
+73
View File
@@ -103,6 +103,79 @@ export async function signUp(email, password) {
return { session, user };
}
export async function resetPassword(email, newPassword) {
if (!USE_OFFLINE) {
// Supabase: send reset email
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: 'nova40://auth/reset',
});
if (error) throw error;
return { method: 'email' };
}
// Offline: reset password directly
const users = await getStoredUsers();
const key = email.toLowerCase();
const stored = users[key];
if (!stored) throw new Error('No account found with this email.');
stored.password = newPassword;
users[key] = stored;
await saveUsers(users);
return { method: 'direct' };
}
const OTP_STORE_KEY = 'nova40_pending_otp';
export async function signUpWithPhone(phone, password) {
if (!USE_OFFLINE) {
const { data, error } = await supabase.auth.signUp({ phone, password });
if (error) throw error;
return data;
}
// Offline: store pending registration + generate fake OTP
const users = await getStoredUsers();
const key = phone.toLowerCase();
if (users[key]) throw new Error('An account with this number already exists.');
const code = String(Math.floor(100000 + Math.random() * 900000));
await AsyncStorage.setItem(OTP_STORE_KEY, JSON.stringify({ identifier: key, code, password, method: 'phone' }));
// In real app this would send SMS. For offline, log the code.
console.log(`[Nova40 OTP] Code for ${phone}: ${code}`);
return { needsVerification: true };
}
export async function verifyOtp(identifier, code, method) {
if (!USE_OFFLINE) {
const verifyPayload = method === 'phone'
? { phone: identifier, token: code, type: 'sms' }
: { email: identifier, token: code, type: 'email' };
const { data, error } = await supabase.auth.verifyOtp(verifyPayload);
if (error) throw error;
return data;
}
// Offline: check stored OTP
const raw = await AsyncStorage.getItem(OTP_STORE_KEY);
if (!raw) throw new Error('No verification pending. Please register again.');
const pending = JSON.parse(raw);
if (pending.identifier !== identifier.toLowerCase()) throw new Error('Identifier mismatch.');
if (pending.code !== code) throw new Error('Incorrect code. Please try again.');
// OTP valid — create user
const users = await getStoredUsers();
const user = makeUser(identifier);
users[pending.identifier] = { id: user.id, password: pending.password };
await saveUsers(users);
await AsyncStorage.removeItem(OTP_STORE_KEY);
const session = makeSession(user);
await saveSession(session);
return { session, user };
}
export async function signOut() {
if (!USE_OFFLINE) {
const { error } = await supabase.auth.signOut();
+61
View File
@@ -0,0 +1,61 @@
let crashlytics = null;
try {
crashlytics = require('@react-native-firebase/crashlytics').default;
// Test if native module is available
crashlytics();
} catch (_) {
crashlytics = null;
}
const isAvailable = !!crashlytics;
export function initCrashlytics() {
if (!isAvailable) {
console.log('Crashlytics: native module not available (Expo Go). Skipping.');
return;
}
try {
crashlytics().setCrashlyticsCollectionEnabled(true);
const originalHandler = ErrorUtils.getGlobalHandler();
ErrorUtils.setGlobalHandler((error, isFatal) => {
crashlytics().recordError(error);
if (isFatal) crashlytics().log(`Fatal: ${error.message}`);
if (originalHandler) originalHandler(error, isFatal);
});
console.log('Crashlytics initialized');
} catch (e) {
console.warn('Crashlytics init failed:', e.message);
}
}
export function setUser(userId, email) {
if (!isAvailable) return;
try {
if (userId) crashlytics().setUserId(userId);
if (email) crashlytics().setAttribute('email', email);
} catch (_) {}
}
export function log(message) {
if (!isAvailable) return;
try { crashlytics().log(message); } catch (_) {}
}
export function recordError(error, context) {
if (!isAvailable) return;
try {
if (context) crashlytics().log(context);
crashlytics().recordError(error instanceof Error ? error : new Error(String(error)));
} catch (_) {}
}
export function testCrash() {
if (!isAvailable) {
console.warn('Crashlytics not available in Expo Go');
return;
}
crashlytics().crash();
}
+32
View File
@@ -103,6 +103,38 @@ export function getStatLabel(gameType) {
return labels[gameType] || 'Score';
}
/**
* Get best score and last score for each game type.
* Returns { [gameType]: { best, last } }
*/
export async function getGameScores(identityId) {
let sessions;
if (USE_OFFLINE) {
sessions = await offline.getAll('game_sessions', { identity_id: identityId });
} else {
const { data } = await supabase
.from('game_sessions')
.select('game_type, score, created_at')
.eq('identity_id', identityId);
sessions = data || [];
}
const scores = {};
// Sort by created_at so last entry is the most recent
sessions.sort((a, b) => (a.created_at || '').localeCompare(b.created_at || ''));
for (const s of sessions) {
if (!scores[s.game_type]) {
scores[s.game_type] = { best: s.score, last: s.score };
} else {
if (s.score > scores[s.game_type].best) scores[s.game_type].best = s.score;
scores[s.game_type].last = s.score;
}
}
return scores;
}
// Difficulty scaling based on current day
export function getDifficulty(currentDay) {
if (currentDay <= 10) return 1;
+34 -1
View File
@@ -5,9 +5,42 @@ import { todayISO } from '../utils/date';
// Must match authService.js
const USE_OFFLINE = true;
const DEFAULT_TIMES = ['06:00','06:30','07:00','07:30','08:00','12:00','17:00','18:00','19:00','20:00','21:00'];
const DEFAULT_DURATIONS = [10, 15, 20, 15, 30, 10, 15, 20, 10, 15];
export async function getHabits(identityId) {
if (USE_OFFLINE) {
return offline.getAll('habits', { identity_id: identityId });
const habits = await offline.getAll('habits', { identity_id: identityId });
// Auto-migrate old habits that are missing best_time/duration
let needsSave = false;
const migrated = habits.map((h, i) => {
if (h.best_time && h.duration) return h;
needsSave = true;
return {
...h,
best_time: h.best_time || DEFAULT_TIMES[i % DEFAULT_TIMES.length],
duration: h.duration || DEFAULT_DURATIONS[i % DEFAULT_DURATIONS.length],
frequency: h.frequency || 'Every day',
category: h.category || ['Discipline','Body','Mind','Emotion','Health'][i % 5],
difficulty: h.difficulty || (i < 2 ? 'Easy' : 'Medium'),
};
});
// Persist the migration
if (needsSave) {
for (const h of migrated) {
await offline.update('habits', { id: h.id }, {
best_time: h.best_time,
duration: h.duration,
frequency: h.frequency,
category: h.category,
difficulty: h.difficulty,
});
}
}
return migrated;
}
const { data, error } = await supabase
+41 -2
View File
@@ -51,7 +51,12 @@ export async function createIdentity(userId, title, description, customHabits, s
const habitsToInsert = habitsList.map((h) => ({
identity_id: identity.id,
title: h.title,
description: h.description || '',
description: h.description || h.why || '',
best_time: h.best_time || '',
duration: h.duration || 0,
frequency: h.frequency || '',
category: h.category || '',
difficulty: h.difficulty || '',
}));
const habits = await offline.insertMany('habits', habitsToInsert);
@@ -90,7 +95,12 @@ export async function createIdentity(userId, title, description, customHabits, s
const habitsToInsert = habitsList.map((h) => ({
identity_id: data.id,
title: h.title,
description: h.description || '',
description: h.description || h.why || '',
best_time: h.best_time || '',
duration: h.duration || 0,
frequency: h.frequency || '',
category: h.category || '',
difficulty: h.difficulty || '',
}));
const { data: habits, error: habitsError } = await supabase
@@ -240,3 +250,32 @@ export async function saveGameScore(identityId, gameType, score) {
});
if (error) throw error;
}
// ========================
// RESET JOURNEY
// ========================
export async function resetJourney(identityId, reason) {
if (USE_OFFLINE) {
// Save reset log
await offline.insert('reset_logs', {
identity_id: identityId,
reason: reason || '',
reset_at: new Date().toISOString(),
});
// Remove all related data
await offline.removeAll('habits', { identity_id: identityId });
await offline.removeAll('daily_logs', { identity_id: identityId });
await offline.removeAll('habit_logs'); // habit_logs don't have identity_id directly
await offline.removeAll('stats', { identity_id: identityId });
await offline.removeAll('game_sessions', { identity_id: identityId });
await offline.removeAll('identities', { id: identityId });
return;
}
// Supabase path
await supabase.from('habits').delete().eq('identity_id', identityId);
await supabase.from('daily_logs').delete().eq('identity_id', identityId);
await supabase.from('stats').delete().eq('identity_id', identityId);
await supabase.from('game_sessions').delete().eq('identity_id', identityId);
await supabase.from('identities').delete().eq('id', identityId);
}
+7 -24
View File
@@ -1,11 +1,8 @@
import * as offline from './offlineStorage';
import safeParseJournal from '../utils/safeParseJournal';
import { GEMINI_API_KEY } from '../config/keys';
import { AI_SERVICE_URL, AI_MODEL } 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.
@@ -44,34 +41,20 @@ Return ONLY valid JSON:
*/
export async function generateDailyReflection(data) {
try {
const response = await fetch(`${GEMINI_URL}?key=${GEMINI_API_KEY}`, {
const response = await fetch(`${AI_SERVICE_URL}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: buildPrompt(data) }] }],
generationConfig: { temperature: 0.8, topP: 0.9, maxOutputTokens: 512 },
model: AI_MODEL,
max_tokens: 512,
messages: [{ role: 'user', content: buildPrompt(data) }],
}),
});
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');
if (!response.ok) throw new Error(`AI failed (${response.status})`);
const json = await response.json();
const rawText = json?.candidates?.[0]?.content?.parts?.[0]?.text || '';
const rawText = json?.choices?.[0]?.message?.content || '';
return { ...safeParseJournal(rawText), source: 'ai' };
} catch (e) {
console.warn('Journal AI failed, using fallback:', e.message);
+11
View File
@@ -83,3 +83,14 @@ export async function upsert(collection, filter, data) {
}
return insert(collection, { ...filter, ...data });
}
export async function removeAll(collection, filter = {}) {
const items = await getCollection(collection);
const filtered = items.filter((item) => {
for (const [k, v] of Object.entries(filter)) {
if (item[k] !== v) return true;
}
return false;
});
await saveCollection(collection, filtered);
}
+24
View File
@@ -0,0 +1,24 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
const PROFILE_KEY = 'nova40_profile';
/**
* Get user profile data.
* @param {string} userId
*/
export async function getProfile(userId) {
const raw = await AsyncStorage.getItem(`${PROFILE_KEY}_${userId}`);
return raw ? JSON.parse(raw) : null;
}
/**
* Save user profile data.
* @param {string} userId
* @param {{ nickname?: string, fullName?: string, photoUri?: string }} data
*/
export async function saveProfile(userId, data) {
const existing = await getProfile(userId) || {};
const updated = { ...existing, ...data, updatedAt: new Date().toISOString() };
await AsyncStorage.setItem(`${PROFILE_KEY}_${userId}`, JSON.stringify(updated));
return updated;
}
+7 -30
View File
@@ -1,8 +1,5 @@
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';
import { AI_SERVICE_URL, AI_MODEL } from '../config/keys';
function buildPrompt(identity, habits, logs) {
const yesCount = logs.filter((l) => l.identity_check === 'yes').length;
@@ -76,40 +73,20 @@ IMPORTANT: Make it feel like this story belongs to ONE person, not a template.`;
*/
export async function generateTransformationStory(identity, habits, logs, stats) {
try {
const response = await fetch(`${GEMINI_URL}?key=${GEMINI_API_KEY}`, {
const response = await fetch(`${AI_SERVICE_URL}/v1/chat/completions`, {
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,
},
model: AI_MODEL,
max_tokens: 2048,
messages: [{ role: 'user', content: buildPrompt(identity, habits, logs) }],
}),
});
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');
if (!response.ok) throw new Error(`AI request failed (${response.status})`);
const json = await response.json();
const rawText = json?.candidates?.[0]?.content?.parts?.[0]?.text || '';
const rawText = json?.choices?.[0]?.message?.content || '';
const parsed = safeParseStory(rawText, identity, logs, stats);
return { ...parsed, source: 'ai' };
} catch (e) {
+55 -7
View File
@@ -1,5 +1,7 @@
import { create } from 'zustand';
import * as authService from '../services/authService';
import { setUser as setCrashlyticsUser } from '../services/crashlytics';
import { setAnalyticsUser, trackSignUp, trackLogin } from '../services/analytics';
const useAuthStore = create((set) => ({
session: null,
@@ -7,18 +9,20 @@ const useAuthStore = create((set) => ({
loading: false,
error: null,
setSession: (session) =>
set({
session,
user: session?.user ?? null,
loading: false,
error: null,
}),
setSession: (session) => {
const user = session?.user ?? null;
if (user) {
setCrashlyticsUser(user.id, user.email);
setAnalyticsUser(user.id);
}
set({ session, user, loading: false, error: null });
},
login: async (email, password) => {
set({ loading: true, error: null });
try {
const data = await authService.signIn(email, password);
trackLogin('email');
set({ session: data.session, user: data.user, loading: false });
return data;
} catch (e) {
@@ -31,6 +35,7 @@ const useAuthStore = create((set) => ({
set({ loading: true, error: null });
try {
const data = await authService.signInAsDemo();
trackLogin('demo');
set({ session: data.session, user: data.user, loading: false });
return data;
} catch (e) {
@@ -55,6 +60,7 @@ const useAuthStore = create((set) => ({
set({ loading: true, error: null });
try {
const data = await authService.signUp(email, password);
trackSignUp('email');
// If signup returned a session (offline mode or auto-confirm), set it
if (data?.session) {
set({
@@ -72,6 +78,36 @@ const useAuthStore = create((set) => ({
}
},
registerWithPhone: async (phone, password) => {
set({ loading: true, error: null });
try {
const data = await authService.signUpWithPhone(phone, password);
set({ loading: false });
return data;
} catch (e) {
set({ loading: false, error: e.message });
throw e;
}
},
verifyOtp: async (identifier, code, method) => {
set({ loading: true, error: null });
try {
const data = await authService.verifyOtp(identifier, code, method);
if (data?.session) {
const user = data.session?.user ?? data.user ?? null;
if (user) setCrashlyticsUser(user.id, user.email || identifier);
set({ session: data.session, user, loading: false });
} else {
set({ loading: false });
}
return data;
} catch (e) {
set({ loading: false, error: e.message });
throw e;
}
},
logout: async () => {
try {
await authService.signOut();
@@ -92,6 +128,18 @@ const useAuthStore = create((set) => ({
}
},
resetPassword: async (email, newPassword) => {
set({ loading: true, error: null });
try {
const result = await authService.resetPassword(email, newPassword);
set({ loading: false });
return result;
} catch (e) {
set({ loading: false, error: e.message });
throw e;
}
},
clearError: () => set({ error: null }),
}));
+11
View File
@@ -3,6 +3,7 @@ import * as identityService from '../services/identityService';
import { calculateCurrentDay } from '../utils/date';
import useAuthStore from './useAuthStore';
import useHabitStore from './useHabitStore';
import { trackIdentityCreated } from '../services/analytics';
const useIdentityStore = create((set, get) => ({
identity: null,
@@ -40,6 +41,7 @@ const useIdentityStore = create((set, get) => ({
user.id, title, description, customHabits, storyText
);
set({ identity, currentDay: 1, loading: false });
trackIdentityCreated(title, customHabits ? 'custom' : 'auto');
useHabitStore.getState().setHabits(habits);
return identity;
} catch (e) {
@@ -75,6 +77,15 @@ const useIdentityStore = create((set, get) => ({
await identityService.saveGameScore(identity.id, gameType, score);
},
// Reset journey (with reason)
resetJourney: async (reason) => {
const { identity } = get();
if (!identity) return;
await identityService.resetJourney(identity.id, reason);
set({ identity: null, currentDay: 0 });
useHabitStore.getState().reset();
},
// Reset on logout
reset: () => set({ identity: null, currentDay: 0, loading: false, error: null }),
}));
+60 -26
View File
@@ -1,39 +1,75 @@
const FALLBACK_HABIT = {
title: '',
best_time: '07:00',
duration: 10,
frequency: 'Every day',
category: 'Discipline',
difficulty: 'Easy',
why: '',
};
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',
priority_habits: [
{ title: 'Do one small positive action today', best_time: '08:00', duration: 10, frequency: 'Every day', category: 'Discipline', difficulty: 'Easy', why: 'Small actions build momentum' },
{ title: 'Avoid one bad habit', best_time: '—', duration: 0, frequency: 'Every day', category: 'Discipline', difficulty: 'Medium', why: 'Saying no is also a skill' },
{ title: 'Reflect for 2 minutes before sleep', best_time: '21:30', duration: 2, frequency: 'Every evening', category: 'Mind', difficulty: 'Easy', why: 'Awareness drives change' },
{ title: 'Move your body for 20 minutes', best_time: '06:30', duration: 20, frequency: 'Every day', category: 'Body', difficulty: 'Easy', why: 'Movement lifts everything' },
{ title: 'Write one thing you are grateful for', best_time: '06:00', duration: 2, frequency: 'Every morning', category: 'Emotion', difficulty: 'Easy', why: 'Gratitude shifts perspective' },
],
suggested_habits: [],
};
function parseDuration(val) {
if (typeof val === 'number') return val;
if (typeof val === 'string') {
const num = parseInt(val, 10);
return isNaN(num) ? 10 : num;
}
return 10;
}
function normalizeHabit(h) {
if (typeof h === 'string') {
return { ...FALLBACK_HABIT, title: h.trim() };
}
if (typeof h === 'object' && h !== null) {
return {
title: (typeof h.title === 'string' && h.title.trim()) || '',
best_time: (typeof h.best_time === 'string' && h.best_time.trim()) || '07:00',
duration: parseDuration(h.duration),
frequency: (typeof h.frequency === 'string' && h.frequency.trim()) || 'Every day',
category: (typeof h.category === 'string' && h.category.trim()) || 'Discipline',
difficulty: (typeof h.difficulty === 'string' && h.difficulty.trim()) || 'Easy',
why: (typeof h.why === 'string' && h.why.trim()) || '',
};
}
return null;
}
function parseHabitArray(arr) {
if (!Array.isArray(arr)) return [];
return arr.map(normalizeHabit).filter((h) => h && h.title);
}
/**
* Safely parse AI JSON response.
* Handles markdown wrapping, broken JSON, and missing fields.
* Safely parse AI JSON response with rich habit objects.
* 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);
const parsed = JSON.parse(cleaned.slice(start, end + 1));
// Validate required fields
const title = typeof parsed.identity_title === 'string' && parsed.identity_title.trim()
? parsed.identity_title.trim()
: FALLBACK.identity_title;
@@ -42,20 +78,18 @@ export default function safeParseAI(raw) {
? 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());
let priority = parseHabitArray(parsed.priority_habits);
let suggested = parseHabitArray(parsed.suggested_habits);
// Backward compat: if no priority_habits, split suggested
if (priority.length === 0 && suggested.length > 0) {
priority = suggested.slice(0, 5);
suggested = suggested.slice(5);
}
if (habits.length === 0) habits = FALLBACK.suggested_habits;
if (priority.length === 0) priority = FALLBACK.priority_habits;
return {
identity_title: title,
identity_summary: summary,
suggested_habits: habits,
};
return { identity_title: title, identity_summary: summary, priority_habits: priority, suggested_habits: suggested };
} catch (_) {
return { ...FALLBACK };
}