diff --git a/App.js b/App.js
index 09f879b..cbfb115 100644
--- a/App.js
+++ b/App.js
@@ -1,20 +1,21 @@
-import { StatusBar } from 'expo-status-bar';
-import { StyleSheet, Text, View } from 'react-native';
+import 'react-native-gesture-handler';
+import React from 'react';
+import { LogBox } from 'react-native';
+import { GestureHandlerRootView } from 'react-native-gesture-handler';
+import { SafeAreaProvider } from 'react-native-safe-area-context';
+import NovaAlertProvider from './src/components/NovaAlert';
+import AppNavigator from './src/navigation/AppNavigator';
+
+LogBox.ignoreLogs(['TypeError: Network request failed']);
export default function App() {
return (
-
- Open up App.js to start working on your app!
-
-
+
+
+
+
+
+
+
);
}
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- backgroundColor: '#fff',
- alignItems: 'center',
- justifyContent: 'center',
- },
-});
diff --git a/app.json b/app.json
index a652090..15b2c97 100644
--- a/app.json
+++ b/app.json
@@ -5,25 +5,37 @@
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
- "userInterfaceStyle": "light",
+ "userInterfaceStyle": "dark",
"newArchEnabled": true,
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
- "backgroundColor": "#ffffff"
+ "backgroundColor": "#0A0E1A"
},
"ios": {
- "supportsTablet": true
+ "supportsTablet": true,
+ "bundleIdentifier": "com.nova40.app"
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
- "backgroundColor": "#ffffff"
+ "backgroundColor": "#0A0E1A"
},
- "edgeToEdgeEnabled": true
+ "edgeToEdgeEnabled": true,
+ "package": "com.nova40.app",
+ "permissions": [
+ "WRITE_EXTERNAL_STORAGE",
+ "READ_EXTERNAL_STORAGE"
+ ]
},
"web": {
"favicon": "./assets/favicon.png"
- }
+ },
+ "extra": {
+ "eas": {
+ "projectId": "f0ecc895-4610-481d-96db-73a121e78254"
+ }
+ },
+ "owner": "heyaciell"
}
}
diff --git a/assets/adaptive-icon.png b/assets/adaptive-icon.png
index 03d6f6b..070d568 100644
Binary files a/assets/adaptive-icon.png and b/assets/adaptive-icon.png differ
diff --git a/assets/favicon.png b/assets/favicon.png
index e75f697..51ea185 100644
Binary files a/assets/favicon.png and b/assets/favicon.png differ
diff --git a/assets/icon.png b/assets/icon.png
index a0b1526..070d568 100644
Binary files a/assets/icon.png and b/assets/icon.png differ
diff --git a/assets/splash-icon.png b/assets/splash-icon.png
index 03d6f6b..070d568 100644
Binary files a/assets/splash-icon.png and b/assets/splash-icon.png differ
diff --git a/babel.config.js b/babel.config.js
new file mode 100644
index 0000000..9d89e13
--- /dev/null
+++ b/babel.config.js
@@ -0,0 +1,6 @@
+module.exports = function (api) {
+ api.cache(true);
+ return {
+ presets: ['babel-preset-expo'],
+ };
+};
diff --git a/eas.json b/eas.json
new file mode 100644
index 0000000..e70ac43
--- /dev/null
+++ b/eas.json
@@ -0,0 +1,17 @@
+{
+ "cli": {
+ "version": ">= 3.0.0"
+ },
+ "build": {
+ "preview": {
+ "android": {
+ "buildType": "apk"
+ }
+ },
+ "production": {
+ "android": {
+ "buildType": "app-bundle"
+ }
+ }
+ }
+}
diff --git a/package-lock.json b/package-lock.json
index 531d56f..6df9659 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,10 +8,32 @@
"name": "nova40",
"version": "1.0.0",
"dependencies": {
+ "@react-native-async-storage/async-storage": "2.2.0",
+ "@react-navigation/bottom-tabs": "^7.15.9",
+ "@react-navigation/native": "^7.2.2",
+ "@react-navigation/native-stack": "^7.14.11",
+ "@supabase/ssr": "^0.10.2",
+ "@supabase/supabase-js": "^2.103.0",
+ "babel-preset-expo": "~54.0.10",
"expo": "~54.0.33",
+ "expo-linear-gradient": "~15.0.8",
+ "expo-media-library": "~18.2.1",
+ "expo-print": "~15.0.8",
+ "expo-sharing": "~14.0.8",
"expo-status-bar": "~3.0.9",
"react": "19.1.0",
- "react-native": "0.81.5"
+ "react-native": "0.81.5",
+ "react-native-gesture-handler": "~2.28.0",
+ "react-native-reanimated": "~4.1.1",
+ "react-native-safe-area-context": "~5.6.0",
+ "react-native-screens": "~4.16.0",
+ "react-native-url-polyfill": "^3.0.0",
+ "react-native-view-shot": "4.0.3",
+ "zustand": "^5.0.12"
+ },
+ "devDependencies": {
+ "@types/react": "~19.1.10",
+ "typescript": "~5.9.2"
}
},
"node_modules/@0no-co/graphql.web": {
@@ -1326,6 +1348,22 @@
"@babel/core": "^7.0.0-0"
}
},
+ "node_modules/@babel/plugin-transform-template-literals": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz",
+ "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
"node_modules/@babel/plugin-transform-typescript": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz",
@@ -1515,6 +1553,18 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@egjs/hammerjs": {
+ "version": "2.0.17",
+ "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz",
+ "integrity": "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hammerjs": "^2.0.36"
+ },
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
"node_modules/@expo/code-signing-certificates": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz",
@@ -2700,6 +2750,18 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@react-native-async-storage/async-storage": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz",
+ "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==",
+ "license": "MIT",
+ "dependencies": {
+ "merge-options": "^3.0.4"
+ },
+ "peerDependencies": {
+ "react-native": "^0.0.0-0 || >=0.65 <1.0"
+ }
+ },
"node_modules/@react-native/assets-registry": {
"version": "0.81.5",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
@@ -2948,12 +3010,758 @@
"node": ">= 20.19.4"
}
},
+ "node_modules/@react-native/metro-babel-transformer": {
+ "version": "0.85.1",
+ "resolved": "https://registry.npmjs.org/@react-native/metro-babel-transformer/-/metro-babel-transformer-0.85.1.tgz",
+ "integrity": "sha512-oXAVv9GfGYxkqdf20o+gbJSw4yqaUZr7AZMZ4bJG8Nom/T9GmLu/Pd2kJo5U6NQYIndgfgU73pzRgL8H7YCIWw==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/core": "^7.25.2",
+ "@react-native/babel-preset": "0.85.1",
+ "hermes-parser": "0.33.3",
+ "nullthrows": "^1.1.1"
+ },
+ "engines": {
+ "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "*"
+ }
+ },
+ "node_modules/@react-native/metro-babel-transformer/node_modules/@react-native/babel-plugin-codegen": {
+ "version": "0.85.1",
+ "resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.85.1.tgz",
+ "integrity": "sha512-Klex4kTsRxoswZmo7EBXobvpg+HO6h7xeGo87CLXSKPq3qHlJ8ilpgtmzYCTK+Qr/0Mk3cz2zv3bA9VTXR+NDA==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/traverse": "^7.29.0",
+ "@react-native/codegen": "0.85.1"
+ },
+ "engines": {
+ "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
+ }
+ },
+ "node_modules/@react-native/metro-babel-transformer/node_modules/@react-native/babel-preset": {
+ "version": "0.85.1",
+ "resolved": "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.85.1.tgz",
+ "integrity": "sha512-Mplsn13fCxQElOfWg6wIuXJP+tyO980etTQ1gQFTt5Zstj3rs33GzTLMNlo6EnT8PQghO3GxIrg/2im5GwodnA==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/core": "^7.25.2",
+ "@babel/plugin-proposal-export-default-from": "^7.24.7",
+ "@babel/plugin-syntax-dynamic-import": "^7.8.3",
+ "@babel/plugin-syntax-export-default-from": "^7.24.7",
+ "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3",
+ "@babel/plugin-syntax-optional-chaining": "^7.8.3",
+ "@babel/plugin-transform-async-generator-functions": "^7.25.4",
+ "@babel/plugin-transform-async-to-generator": "^7.24.7",
+ "@babel/plugin-transform-block-scoping": "^7.25.0",
+ "@babel/plugin-transform-class-properties": "^7.25.4",
+ "@babel/plugin-transform-classes": "^7.25.4",
+ "@babel/plugin-transform-destructuring": "^7.24.8",
+ "@babel/plugin-transform-flow-strip-types": "^7.25.2",
+ "@babel/plugin-transform-for-of": "^7.24.7",
+ "@babel/plugin-transform-modules-commonjs": "^7.24.8",
+ "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7",
+ "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7",
+ "@babel/plugin-transform-optional-catch-binding": "^7.24.7",
+ "@babel/plugin-transform-optional-chaining": "^7.24.8",
+ "@babel/plugin-transform-private-methods": "^7.24.7",
+ "@babel/plugin-transform-private-property-in-object": "^7.24.7",
+ "@babel/plugin-transform-react-display-name": "^7.24.7",
+ "@babel/plugin-transform-react-jsx": "^7.25.2",
+ "@babel/plugin-transform-react-jsx-self": "^7.24.7",
+ "@babel/plugin-transform-react-jsx-source": "^7.24.7",
+ "@babel/plugin-transform-regenerator": "^7.24.7",
+ "@babel/plugin-transform-runtime": "^7.24.7",
+ "@babel/plugin-transform-typescript": "^7.25.2",
+ "@babel/plugin-transform-unicode-regex": "^7.24.7",
+ "@react-native/babel-plugin-codegen": "0.85.1",
+ "babel-plugin-syntax-hermes-parser": "0.33.3",
+ "babel-plugin-transform-flow-enums": "^0.0.2",
+ "react-refresh": "^0.14.0"
+ },
+ "engines": {
+ "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "*"
+ }
+ },
+ "node_modules/@react-native/metro-babel-transformer/node_modules/@react-native/codegen": {
+ "version": "0.85.1",
+ "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.85.1.tgz",
+ "integrity": "sha512-Ge8F5VejnI7ng/NGObqBBovuLbItvmmZDFQ1Qwt/nBhHtk7l2tOffNMVNTta9Jt8TW0oXxVj6FG3hr6nx03JrQ==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/core": "^7.25.2",
+ "@babel/parser": "^7.29.0",
+ "hermes-parser": "0.33.3",
+ "invariant": "^2.2.4",
+ "nullthrows": "^1.1.1",
+ "tinyglobby": "^0.2.15",
+ "yargs": "^17.6.2"
+ },
+ "engines": {
+ "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "*"
+ }
+ },
+ "node_modules/@react-native/metro-babel-transformer/node_modules/babel-plugin-syntax-hermes-parser": {
+ "version": "0.33.3",
+ "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.33.3.tgz",
+ "integrity": "sha512-/Z9xYdaJ1lC0pT9do6TqCqhOSLfZ5Ot8D5za1p+feEfWYupCOfGbhhEXN9r2ZgJtDNUNRw/Z+T2CvAGKBqtqWA==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "hermes-parser": "0.33.3"
+ }
+ },
+ "node_modules/@react-native/metro-babel-transformer/node_modules/hermes-estree": {
+ "version": "0.33.3",
+ "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.33.3.tgz",
+ "integrity": "sha512-6kzYZHCk8Fy1Uc+t3HGYyJn3OL4aeqKLTyina4UFtWl8I0kSL7OmKThaiX+Uh2f8nGw3mo4Ifxg0M5Zk3/Oeqg==",
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@react-native/metro-babel-transformer/node_modules/hermes-parser": {
+ "version": "0.33.3",
+ "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.33.3.tgz",
+ "integrity": "sha512-Yg3HgaG4CqgyowtYjX/FsnPAuZdHOqSMtnbpylbptsQ9nwwSKsy6uRWcGO5RK0EqiX12q8HvDWKgeAVajRO5DA==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "hermes-estree": "0.33.3"
+ }
+ },
+ "node_modules/@react-native/metro-config": {
+ "version": "0.85.1",
+ "resolved": "https://registry.npmjs.org/@react-native/metro-config/-/metro-config-0.85.1.tgz",
+ "integrity": "sha512-Na0OD2YFM7rESHJ3ETuYHnXNc5TJU/fpwlLmN2/uDTM9ZDb6EaEfFKZaXGbUm2lBYyeo/FG3Ur4glu8jLWMNgQ==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@react-native/js-polyfills": "0.85.1",
+ "@react-native/metro-babel-transformer": "0.85.1",
+ "metro-config": "^0.84.0",
+ "metro-runtime": "^0.84.0"
+ },
+ "engines": {
+ "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
+ }
+ },
+ "node_modules/@react-native/metro-config/node_modules/@babel/code-frame": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
+ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@react-native/metro-config/node_modules/@react-native/js-polyfills": {
+ "version": "0.85.1",
+ "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.85.1.tgz",
+ "integrity": "sha512-VseQZAKnDbmpZThLWviDIJ0NmuSiwiHA6vc2HNJTTVqTy2mQR0+858y9kDdDBQPYe0HH8+W1mYui2i4eUWGh4g==",
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
+ }
+ },
+ "node_modules/@react-native/metro-config/node_modules/accepts": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+ "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "mime-types": "^3.0.0",
+ "negotiator": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/@react-native/metro-config/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/@react-native/metro-config/node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/@react-native/metro-config/node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/@react-native/metro-config/node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@react-native/metro-config/node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@react-native/metro-config/node_modules/hermes-estree": {
+ "version": "0.35.0",
+ "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.35.0.tgz",
+ "integrity": "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==",
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@react-native/metro-config/node_modules/hermes-parser": {
+ "version": "0.35.0",
+ "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.35.0.tgz",
+ "integrity": "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "hermes-estree": "0.35.0"
+ }
+ },
+ "node_modules/@react-native/metro-config/node_modules/metro": {
+ "version": "0.84.3",
+ "resolved": "https://registry.npmjs.org/metro/-/metro-0.84.3.tgz",
+ "integrity": "sha512-1h3lbVrE6hGf1e/764HfhPGg/bGrWMJDDh7G2rc4gFYZboVuI40BlG/y+UhtbhQDNlO/csMvrcnK0YrTlHUVew==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/core": "^7.25.2",
+ "@babel/generator": "^7.29.1",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/traverse": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "accepts": "^2.0.0",
+ "chalk": "^4.0.0",
+ "ci-info": "^2.0.0",
+ "connect": "^3.6.5",
+ "debug": "^4.4.0",
+ "error-stack-parser": "^2.0.6",
+ "flow-enums-runtime": "^0.0.6",
+ "graceful-fs": "^4.2.4",
+ "hermes-parser": "0.35.0",
+ "image-size": "^1.0.2",
+ "invariant": "^2.2.4",
+ "jest-worker": "^29.7.0",
+ "jsc-safe-url": "^0.2.2",
+ "lodash.throttle": "^4.1.1",
+ "metro-babel-transformer": "0.84.3",
+ "metro-cache": "0.84.3",
+ "metro-cache-key": "0.84.3",
+ "metro-config": "0.84.3",
+ "metro-core": "0.84.3",
+ "metro-file-map": "0.84.3",
+ "metro-resolver": "0.84.3",
+ "metro-runtime": "0.84.3",
+ "metro-source-map": "0.84.3",
+ "metro-symbolicate": "0.84.3",
+ "metro-transform-plugins": "0.84.3",
+ "metro-transform-worker": "0.84.3",
+ "mime-types": "^3.0.1",
+ "nullthrows": "^1.1.1",
+ "serialize-error": "^2.1.0",
+ "source-map": "^0.5.6",
+ "throat": "^5.0.0",
+ "ws": "^7.5.10",
+ "yargs": "^17.6.2"
+ },
+ "bin": {
+ "metro": "src/cli.js"
+ },
+ "engines": {
+ "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
+ }
+ },
+ "node_modules/@react-native/metro-config/node_modules/metro-babel-transformer": {
+ "version": "0.84.3",
+ "resolved": "https://registry.npmjs.org/metro-babel-transformer/-/metro-babel-transformer-0.84.3.tgz",
+ "integrity": "sha512-svAA+yMLpeMiGcz/jKJs4oHpIGEx4nBqNEJ5AGj4CYIg1efvK+A0TjR6tgIuc6tKO5e8JmN/1lglpN2+f3/z/w==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/core": "^7.25.2",
+ "flow-enums-runtime": "^0.0.6",
+ "hermes-parser": "0.35.0",
+ "metro-cache-key": "0.84.3",
+ "nullthrows": "^1.1.1"
+ },
+ "engines": {
+ "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
+ }
+ },
+ "node_modules/@react-native/metro-config/node_modules/metro-cache": {
+ "version": "0.84.3",
+ "resolved": "https://registry.npmjs.org/metro-cache/-/metro-cache-0.84.3.tgz",
+ "integrity": "sha512-0QElxwLaHqLZf+Xqio8QrjVbuXP/8sJfQBGSPiITlKDVXrVLefuzYVSH9Sj+QL6lrPj2gYZd/iwQh1yZuVKnLA==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "exponential-backoff": "^3.1.1",
+ "flow-enums-runtime": "^0.0.6",
+ "https-proxy-agent": "^7.0.5",
+ "metro-core": "0.84.3"
+ },
+ "engines": {
+ "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
+ }
+ },
+ "node_modules/@react-native/metro-config/node_modules/metro-cache-key": {
+ "version": "0.84.3",
+ "resolved": "https://registry.npmjs.org/metro-cache-key/-/metro-cache-key-0.84.3.tgz",
+ "integrity": "sha512-TnSL1Fdvrw+2glTdBSRmA5TL8l/i16ECjsrUdf3E5HncA+sNx8KcwDG8r+3ct1UhfYcusJypzZqTN55FZZcwGg==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "flow-enums-runtime": "^0.0.6"
+ },
+ "engines": {
+ "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
+ }
+ },
+ "node_modules/@react-native/metro-config/node_modules/metro-config": {
+ "version": "0.84.3",
+ "resolved": "https://registry.npmjs.org/metro-config/-/metro-config-0.84.3.tgz",
+ "integrity": "sha512-JmCzZWOETR+O22q8oPBWyQppx3roU9EbkbGzD8Gf1jukQ4b5T1fTzqqHruu6K4sTiNq5zVQySmKF6bp4kVARew==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "connect": "^3.6.5",
+ "flow-enums-runtime": "^0.0.6",
+ "jest-validate": "^29.7.0",
+ "metro": "0.84.3",
+ "metro-cache": "0.84.3",
+ "metro-core": "0.84.3",
+ "metro-runtime": "0.84.3",
+ "yaml": "^2.6.1"
+ },
+ "engines": {
+ "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
+ }
+ },
+ "node_modules/@react-native/metro-config/node_modules/metro-core": {
+ "version": "0.84.3",
+ "resolved": "https://registry.npmjs.org/metro-core/-/metro-core-0.84.3.tgz",
+ "integrity": "sha512-cc0pvAa80ai1nDmqqz0P59a+0ZqCZ/YHU/3jEekZL6spFnYDfX8iDLdn9FR6kX+67rmzKxHNrbrSRFLX2AYocw==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "flow-enums-runtime": "^0.0.6",
+ "lodash.throttle": "^4.1.1",
+ "metro-resolver": "0.84.3"
+ },
+ "engines": {
+ "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
+ }
+ },
+ "node_modules/@react-native/metro-config/node_modules/metro-file-map": {
+ "version": "0.84.3",
+ "resolved": "https://registry.npmjs.org/metro-file-map/-/metro-file-map-0.84.3.tgz",
+ "integrity": "sha512-1cL4m4Jv1yRUt9RJExZQLfccscdlMNOcRG6LHLtmJhf3BG9j3MujPVc7CIpKYdFl+KUl+sdjge6oO3+meKCHQA==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "debug": "^4.4.0",
+ "fb-watchman": "^2.0.0",
+ "flow-enums-runtime": "^0.0.6",
+ "graceful-fs": "^4.2.4",
+ "invariant": "^2.2.4",
+ "jest-worker": "^29.7.0",
+ "micromatch": "^4.0.4",
+ "nullthrows": "^1.1.1",
+ "walker": "^1.0.7"
+ },
+ "engines": {
+ "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
+ }
+ },
+ "node_modules/@react-native/metro-config/node_modules/metro-minify-terser": {
+ "version": "0.84.3",
+ "resolved": "https://registry.npmjs.org/metro-minify-terser/-/metro-minify-terser-0.84.3.tgz",
+ "integrity": "sha512-3ofrG2OQyJbO9RNhCfOcl8QU7EE2WrSsnN5dFkuZaJO5+4Imujr9bUXmspeNlXRsOVk0F/rVRbEFH98lFSCkBQ==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "flow-enums-runtime": "^0.0.6",
+ "terser": "^5.15.0"
+ },
+ "engines": {
+ "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
+ }
+ },
+ "node_modules/@react-native/metro-config/node_modules/metro-resolver": {
+ "version": "0.84.3",
+ "resolved": "https://registry.npmjs.org/metro-resolver/-/metro-resolver-0.84.3.tgz",
+ "integrity": "sha512-pjEzGDtoM8DTHAIPK/9u9ZxszEiuRohYUVImWvgbnB91V4gqYJpQcoEYUugf2NIm1lrX5HNu0OvNqWmPBnGYjA==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "flow-enums-runtime": "^0.0.6"
+ },
+ "engines": {
+ "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
+ }
+ },
+ "node_modules/@react-native/metro-config/node_modules/metro-runtime": {
+ "version": "0.84.3",
+ "resolved": "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.84.3.tgz",
+ "integrity": "sha512-o7HLRfMyVk9N2dUZ9VjQfB6xxUItL9Pi9WcqxURE7MEKOH6wbGt9/E92YdYLluTOtkzYAEVfdC6h6lcxqA+hMQ==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/runtime": "^7.25.0",
+ "flow-enums-runtime": "^0.0.6"
+ },
+ "engines": {
+ "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
+ }
+ },
+ "node_modules/@react-native/metro-config/node_modules/metro-source-map": {
+ "version": "0.84.3",
+ "resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.84.3.tgz",
+ "integrity": "sha512-jS48CeSzw78M8y6VE0f9uy3lVmfbOS677j2VCxnlmlYmnahcXuC6IhoN9K6LynNvos9517yUadcfgioju38xYQ==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/traverse": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "flow-enums-runtime": "^0.0.6",
+ "invariant": "^2.2.4",
+ "metro-symbolicate": "0.84.3",
+ "nullthrows": "^1.1.1",
+ "ob1": "0.84.3",
+ "source-map": "^0.5.6",
+ "vlq": "^1.0.0"
+ },
+ "engines": {
+ "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
+ }
+ },
+ "node_modules/@react-native/metro-config/node_modules/metro-symbolicate": {
+ "version": "0.84.3",
+ "resolved": "https://registry.npmjs.org/metro-symbolicate/-/metro-symbolicate-0.84.3.tgz",
+ "integrity": "sha512-J9Tpo8NCycYrozRvBIUyOwGAu4xkawOsAppmTscFiaegK0WvuDGwIM53GbzVSnytCHjVAF0io5GQxpkrKTuc7g==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "flow-enums-runtime": "^0.0.6",
+ "invariant": "^2.2.4",
+ "metro-source-map": "0.84.3",
+ "nullthrows": "^1.1.1",
+ "source-map": "^0.5.6",
+ "vlq": "^1.0.0"
+ },
+ "bin": {
+ "metro-symbolicate": "src/index.js"
+ },
+ "engines": {
+ "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
+ }
+ },
+ "node_modules/@react-native/metro-config/node_modules/metro-transform-plugins": {
+ "version": "0.84.3",
+ "resolved": "https://registry.npmjs.org/metro-transform-plugins/-/metro-transform-plugins-0.84.3.tgz",
+ "integrity": "sha512-8S3baq2XhBaafHEH5Q8sJW6tmzsEJk80qKc3RU/nZV1MsnYq94RdjTUR6AyKjQd6Rfsk1BtBxhtiNnk7mgslCg==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/core": "^7.25.2",
+ "@babel/generator": "^7.29.1",
+ "@babel/template": "^7.28.6",
+ "@babel/traverse": "^7.29.0",
+ "flow-enums-runtime": "^0.0.6",
+ "nullthrows": "^1.1.1"
+ },
+ "engines": {
+ "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
+ }
+ },
+ "node_modules/@react-native/metro-config/node_modules/metro-transform-worker": {
+ "version": "0.84.3",
+ "resolved": "https://registry.npmjs.org/metro-transform-worker/-/metro-transform-worker-0.84.3.tgz",
+ "integrity": "sha512-Wjba7PyYktNRsHbPmkx2J2UX32rAzcDXjCu49zPHeF/viJlYJhwRaNePQcHaCRqQ+kmgQT4ThprsnJfDj71ZMA==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/core": "^7.25.2",
+ "@babel/generator": "^7.29.1",
+ "@babel/parser": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "flow-enums-runtime": "^0.0.6",
+ "metro": "0.84.3",
+ "metro-babel-transformer": "0.84.3",
+ "metro-cache": "0.84.3",
+ "metro-cache-key": "0.84.3",
+ "metro-minify-terser": "0.84.3",
+ "metro-source-map": "0.84.3",
+ "metro-transform-plugins": "0.84.3",
+ "nullthrows": "^1.1.1"
+ },
+ "engines": {
+ "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
+ }
+ },
+ "node_modules/@react-native/metro-config/node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/@react-native/metro-config/node_modules/mime-types": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/@react-native/metro-config/node_modules/negotiator": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+ "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/@react-native/metro-config/node_modules/ob1": {
+ "version": "0.84.3",
+ "resolved": "https://registry.npmjs.org/ob1/-/ob1-0.84.3.tgz",
+ "integrity": "sha512-J7554Ef8bzmKaDY365Afq6PF+qtdnY/d5PKUQFrsKlZHV/N3OGZewVrvDrQDyX5V5NJjTpcAKtlrFZcDr+HvpQ==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "flow-enums-runtime": "^0.0.6"
+ },
+ "engines": {
+ "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
+ }
+ },
+ "node_modules/@react-native/metro-config/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/@react-native/normalize-colors": {
"version": "0.81.5",
"resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.81.5.tgz",
"integrity": "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g==",
"license": "MIT"
},
+ "node_modules/@react-navigation/bottom-tabs": {
+ "version": "7.15.9",
+ "resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.15.9.tgz",
+ "integrity": "sha512-Ou28A1aZLj5wiFQ3F93aIsrI4NCwn3IJzkkjNo9KLFXsc0Yks+UqrVaFlffHFLsrbajuGRG/OQpnMA1ljayY5Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@react-navigation/elements": "^2.9.14",
+ "color": "^4.2.3",
+ "sf-symbols-typescript": "^2.1.0"
+ },
+ "peerDependencies": {
+ "@react-navigation/native": "^7.2.2",
+ "react": ">= 18.2.0",
+ "react-native": "*",
+ "react-native-safe-area-context": ">= 4.0.0",
+ "react-native-screens": ">= 4.0.0"
+ }
+ },
+ "node_modules/@react-navigation/core": {
+ "version": "7.17.2",
+ "resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-7.17.2.tgz",
+ "integrity": "sha512-Rt2OZwcgOmjv401uLGAKaRM6xo0fiBce/A7LfRHI1oe5FV+KooWcgAoZ2XOtgKj6UzVMuQWt3b2e6rxo/mDJRA==",
+ "license": "MIT",
+ "dependencies": {
+ "@react-navigation/routers": "^7.5.3",
+ "escape-string-regexp": "^4.0.0",
+ "fast-deep-equal": "^3.1.3",
+ "nanoid": "^3.3.11",
+ "query-string": "^7.1.3",
+ "react-is": "^19.1.0",
+ "use-latest-callback": "^0.2.4",
+ "use-sync-external-store": "^1.5.0"
+ },
+ "peerDependencies": {
+ "react": ">= 18.2.0"
+ }
+ },
+ "node_modules/@react-navigation/core/node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@react-navigation/core/node_modules/react-is": {
+ "version": "19.2.5",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz",
+ "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==",
+ "license": "MIT"
+ },
+ "node_modules/@react-navigation/elements": {
+ "version": "2.9.14",
+ "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.14.tgz",
+ "integrity": "sha512-lKqzu+su2pI/YIZmR7L7xdOs4UL+rVXKJAMpRMBrwInEy96SjIFst6QDGpE89Dunnu3VjVpjWfByo9f2GWBHDQ==",
+ "license": "MIT",
+ "dependencies": {
+ "color": "^4.2.3",
+ "use-latest-callback": "^0.2.4",
+ "use-sync-external-store": "^1.5.0"
+ },
+ "peerDependencies": {
+ "@react-native-masked-view/masked-view": ">= 0.2.0",
+ "@react-navigation/native": "^7.2.2",
+ "react": ">= 18.2.0",
+ "react-native": "*",
+ "react-native-safe-area-context": ">= 4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@react-native-masked-view/masked-view": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@react-navigation/native": {
+ "version": "7.2.2",
+ "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.2.2.tgz",
+ "integrity": "sha512-kem1Ko2BcbAjmbQIv66dNmr6EtfDut3QU0qjsVhMnLLhktwyXb6FzZYp8gTrUb6AvkAbaJoi+BF5Pl55pAUa5w==",
+ "license": "MIT",
+ "dependencies": {
+ "@react-navigation/core": "^7.17.2",
+ "escape-string-regexp": "^4.0.0",
+ "fast-deep-equal": "^3.1.3",
+ "nanoid": "^3.3.11",
+ "use-latest-callback": "^0.2.4"
+ },
+ "peerDependencies": {
+ "react": ">= 18.2.0",
+ "react-native": "*"
+ }
+ },
+ "node_modules/@react-navigation/native-stack": {
+ "version": "7.14.11",
+ "resolved": "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-7.14.11.tgz",
+ "integrity": "sha512-1ufBtJ7KbVFlQhXsYSYHqjgkmP30AzJSgW48YjWMQZ3NZGAyYe34w9Wd4KpdebQCfDClPe9maU+8crA/awa6lQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@react-navigation/elements": "^2.9.14",
+ "color": "^4.2.3",
+ "sf-symbols-typescript": "^2.1.0",
+ "warn-once": "^0.1.1"
+ },
+ "peerDependencies": {
+ "@react-navigation/native": "^7.2.2",
+ "react": ">= 18.2.0",
+ "react-native": "*",
+ "react-native-safe-area-context": ">= 4.0.0",
+ "react-native-screens": ">= 4.0.0"
+ }
+ },
+ "node_modules/@react-navigation/native/node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@react-navigation/routers": {
+ "version": "7.5.3",
+ "resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-7.5.3.tgz",
+ "integrity": "sha512-1tJHg4KKRJuQ1/EvJxatrMef3NZXEPzwUIUZ3n1yJ2t7Q97siwRtbynRpQG9/69ebbtiZ8W3ScOZF/OmhvM4Rg==",
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11"
+ }
+ },
"node_modules/@sinclair/typebox": {
"version": "0.27.10",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz",
@@ -2978,6 +3786,125 @@
"@sinonjs/commons": "^3.0.0"
}
},
+ "node_modules/@supabase/auth-js": {
+ "version": "2.103.0",
+ "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.103.0.tgz",
+ "integrity": "sha512-6zAanO6c+6gpHOlt5Lb9TlBBkJdZiUWkWCJKAxzkywBDcwaHlLJKXnjQGX6GyVCyKRR1e7sTq4re/yRTH6U/9A==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/functions-js": {
+ "version": "2.103.0",
+ "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.103.0.tgz",
+ "integrity": "sha512-YrneV2NjskUkkmkZ2Jt2n3elBgbWzV4Y1M9MM370z2Zd5ZPFqFbY8KIoPwuNjtAGE9YrpKBxnbZqeF07BiN9Og==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/phoenix": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.0.tgz",
+ "integrity": "sha512-RHSx8bHS02xwfHdAbX5Lpbo6PXbgyf7lTaXTlwtFDPwOIw64NnVRwFAXGojHhjtVYI+PEPNSWwkL90f4agN3bw==",
+ "license": "MIT"
+ },
+ "node_modules/@supabase/postgrest-js": {
+ "version": "2.103.0",
+ "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.103.0.tgz",
+ "integrity": "sha512-rC3sRxYdPZymkp2CZR1MiNQgbOleD01bGsW8VxEKRR5nMkLZ1NgAS1QTQf78Wh30czFyk505ZYr9Od8/mWT2TA==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/realtime-js": {
+ "version": "2.103.0",
+ "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.103.0.tgz",
+ "integrity": "sha512-gcPtXzZ6izyyBVf2of7K3dEt8CScPJn8VcSlQq6oWL9QoE1kqfQl0oFrOMHd5qrcADewxI7OxxosLB8W4XqtIQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@supabase/phoenix": "^0.4.0",
+ "@types/ws": "^8.18.1",
+ "tslib": "2.8.1",
+ "ws": "^8.18.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/realtime-js/node_modules/ws": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
+ "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@supabase/ssr": {
+ "version": "0.10.2",
+ "resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.10.2.tgz",
+ "integrity": "sha512-JFbchN63CXLFHJRNT7udec4/RoD9PmXkSGko3QSO6vUuqGBtSzdmxR7FPfQNr7SuFd65I7Xv46q66ALjEN1cgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "^1.0.2"
+ },
+ "peerDependencies": {
+ "@supabase/supabase-js": "^2.102.1"
+ }
+ },
+ "node_modules/@supabase/storage-js": {
+ "version": "2.103.0",
+ "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.103.0.tgz",
+ "integrity": "sha512-DHmlvdAXwtOmZNbkIZi4lkobPR3XjIzoOgzoz5duMf6G+sDeY015YrzMJCnqdccuYr7X5x4yYuSwF//RoN2dvQ==",
+ "license": "MIT",
+ "dependencies": {
+ "iceberg-js": "^0.8.1",
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/supabase-js": {
+ "version": "2.103.0",
+ "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.103.0.tgz",
+ "integrity": "sha512-j/6q5+LtXbR/YOLSLhy7Na74RD1cV2v+KwIIuuqMEjk1JpLEEyu0ynwDHpGoxMncDQl+R5FogaVqZm+85lZvtw==",
+ "license": "MIT",
+ "dependencies": {
+ "@supabase/auth-js": "2.103.0",
+ "@supabase/functions-js": "2.103.0",
+ "@supabase/postgrest-js": "2.103.0",
+ "@supabase/realtime-js": "2.103.0",
+ "@supabase/storage-js": "2.103.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -3028,6 +3955,12 @@
"@types/node": "*"
}
},
+ "node_modules/@types/hammerjs": {
+ "version": "2.0.46",
+ "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz",
+ "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==",
+ "license": "MIT"
+ },
"node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
@@ -3061,12 +3994,31 @@
"undici-types": "~7.19.0"
}
},
+ "node_modules/@types/react": {
+ "version": "19.1.17",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz",
+ "integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.0.2"
+ }
+ },
"node_modules/@types/stack-utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
"integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==",
"license": "MIT"
},
+ "node_modules/@types/ws": {
+ "version": "8.18.1",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
+ "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/yargs": {
"version": "17.0.35",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
@@ -3510,6 +4462,49 @@
"@babel/core": "^7.0.0 || ^8.0.0-0"
}
},
+ "node_modules/babel-preset-expo": {
+ "version": "54.0.10",
+ "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-54.0.10.tgz",
+ "integrity": "sha512-wTt7POavLFypLcPW/uC5v8y+mtQKDJiyGLzYCjqr9tx0Qc3vCXcDKk1iCFIj/++Iy5CWhhTflEa7VvVPNWeCfw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.25.9",
+ "@babel/plugin-proposal-decorators": "^7.12.9",
+ "@babel/plugin-proposal-export-default-from": "^7.24.7",
+ "@babel/plugin-syntax-export-default-from": "^7.24.7",
+ "@babel/plugin-transform-class-static-block": "^7.27.1",
+ "@babel/plugin-transform-export-namespace-from": "^7.25.9",
+ "@babel/plugin-transform-flow-strip-types": "^7.25.2",
+ "@babel/plugin-transform-modules-commonjs": "^7.24.8",
+ "@babel/plugin-transform-object-rest-spread": "^7.24.7",
+ "@babel/plugin-transform-parameters": "^7.24.7",
+ "@babel/plugin-transform-private-methods": "^7.24.7",
+ "@babel/plugin-transform-private-property-in-object": "^7.24.7",
+ "@babel/plugin-transform-runtime": "^7.24.7",
+ "@babel/preset-react": "^7.22.15",
+ "@babel/preset-typescript": "^7.23.0",
+ "@react-native/babel-preset": "0.81.5",
+ "babel-plugin-react-compiler": "^1.0.0",
+ "babel-plugin-react-native-web": "~0.21.0",
+ "babel-plugin-syntax-hermes-parser": "^0.29.1",
+ "babel-plugin-transform-flow-enums": "^0.0.2",
+ "debug": "^4.3.4",
+ "resolve-from": "^5.0.0"
+ },
+ "peerDependencies": {
+ "@babel/runtime": "^7.20.0",
+ "expo": "*",
+ "react-refresh": ">=0.14.0 <1.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@babel/runtime": {
+ "optional": true
+ },
+ "expo": {
+ "optional": true
+ }
+ }
+ },
"node_modules/babel-preset-jest": {
"version": "29.6.3",
"resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz",
@@ -3532,6 +4527,15 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
+ "node_modules/base64-arraybuffer": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
+ "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6.0"
+ }
+ },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -3889,6 +4893,19 @@
"node": ">=0.8"
}
},
+ "node_modules/color": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
+ "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1",
+ "color-string": "^1.9.0"
+ },
+ "engines": {
+ "node": ">=12.5.0"
+ }
+ },
"node_modules/color-convert": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
@@ -3904,6 +4921,34 @@
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
"license": "MIT"
},
+ "node_modules/color-string": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
+ "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "^1.0.0",
+ "simple-swizzle": "^0.2.2"
+ }
+ },
+ "node_modules/color/node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color/node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "license": "MIT"
+ },
"node_modules/commander": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
@@ -4009,6 +5054,19 @@
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"license": "MIT"
},
+ "node_modules/cookie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/core-js-compat": {
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz",
@@ -4036,6 +5094,22 @@
"node": ">= 8"
}
},
+ "node_modules/css-line-break": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
+ "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
+ "license": "MIT",
+ "dependencies": {
+ "utrie": "^1.0.2"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "devOptional": true,
+ "license": "MIT"
+ },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -4053,6 +5127,15 @@
}
}
},
+ "node_modules/decode-uri-component": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz",
+ "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
@@ -4308,6 +5391,27 @@
}
}
},
+ "node_modules/expo-linear-gradient": {
+ "version": "15.0.8",
+ "resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-15.0.8.tgz",
+ "integrity": "sha512-V2d8Wjn0VzhPHO+rrSBtcl+Fo+jUUccdlmQ6OoL9/XQB7Qk3d9lYrqKDJyccwDxmQT10JdST3Tmf2K52NLc3kw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "expo": "*",
+ "react": "*",
+ "react-native": "*"
+ }
+ },
+ "node_modules/expo-media-library": {
+ "version": "18.2.1",
+ "resolved": "https://registry.npmjs.org/expo-media-library/-/expo-media-library-18.2.1.tgz",
+ "integrity": "sha512-dV1acx6Aseu+I5hmF61wY8UkD4vdt8d7YXHDfgNp6ZSs06qxayUxgrBsiG2eigLe54VLm3ycbFBbWi31lhfsCA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "expo": "*",
+ "react-native": "*"
+ }
+ },
"node_modules/expo-modules-autolinking": {
"version": "3.0.24",
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.24.tgz",
@@ -4407,6 +5511,16 @@
"react-native": "*"
}
},
+ "node_modules/expo-print": {
+ "version": "15.0.8",
+ "resolved": "https://registry.npmjs.org/expo-print/-/expo-print-15.0.8.tgz",
+ "integrity": "sha512-4O0Qzm0On5AmJIl9d+BT+ieTipFp658nHI4aX7vKEFPfj3dfQxG6rDJJpca+rrc9c4Ha8ZFYGvxJG5+4lFq2Pw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "expo": "*",
+ "react-native": "*"
+ }
+ },
"node_modules/expo-server": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz",
@@ -4416,6 +5530,15 @@
"node": ">=20.16.0"
}
},
+ "node_modules/expo-sharing": {
+ "version": "14.0.8",
+ "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-14.0.8.tgz",
+ "integrity": "sha512-A1pPr2iBrxypFDCWVAESk532HK+db7MFXbvO2sCV9ienaFXAk7lIBm6bkqgE6vzRd9O3RGdEGzYx80cYlc089Q==",
+ "license": "MIT",
+ "peerDependencies": {
+ "expo": "*"
+ }
+ },
"node_modules/expo-status-bar": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-3.0.9.tgz",
@@ -4614,49 +5737,6 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
- "node_modules/expo/node_modules/babel-preset-expo": {
- "version": "54.0.10",
- "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-54.0.10.tgz",
- "integrity": "sha512-wTt7POavLFypLcPW/uC5v8y+mtQKDJiyGLzYCjqr9tx0Qc3vCXcDKk1iCFIj/++Iy5CWhhTflEa7VvVPNWeCfw==",
- "license": "MIT",
- "dependencies": {
- "@babel/helper-module-imports": "^7.25.9",
- "@babel/plugin-proposal-decorators": "^7.12.9",
- "@babel/plugin-proposal-export-default-from": "^7.24.7",
- "@babel/plugin-syntax-export-default-from": "^7.24.7",
- "@babel/plugin-transform-class-static-block": "^7.27.1",
- "@babel/plugin-transform-export-namespace-from": "^7.25.9",
- "@babel/plugin-transform-flow-strip-types": "^7.25.2",
- "@babel/plugin-transform-modules-commonjs": "^7.24.8",
- "@babel/plugin-transform-object-rest-spread": "^7.24.7",
- "@babel/plugin-transform-parameters": "^7.24.7",
- "@babel/plugin-transform-private-methods": "^7.24.7",
- "@babel/plugin-transform-private-property-in-object": "^7.24.7",
- "@babel/plugin-transform-runtime": "^7.24.7",
- "@babel/preset-react": "^7.22.15",
- "@babel/preset-typescript": "^7.23.0",
- "@react-native/babel-preset": "0.81.5",
- "babel-plugin-react-compiler": "^1.0.0",
- "babel-plugin-react-native-web": "~0.21.0",
- "babel-plugin-syntax-hermes-parser": "^0.29.1",
- "babel-plugin-transform-flow-enums": "^0.0.2",
- "debug": "^4.3.4",
- "resolve-from": "^5.0.0"
- },
- "peerDependencies": {
- "@babel/runtime": "^7.20.0",
- "expo": "*",
- "react-refresh": ">=0.14.0 <1.0.0"
- },
- "peerDependenciesMeta": {
- "@babel/runtime": {
- "optional": true
- },
- "expo": {
- "optional": true
- }
- }
- },
"node_modules/expo/node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -4844,6 +5924,12 @@
"integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==",
"license": "Apache-2.0"
},
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "license": "MIT"
+ },
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -4871,6 +5957,15 @@
"node": ">=8"
}
},
+ "node_modules/filter-obj": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz",
+ "integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/finalhandler": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz",
@@ -5107,6 +6202,21 @@
"hermes-estree": "0.32.0"
}
},
+ "node_modules/hoist-non-react-statics": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
+ "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "react-is": "^16.7.0"
+ }
+ },
+ "node_modules/hoist-non-react-statics/node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "license": "MIT"
+ },
"node_modules/hosted-git-info": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz",
@@ -5125,6 +6235,19 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
+ "node_modules/html2canvas": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
+ "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
+ "license": "MIT",
+ "dependencies": {
+ "css-line-break": "^2.1.0",
+ "text-segmentation": "^1.0.3"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -5167,6 +6290,15 @@
"node": ">= 14"
}
},
+ "node_modules/iceberg-js": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
+ "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -5252,6 +6384,12 @@
"loose-envify": "^1.0.0"
}
},
+ "node_modules/is-arrayish": {
+ "version": "0.3.4",
+ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
+ "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
+ "license": "MIT"
+ },
"node_modules/is-core-module": {
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
@@ -5300,6 +6438,15 @@
"node": ">=0.12.0"
}
},
+ "node_modules/is-plain-obj": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
+ "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/is-wsl": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
@@ -6198,6 +7345,18 @@
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
"license": "MIT"
},
+ "node_modules/merge-options": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz",
+ "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "is-plain-obj": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@@ -7190,6 +8349,24 @@
"qrcode-terminal": "bin/qrcode-terminal.js"
}
},
+ "node_modules/query-string": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz",
+ "integrity": "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==",
+ "license": "MIT",
+ "dependencies": {
+ "decode-uri-component": "^0.2.2",
+ "filter-obj": "^1.1.0",
+ "split-on-first": "^1.0.0",
+ "strict-uri-encode": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/queue": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
@@ -7242,6 +8419,18 @@
"ws": "^7"
}
},
+ "node_modules/react-freeze": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/react-freeze/-/react-freeze-1.0.4.tgz",
+ "integrity": "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "react": ">=17.0.0"
+ }
+ },
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
@@ -7305,6 +8494,21 @@
}
}
},
+ "node_modules/react-native-gesture-handler": {
+ "version": "2.28.0",
+ "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz",
+ "integrity": "sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A==",
+ "license": "MIT",
+ "dependencies": {
+ "@egjs/hammerjs": "^2.0.17",
+ "hoist-non-react-statics": "^3.3.0",
+ "invariant": "^2.2.4"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*"
+ }
+ },
"node_modules/react-native-is-edge-to-edge": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz",
@@ -7315,6 +8519,97 @@
"react-native": "*"
}
},
+ "node_modules/react-native-reanimated": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.7.tgz",
+ "integrity": "sha512-Q4H6xA3Tn7QL0/E/KjI86I1KK4tcf+ErRE04LH34Etka2oVQhW6oXQ+Q8ZcDCVxiWp5vgbBH6XcH8BOo4w/Rhg==",
+ "license": "MIT",
+ "dependencies": {
+ "react-native-is-edge-to-edge": "^1.2.1",
+ "semver": "^7.7.2"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "0.78 - 0.82",
+ "react-native-worklets": "0.5 - 0.8"
+ }
+ },
+ "node_modules/react-native-safe-area-context": {
+ "version": "5.6.2",
+ "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz",
+ "integrity": "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*"
+ }
+ },
+ "node_modules/react-native-screens": {
+ "version": "4.16.0",
+ "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.16.0.tgz",
+ "integrity": "sha512-yIAyh7F/9uWkOzCi1/2FqvNvK6Wb9Y1+Kzn16SuGfN9YFJDTbwlzGRvePCNTOX0recpLQF3kc2FmvMUhyTCH1Q==",
+ "license": "MIT",
+ "dependencies": {
+ "react-freeze": "^1.0.0",
+ "react-native-is-edge-to-edge": "^1.2.1",
+ "warn-once": "^0.1.0"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*"
+ }
+ },
+ "node_modules/react-native-url-polyfill": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/react-native-url-polyfill/-/react-native-url-polyfill-3.0.0.tgz",
+ "integrity": "sha512-aA5CiuUCUb/lbrliVCJ6lZ17/RpNJzvTO/C7gC/YmDQhTUoRD5q5HlJfwLWcxz4VgAhHwXKzhxH+wUN24tAdqg==",
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-url-without-unicode": "8.0.0-3"
+ },
+ "peerDependencies": {
+ "react-native": "*"
+ }
+ },
+ "node_modules/react-native-view-shot": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/react-native-view-shot/-/react-native-view-shot-4.0.3.tgz",
+ "integrity": "sha512-USNjYmED7C0me02c1DxKA0074Hw+y/nxo+xJKlffMvfUWWzL5ELh/TJA/pTnVqFurIrzthZDPtDM7aBFJuhrHQ==",
+ "license": "MIT",
+ "dependencies": {
+ "html2canvas": "^1.4.1"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*"
+ }
+ },
+ "node_modules/react-native-worklets": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.8.1.tgz",
+ "integrity": "sha512-oWP/lStsAHU6oYCaWDXrda/wOHVdhusQJz1e6x9gPnXdFf4ndNDAOtWCmk2zGrAnlapfyA3rM6PCQq94mPg9cw==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/plugin-transform-arrow-functions": "^7.27.1",
+ "@babel/plugin-transform-class-properties": "^7.27.1",
+ "@babel/plugin-transform-classes": "^7.28.4",
+ "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1",
+ "@babel/plugin-transform-optional-chaining": "^7.27.1",
+ "@babel/plugin-transform-shorthand-properties": "^7.27.1",
+ "@babel/plugin-transform-template-literals": "^7.27.1",
+ "@babel/plugin-transform-unicode-regex": "^7.27.1",
+ "@babel/preset-typescript": "^7.27.1",
+ "convert-source-map": "^2.0.0",
+ "semver": "^7.7.3"
+ },
+ "peerDependencies": {
+ "@babel/core": "*",
+ "@react-native/metro-config": "*",
+ "react": "*",
+ "react-native": "0.81 - 0.85"
+ }
+ },
"node_modules/react-native/node_modules/@react-native/virtualized-lists": {
"version": "0.81.5",
"resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.81.5.tgz",
@@ -7779,6 +9074,15 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
+ "node_modules/sf-symbols-typescript": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/sf-symbols-typescript/-/sf-symbols-typescript-2.2.0.tgz",
+ "integrity": "sha512-TPbeg0b7ylrswdGCji8FRGFAKuqbpQlLbL8SOle3j1iHSs5Ob5mhvMAxWN2UItOjgALAB5Zp3fmMfj8mbWvXKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -7829,6 +9133,15 @@
"plist": "^3.0.5"
}
},
+ "node_modules/simple-swizzle": {
+ "version": "0.2.4",
+ "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
+ "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
+ "license": "MIT",
+ "dependencies": {
+ "is-arrayish": "^0.3.1"
+ }
+ },
"node_modules/sisteransi": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
@@ -7890,6 +9203,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/split-on-first": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz",
+ "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
@@ -7953,6 +9275,15 @@
"node": ">= 0.10.0"
}
},
+ "node_modules/strict-uri-encode": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz",
+ "integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@@ -8205,6 +9536,15 @@
"node": "*"
}
},
+ "node_modules/text-segmentation": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
+ "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
+ "license": "MIT",
+ "dependencies": {
+ "utrie": "^1.0.2"
+ }
+ },
"node_modules/thenify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
@@ -8310,6 +9650,12 @@
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"license": "Apache-2.0"
},
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
"node_modules/type-detect": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
@@ -8328,6 +9674,20 @@
"node": ">=8"
}
},
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
"node_modules/undici": {
"version": "6.25.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz",
@@ -8422,6 +9782,24 @@
"browserslist": ">= 4.21.0"
}
},
+ "node_modules/use-latest-callback": {
+ "version": "0.2.6",
+ "resolved": "https://registry.npmjs.org/use-latest-callback/-/use-latest-callback-0.2.6.tgz",
+ "integrity": "sha512-FvRG9i1HSo0wagmX63Vrm8SnlUU3LMM3WyZkQ76RnslpBrX694AdG4A0zQBx2B3ZifFA0yv/BaEHGBnEax5rZg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": ">=16.8"
+ }
+ },
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@@ -8431,6 +9809,15 @@
"node": ">= 0.4.0"
}
},
+ "node_modules/utrie": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
+ "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
+ "license": "MIT",
+ "dependencies": {
+ "base64-arraybuffer": "^1.0.2"
+ }
+ },
"node_modules/uuid": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz",
@@ -8473,6 +9860,12 @@
"makeerror": "1.0.12"
}
},
+ "node_modules/warn-once": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/warn-once/-/warn-once-0.1.1.tgz",
+ "integrity": "sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q==",
+ "license": "MIT"
+ },
"node_modules/wcwidth": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
@@ -8734,6 +10127,35 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
+ },
+ "node_modules/zustand": {
+ "version": "5.0.12",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz",
+ "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18.0.0",
+ "immer": ">=9.0.6",
+ "react": ">=18.0.0",
+ "use-sync-external-store": ">=1.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "use-sync-external-store": {
+ "optional": true
+ }
+ }
}
}
}
diff --git a/package.json b/package.json
index 4a4b417..c6f6306 100644
--- a/package.json
+++ b/package.json
@@ -9,10 +9,32 @@
"web": "expo start --web"
},
"dependencies": {
+ "@react-native-async-storage/async-storage": "2.2.0",
+ "@react-navigation/bottom-tabs": "^7.15.9",
+ "@react-navigation/native": "^7.2.2",
+ "@react-navigation/native-stack": "^7.14.11",
+ "@supabase/ssr": "^0.10.2",
+ "@supabase/supabase-js": "^2.103.0",
+ "babel-preset-expo": "~54.0.10",
"expo": "~54.0.33",
+ "expo-linear-gradient": "~15.0.8",
+ "expo-media-library": "~18.2.1",
+ "expo-print": "~15.0.8",
+ "expo-sharing": "~14.0.8",
"expo-status-bar": "~3.0.9",
"react": "19.1.0",
- "react-native": "0.81.5"
+ "react-native": "0.81.5",
+ "react-native-gesture-handler": "~2.28.0",
+ "react-native-reanimated": "~4.1.1",
+ "react-native-safe-area-context": "~5.6.0",
+ "react-native-screens": "~4.16.0",
+ "react-native-url-polyfill": "^3.0.0",
+ "react-native-view-shot": "4.0.3",
+ "zustand": "^5.0.12"
},
- "private": true
+ "private": true,
+ "devDependencies": {
+ "@types/react": "~19.1.10",
+ "typescript": "~5.9.2"
+ }
}
diff --git a/src/components/Button.js b/src/components/Button.js
new file mode 100644
index 0000000..c534d97
--- /dev/null
+++ b/src/components/Button.js
@@ -0,0 +1,93 @@
+import React, { useRef } from 'react';
+import { Animated, Text, StyleSheet, ActivityIndicator, Pressable } from 'react-native';
+import { colors, fonts, borderRadius, spacing } from '../utils/theme';
+
+export default function Button({
+ title,
+ onPress,
+ variant = 'primary',
+ loading = false,
+ disabled = false,
+ style,
+}) {
+ const isPrimary = variant === 'primary';
+ const scale = useRef(new Animated.Value(1)).current;
+
+ const onPressIn = () => {
+ Animated.spring(scale, {
+ toValue: 0.93,
+ useNativeDriver: true,
+ speed: 50,
+ bounciness: 4,
+ }).start();
+ };
+
+ const onPressOut = () => {
+ Animated.spring(scale, {
+ toValue: 1,
+ useNativeDriver: true,
+ speed: 12,
+ bounciness: 8,
+ }).start();
+ };
+
+ return (
+
+
+ {loading ? (
+
+ ) : (
+
+ {title}
+
+ )}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ button: {
+ paddingVertical: spacing.md,
+ paddingHorizontal: spacing.xl,
+ borderRadius: borderRadius.lg,
+ alignItems: 'center',
+ justifyContent: 'center',
+ minHeight: 52,
+ },
+ primary: {
+ backgroundColor: colors.primary,
+ shadowColor: colors.primary,
+ shadowOffset: { width: 0, height: 4 },
+ shadowOpacity: 0.4,
+ shadowRadius: 12,
+ elevation: 6,
+ },
+ secondary: {
+ backgroundColor: 'transparent',
+ borderWidth: 1.5,
+ borderColor: colors.primary,
+ },
+ disabled: {
+ opacity: 0.5,
+ },
+ text: {
+ color: colors.text,
+ fontSize: fonts.sizes.md,
+ fontWeight: fonts.weights.semibold,
+ textAlign: 'center',
+ },
+ secondaryText: {
+ color: colors.primary,
+ },
+});
diff --git a/src/components/HabitInput.js b/src/components/HabitInput.js
new file mode 100644
index 0000000..8cf0c8a
--- /dev/null
+++ b/src/components/HabitInput.js
@@ -0,0 +1,77 @@
+import React, { useRef, useEffect } from 'react';
+import { View, TextInput, Pressable, Text, StyleSheet, Animated } from 'react-native';
+import { colors, fonts, spacing, borderRadius } from '../utils/theme';
+
+export default function HabitInput({ value, onChangeText, onDelete, index, autoFocus }) {
+ const fadeAnim = useRef(new Animated.Value(0)).current;
+ const slideAnim = useRef(new Animated.Value(-10)).current;
+
+ useEffect(() => {
+ Animated.parallel([
+ Animated.timing(fadeAnim, { toValue: 1, duration: 300, useNativeDriver: true }),
+ Animated.timing(slideAnim, { toValue: 0, duration: 300, useNativeDriver: true }),
+ ]).start();
+ }, []);
+
+ return (
+
+
+ {index + 1}
+
+
+
+ ✕
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: colors.surface,
+ borderRadius: borderRadius.md,
+ borderWidth: 1,
+ borderColor: colors.border,
+ marginBottom: spacing.sm,
+ overflow: 'hidden',
+ },
+ indexBadge: {
+ width: 32,
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingVertical: spacing.md,
+ backgroundColor: 'rgba(108, 99, 255, 0.08)',
+ },
+ indexText: {
+ color: colors.primary,
+ fontSize: fonts.sizes.sm,
+ fontWeight: fonts.weights.bold,
+ },
+ input: {
+ flex: 1,
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.md,
+ color: colors.text,
+ fontSize: fonts.sizes.md,
+ },
+ deleteBtn: {
+ width: 40,
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingVertical: spacing.md,
+ },
+ deleteIcon: {
+ color: colors.textMuted,
+ fontSize: 14,
+ },
+});
diff --git a/src/components/Input.js b/src/components/Input.js
new file mode 100644
index 0000000..a6cc22b
--- /dev/null
+++ b/src/components/Input.js
@@ -0,0 +1,87 @@
+import React from 'react';
+import { TextInput, View, Text, StyleSheet, Pressable } from 'react-native';
+import { colors, fonts, borderRadius, spacing } from '../utils/theme';
+
+export default function Input({
+ label,
+ value,
+ onChangeText,
+ placeholder,
+ secureTextEntry = false,
+ autoCapitalize = 'none',
+ keyboardType = 'default',
+ multiline = false,
+ rightIcon,
+ onRightIconPress,
+ style,
+}) {
+ return (
+
+ {label && {label}}
+
+
+ {rightIcon && (
+
+ {rightIcon}
+
+ )}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ marginBottom: spacing.md,
+ },
+ label: {
+ color: colors.textSecondary,
+ fontSize: fonts.sizes.sm,
+ fontWeight: fonts.weights.medium,
+ marginBottom: spacing.xs,
+ },
+ inputRow: {
+ position: 'relative',
+ },
+ input: {
+ backgroundColor: colors.surface,
+ borderRadius: borderRadius.md,
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.md,
+ color: colors.text,
+ fontSize: fonts.sizes.md,
+ borderWidth: 1,
+ borderColor: colors.border,
+ },
+ inputWithIcon: {
+ paddingRight: 48,
+ },
+ multiline: {
+ minHeight: 100,
+ textAlignVertical: 'top',
+ },
+ iconBtn: {
+ position: 'absolute',
+ right: 0,
+ top: 0,
+ bottom: 0,
+ width: 48,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ iconText: {
+ fontSize: 18,
+ color: colors.textSecondary,
+ },
+});
diff --git a/src/components/NovaAlert.js b/src/components/NovaAlert.js
new file mode 100644
index 0000000..b981038
--- /dev/null
+++ b/src/components/NovaAlert.js
@@ -0,0 +1,179 @@
+import React, { useRef, useEffect } from 'react';
+import { View, Text, StyleSheet, Modal, Animated, Pressable } from 'react-native';
+import { colors, fonts, spacing, borderRadius } from '../utils/theme';
+
+let _showAlert = null;
+
+/**
+ * Show a themed alert. Call from anywhere:
+ * import { showAlert } from '../components/NovaAlert';
+ * showAlert('Title', 'Message', [{ text: 'OK' }]);
+ */
+export function showAlert(title, message, buttons = [{ text: 'OK' }]) {
+ if (_showAlert) _showAlert({ title, message, buttons });
+}
+
+export default function NovaAlertProvider({ children }) {
+ const [alert, setAlert] = React.useState(null);
+ const fadeAnim = useRef(new Animated.Value(0)).current;
+ const scaleAnim = useRef(new Animated.Value(0.85)).current;
+
+ useEffect(() => {
+ _showAlert = (data) => setAlert(data);
+ return () => { _showAlert = null; };
+ }, []);
+
+ useEffect(() => {
+ if (alert) {
+ fadeAnim.setValue(0);
+ scaleAnim.setValue(0.85);
+ Animated.parallel([
+ Animated.timing(fadeAnim, { toValue: 1, duration: 200, useNativeDriver: true }),
+ Animated.spring(scaleAnim, { toValue: 1, useNativeDriver: true, speed: 20, bounciness: 8 }),
+ ]).start();
+ }
+ }, [alert]);
+
+ const dismiss = (callback) => {
+ Animated.timing(fadeAnim, { toValue: 0, duration: 150, useNativeDriver: true }).start(() => {
+ setAlert(null);
+ if (callback) callback();
+ });
+ };
+
+ return (
+ <>
+ {children}
+
+
+ dismiss()} />
+
+ {/* Accent line */}
+
+
+ {alert?.title && (
+ {alert.title}
+ )}
+ {alert?.message && (
+ {alert.message}
+ )}
+
+
+ {(alert?.buttons || []).map((btn, i) => {
+ const isDestructive = btn.style === 'destructive';
+ const isCancel = btn.style === 'cancel';
+ const isLast = i === (alert?.buttons || []).length - 1;
+
+ return (
+ [
+ styles.button,
+ isCancel && styles.buttonCancel,
+ isDestructive && styles.buttonDestructive,
+ !isCancel && !isDestructive && styles.buttonPrimary,
+ pressed && styles.buttonPressed,
+ i > 0 && { marginLeft: spacing.sm },
+ ]}
+ onPress={() => dismiss(btn.onPress)}
+ >
+
+ {btn.text}
+
+
+ );
+ })}
+
+
+
+
+ >
+ );
+}
+
+const styles = StyleSheet.create({
+ overlay: {
+ flex: 1,
+ backgroundColor: 'rgba(0, 0, 0, 0.7)',
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: spacing.xl,
+ },
+ overlayPress: {
+ ...StyleSheet.absoluteFillObject,
+ },
+ card: {
+ backgroundColor: colors.surface,
+ borderRadius: borderRadius.xl,
+ borderWidth: 1,
+ borderColor: colors.border,
+ width: '100%',
+ maxWidth: 340,
+ overflow: 'hidden',
+ },
+ accentLine: {
+ height: 3,
+ backgroundColor: colors.primary,
+ },
+ title: {
+ color: colors.text,
+ fontSize: fonts.sizes.lg,
+ fontWeight: fonts.weights.bold,
+ textAlign: 'center',
+ paddingTop: spacing.xl,
+ paddingHorizontal: spacing.lg,
+ },
+ message: {
+ color: colors.textSecondary,
+ fontSize: fonts.sizes.md,
+ textAlign: 'center',
+ lineHeight: 22,
+ paddingTop: spacing.md,
+ paddingHorizontal: spacing.lg,
+ paddingBottom: spacing.lg,
+ },
+ buttons: {
+ flexDirection: 'row',
+ paddingHorizontal: spacing.lg,
+ paddingBottom: spacing.lg,
+ },
+ button: {
+ flex: 1,
+ paddingVertical: spacing.md,
+ borderRadius: borderRadius.md,
+ alignItems: 'center',
+ justifyContent: 'center',
+ minHeight: 44,
+ },
+ buttonPrimary: {
+ backgroundColor: colors.primary,
+ },
+ buttonCancel: {
+ backgroundColor: 'rgba(138, 143, 181, 0.1)',
+ borderWidth: 1,
+ borderColor: colors.border,
+ },
+ buttonDestructive: {
+ backgroundColor: 'rgba(255, 82, 82, 0.12)',
+ borderWidth: 1,
+ borderColor: 'rgba(255, 82, 82, 0.3)',
+ },
+ buttonPressed: {
+ opacity: 0.7,
+ },
+ buttonText: {
+ color: colors.text,
+ fontSize: fonts.sizes.md,
+ fontWeight: fonts.weights.semibold,
+ },
+ buttonTextCancel: {
+ color: colors.textSecondary,
+ },
+ buttonTextDestructive: {
+ color: colors.error,
+ },
+});
diff --git a/src/components/OrbitContainer.js b/src/components/OrbitContainer.js
new file mode 100644
index 0000000..6b5e7ea
--- /dev/null
+++ b/src/components/OrbitContainer.js
@@ -0,0 +1,134 @@
+import React, { useMemo, useEffect, useRef } from 'react';
+import { View, StyleSheet, Animated } from 'react-native';
+import OrbitDot from './OrbitDot';
+import PlanetCore from './PlanetCore';
+import { colors } from '../utils/theme';
+
+const TOTAL_DAYS = 40;
+
+export default function OrbitContainer({
+ size = 280,
+ currentDay = 1,
+ planetSize = 80,
+ glowIntensity = 0.5,
+ onDotPress,
+}) {
+ const radius = size / 2;
+ const center = size / 2;
+ const rotateAnim = useRef(new Animated.Value(0)).current;
+
+ // Slow subtle orbit rotation
+ useEffect(() => {
+ const anim = Animated.loop(
+ Animated.timing(rotateAnim, {
+ toValue: 1,
+ duration: 120000, // 2 minutes per rotation
+ useNativeDriver: true,
+ })
+ );
+ anim.start();
+ return () => anim.stop();
+ }, []);
+
+ const rotation = rotateAnim.interpolate({
+ inputRange: [0, 1],
+ outputRange: ['0deg', '360deg'],
+ });
+
+ // Calculate dot positions
+ const dots = useMemo(() => {
+ const angleOffset = -Math.PI / 2; // Start from top
+ return Array.from({ length: TOTAL_DAYS }, (_, i) => {
+ const angle = angleOffset + (i / TOTAL_DAYS) * 2 * Math.PI;
+ const x = center + radius * 0.88 * Math.cos(angle);
+ const y = center + radius * 0.88 * Math.sin(angle);
+ const dayNum = i + 1;
+
+ let status = 'future';
+ if (dayNum < currentDay) status = 'completed';
+ else if (dayNum === currentDay) status = 'current';
+
+ return { index: dayNum, x, y, status };
+ });
+ }, [currentDay, size]);
+
+ return (
+
+ {/* Orbit ring */}
+
+
+ {/* Gradient trail for completed portion */}
+ {currentDay > 1 && (
+
+ )}
+
+ {/* Rotating dot layer */}
+
+ {dots.map((dot) => (
+
+ ))}
+
+
+ {/* Planet center (does not rotate) */}
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ orbitRing: {
+ position: 'absolute',
+ borderWidth: 1,
+ borderColor: colors.orbitLine,
+ borderStyle: 'dashed',
+ },
+ orbitTrail: {
+ position: 'absolute',
+ borderWidth: 1.5,
+ },
+ dotsLayer: {
+ position: 'absolute',
+ },
+ planetWrapper: {
+ position: 'absolute',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+});
diff --git a/src/components/OrbitDot.js b/src/components/OrbitDot.js
new file mode 100644
index 0000000..d0545bb
--- /dev/null
+++ b/src/components/OrbitDot.js
@@ -0,0 +1,145 @@
+import React, { useEffect, useRef, memo } from 'react';
+import { Animated, StyleSheet, Pressable } from 'react-native';
+import { colors } from '../utils/theme';
+
+const DOT_SIZE_DEFAULT = 6;
+const DOT_SIZE_CURRENT = 12;
+const DOT_SIZE_COMPLETED = 7;
+
+function OrbitDot({ index, x, y, status, onPress }) {
+ // status: 'completed' | 'current' | 'future'
+ const pulseAnim = useRef(new Animated.Value(1)).current;
+ const glowOpacity = useRef(new Animated.Value(0)).current;
+
+ useEffect(() => {
+ if (status === 'current') {
+ const pulse = Animated.loop(
+ Animated.sequence([
+ Animated.timing(pulseAnim, { toValue: 1.6, duration: 1200, useNativeDriver: true }),
+ Animated.timing(pulseAnim, { toValue: 1, duration: 1200, useNativeDriver: true }),
+ ])
+ );
+ const glow = Animated.loop(
+ Animated.sequence([
+ Animated.timing(glowOpacity, { toValue: 0.8, duration: 1200, useNativeDriver: true }),
+ Animated.timing(glowOpacity, { toValue: 0.2, duration: 1200, useNativeDriver: true }),
+ ])
+ );
+ pulse.start();
+ glow.start();
+ return () => { pulse.stop(); glow.stop(); };
+ }
+ }, [status]);
+
+ const isCurrent = status === 'current';
+ const isCompleted = status === 'completed';
+ const isFuture = status === 'future';
+
+ const dotSize = isCurrent ? DOT_SIZE_CURRENT : isCompleted ? DOT_SIZE_COMPLETED : DOT_SIZE_DEFAULT;
+
+ const dotColor = isCurrent
+ ? colors.orbitActive
+ : isCompleted
+ ? colors.primary
+ : 'rgba(108, 99, 255, 0.15)';
+
+ const content = (
+ <>
+ {/* Glow ring for current day */}
+ {isCurrent && (
+
+ )}
+
+ {/* Completed glow */}
+ {isCompleted && (
+
+ )}
+
+ {/* Dot */}
+
+ >
+ );
+
+ const containerStyle = [
+ styles.container,
+ {
+ left: x - DOT_SIZE_CURRENT * 1.25,
+ top: y - DOT_SIZE_CURRENT * 1.25,
+ width: DOT_SIZE_CURRENT * 2.5,
+ height: DOT_SIZE_CURRENT * 2.5,
+ },
+ ];
+
+ if (onPress && (isCurrent || isCompleted)) {
+ return (
+ onPress(index, status)}>
+ {content}
+
+ );
+ }
+
+ return {content};
+}
+
+export default memo(OrbitDot);
+
+const styles = StyleSheet.create({
+ container: {
+ position: 'absolute',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ dot: {},
+ glowRing: {
+ position: 'absolute',
+ backgroundColor: 'rgba(0, 229, 255, 0.2)',
+ },
+ completedGlow: {
+ position: 'absolute',
+ backgroundColor: 'rgba(108, 99, 255, 0.15)',
+ },
+});
diff --git a/src/components/Planet.js b/src/components/Planet.js
new file mode 100644
index 0000000..f362271
--- /dev/null
+++ b/src/components/Planet.js
@@ -0,0 +1,130 @@
+import React, { useEffect, useRef } from 'react';
+import { View, StyleSheet, Animated } from 'react-native';
+import { colors } from '../utils/theme';
+
+export default function Planet({ size = 120, glowIntensity = 1, showOrbit = false, progress = 0 }) {
+ const pulse = useRef(new Animated.Value(1)).current;
+
+ useEffect(() => {
+ const animation = Animated.loop(
+ Animated.sequence([
+ Animated.timing(pulse, { toValue: 1.1, duration: 2000, useNativeDriver: true }),
+ Animated.timing(pulse, { toValue: 1, duration: 2000, useNativeDriver: true }),
+ ])
+ );
+ animation.start();
+ return () => animation.stop();
+ }, []);
+
+ return (
+
+ {showOrbit && (
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ planet: {
+ backgroundColor: colors.planetCore,
+ position: 'absolute',
+ shadowColor: colors.primary,
+ shadowOffset: { width: 0, height: 0 },
+ shadowOpacity: 0.8,
+ shadowRadius: 20,
+ elevation: 10,
+ },
+ highlight: {
+ backgroundColor: 'rgba(255, 255, 255, 0.15)',
+ position: 'absolute',
+ },
+ glow: {
+ backgroundColor: colors.planetGlow,
+ position: 'absolute',
+ },
+ orbit: {
+ borderWidth: 1.5,
+ position: 'absolute',
+ borderStyle: 'dashed',
+ },
+ orbitDot: {
+ position: 'absolute',
+ top: '50%',
+ left: '50%',
+ marginLeft: -6,
+ marginTop: -6,
+ },
+ dot: {
+ width: 12,
+ height: 12,
+ borderRadius: 6,
+ backgroundColor: colors.orbitActive,
+ shadowColor: colors.accent,
+ shadowOffset: { width: 0, height: 0 },
+ shadowOpacity: 1,
+ shadowRadius: 8,
+ elevation: 5,
+ },
+});
diff --git a/src/components/PlanetCore.js b/src/components/PlanetCore.js
new file mode 100644
index 0000000..57d6f2b
--- /dev/null
+++ b/src/components/PlanetCore.js
@@ -0,0 +1,87 @@
+import React, { useEffect, useRef } from 'react';
+import { View, Image, StyleSheet, Animated } from 'react-native';
+import { colors } from '../utils/theme';
+
+export default function PlanetCore({ size = 90, glowIntensity = 0.5 }) {
+ const breathe = useRef(new Animated.Value(0.3)).current;
+ const pulseScale = useRef(new Animated.Value(1)).current;
+
+ useEffect(() => {
+ const opacityAnim = Animated.loop(
+ Animated.sequence([
+ Animated.timing(breathe, {
+ toValue: 0.6 * glowIntensity,
+ duration: 3000,
+ useNativeDriver: true,
+ }),
+ Animated.timing(breathe, {
+ toValue: 0.2 * glowIntensity,
+ duration: 3000,
+ useNativeDriver: true,
+ }),
+ ])
+ );
+ const scaleAnim = Animated.loop(
+ Animated.sequence([
+ Animated.timing(pulseScale, { toValue: 1.08, duration: 3000, useNativeDriver: true }),
+ Animated.timing(pulseScale, { toValue: 1, duration: 3000, useNativeDriver: true }),
+ ])
+ );
+ opacityAnim.start();
+ scaleAnim.start();
+ return () => { opacityAnim.stop(); scaleAnim.stop(); };
+ }, [glowIntensity]);
+
+ return (
+
+ {/* Outer glow */}
+
+
+ {/* Inner glow */}
+
+
+ {/* Planet image */}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ outerGlow: {
+ position: 'absolute',
+ backgroundColor: colors.planetGlow,
+ },
+ innerGlow: {
+ position: 'absolute',
+ backgroundColor: 'rgba(108, 99, 255, 0.35)',
+ },
+});
diff --git a/src/components/ScreenWrapper.js b/src/components/ScreenWrapper.js
new file mode 100644
index 0000000..ab1af89
--- /dev/null
+++ b/src/components/ScreenWrapper.js
@@ -0,0 +1,25 @@
+import React from 'react';
+import { View, StyleSheet, StatusBar } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import StarField from './StarField';
+import { colors } from '../utils/theme';
+
+export default function ScreenWrapper({ children, showStars = true, style }) {
+ return (
+
+
+ {showStars && }
+ {children}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: colors.background,
+ },
+ safe: {
+ flex: 1,
+ },
+});
diff --git a/src/components/StarField.js b/src/components/StarField.js
new file mode 100644
index 0000000..394350f
--- /dev/null
+++ b/src/components/StarField.js
@@ -0,0 +1,126 @@
+import React, { useMemo, useEffect, useRef } from 'react';
+import { View, StyleSheet, Dimensions, Animated } from 'react-native';
+import { colors } from '../utils/theme';
+
+const { width, height } = Dimensions.get('window');
+
+function Star({ x, y, size, delay, glow }) {
+ const opacity = useRef(new Animated.Value(0.15)).current;
+
+ useEffect(() => {
+ const duration = 2000 + Math.random() * 2500;
+ const animation = Animated.loop(
+ Animated.sequence([
+ Animated.timing(opacity, {
+ toValue: glow ? 0.9 : 1,
+ duration,
+ delay,
+ useNativeDriver: true,
+ }),
+ Animated.timing(opacity, {
+ toValue: glow ? 0.2 : 0.15,
+ duration,
+ useNativeDriver: true,
+ }),
+ ])
+ );
+ animation.start();
+ return () => animation.stop();
+ }, []);
+
+ return (
+
+ {/* Glow halo for larger stars */}
+ {glow && (
+
+ )}
+
+
+ );
+}
+
+export default function StarField({ count = 50 }) {
+ const stars = useMemo(() => {
+ const items = [];
+
+ // Regular small stars
+ const smallCount = Math.floor(count * 0.6);
+ for (let i = 0; i < smallCount; i++) {
+ items.push({
+ id: i,
+ x: Math.random() * width,
+ y: Math.random() * height,
+ size: Math.random() * 3 + 1.5, // 1.5–4.5px
+ delay: Math.random() * 3000,
+ glow: false,
+ });
+ }
+
+ // Medium stars
+ const medCount = Math.floor(count * 0.25);
+ for (let i = 0; i < medCount; i++) {
+ items.push({
+ id: smallCount + i,
+ x: Math.random() * width,
+ y: Math.random() * height,
+ size: Math.random() * 3 + 4, // 4–7px
+ delay: Math.random() * 3000,
+ glow: false,
+ });
+ }
+
+ // Large glowing stars
+ const glowCount = count - smallCount - medCount;
+ for (let i = 0; i < glowCount; i++) {
+ items.push({
+ id: smallCount + medCount + i,
+ x: Math.random() * width,
+ y: Math.random() * height,
+ size: Math.random() * 3 + 5, // 5–8px with glow halo
+ delay: Math.random() * 4000,
+ glow: true,
+ });
+ }
+
+ return items;
+ }, [count]);
+
+ return (
+
+ {stars.map((star) => (
+
+ ))}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ ...StyleSheet.absoluteFillObject,
+ },
+ star: {
+ position: 'absolute',
+ backgroundColor: colors.star,
+ },
+});
diff --git a/src/components/games/FocusHold.js b/src/components/games/FocusHold.js
new file mode 100644
index 0000000..701be2f
--- /dev/null
+++ b/src/components/games/FocusHold.js
@@ -0,0 +1,167 @@
+import React, { useState, useEffect, useRef, useCallback } from 'react';
+import { View, Text, StyleSheet, Animated, PanResponder, Dimensions } from 'react-native';
+import { colors, fonts, spacing, borderRadius } from '../../utils/theme';
+import { getDifficulty } from '../../services/gameService';
+
+const TOTAL_ROUNDS = 5;
+
+export default function FocusHold({ currentDay, onComplete }) {
+ const difficulty = getDifficulty(currentDay);
+ const holdDuration = 3000 + difficulty * 500; // 3.5s to 5s
+ const circleSize = Math.max(120 - difficulty * 8, 88);
+
+ const [round, setRound] = useState(0);
+ const [holding, setHolding] = useState(false);
+ const [failed, setFailed] = useState(false);
+ const [successes, setSuccesses] = useState(0);
+ const [message, setMessage] = useState('Press and hold');
+
+ const progress = useRef(new Animated.Value(0)).current;
+ const ringOpacity = useRef(new Animated.Value(0.4)).current;
+ const holdTimer = useRef(null);
+ const animRef = useRef(null);
+
+ const resetRound = useCallback(() => {
+ progress.setValue(0);
+ ringOpacity.setValue(0.4);
+ setHolding(false);
+ setFailed(false);
+ setMessage('Press and hold');
+ }, []);
+
+ useEffect(() => {
+ if (round >= TOTAL_ROUNDS && round > 0) {
+ const score = successes; // 0-5
+ onComplete(score, { rounds: TOTAL_ROUNDS, successes });
+ }
+ }, [round]);
+
+ const startHold = () => {
+ if (round >= TOTAL_ROUNDS) return;
+ setHolding(true);
+ setFailed(false);
+ setMessage('Hold steady...');
+
+ animRef.current = Animated.timing(progress, {
+ toValue: 1,
+ duration: holdDuration,
+ useNativeDriver: false,
+ });
+
+ Animated.timing(ringOpacity, { toValue: 1, duration: 300, useNativeDriver: true }).start();
+
+ animRef.current.start(({ finished }) => {
+ if (finished) {
+ // Success
+ setSuccesses((prev) => prev + 1);
+ setMessage('Perfect!');
+ setHolding(false);
+ setTimeout(() => {
+ setRound((prev) => prev + 1);
+ resetRound();
+ }, 600);
+ }
+ });
+ };
+
+ const endHold = () => {
+ if (!holding) return;
+ // Released too early
+ animRef.current?.stop();
+ setHolding(false);
+ setFailed(true);
+ setMessage('Released too early');
+ Animated.timing(ringOpacity, { toValue: 0.2, duration: 200, useNativeDriver: true }).start();
+
+ setTimeout(() => {
+ setRound((prev) => prev + 1);
+ resetRound();
+ }, 800);
+ };
+
+ const panResponder = useRef(
+ PanResponder.create({
+ onStartShouldSetPanResponder: () => true,
+ onPanResponderGrant: startHold,
+ onPanResponderRelease: endHold,
+ onPanResponderTerminate: endHold,
+ onPanResponderMove: (_, gestureState) => {
+ // Fail if finger moves too far
+ const dist = Math.sqrt(gestureState.dx ** 2 + gestureState.dy ** 2);
+ if (dist > circleSize * 0.6 && holding) {
+ animRef.current?.stop();
+ setHolding(false);
+ setFailed(true);
+ setMessage('Moved outside!');
+ Animated.timing(ringOpacity, { toValue: 0.2, duration: 200, useNativeDriver: true }).start();
+ setTimeout(() => {
+ setRound((prev) => prev + 1);
+ resetRound();
+ }, 800);
+ }
+ },
+ })
+ ).current;
+
+ const fillWidth = progress.interpolate({
+ inputRange: [0, 1],
+ outputRange: ['0%', '100%'],
+ });
+
+ const ringColor = failed ? colors.error : holding ? colors.accent : colors.primary;
+
+ return (
+
+
+ {Math.min(round + 1, TOTAL_ROUNDS)} / {TOTAL_ROUNDS}
+ {successes} held
+
+
+
+ {/* Hold circle */}
+
+
+
+
+ {/* Fill progress */}
+
+
+
+ {holding ? 'Holding...' : round >= TOTAL_ROUNDS ? 'Done' : 'Hold'}
+
+
+
+
+
+ {message}
+ Press and hold the circle without moving
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: { flex: 1, alignItems: 'center' },
+ header: { flexDirection: 'row', justifyContent: 'space-between', width: '100%', paddingHorizontal: spacing.md, marginBottom: spacing.xl },
+ roundText: { color: colors.text, fontSize: fonts.sizes.lg, fontWeight: fonts.weights.bold },
+ successText: { color: colors.success, fontSize: fonts.sizes.md, fontWeight: fonts.weights.medium },
+ holdArea: { flex: 1, alignItems: 'center', justifyContent: 'center' },
+ circleWrapper: { alignItems: 'center', justifyContent: 'center' },
+ ring: { position: 'absolute', borderWidth: 2, borderStyle: 'dashed' },
+ circle: {
+ backgroundColor: colors.surface, borderWidth: 2, borderColor: colors.border,
+ alignItems: 'center', justifyContent: 'center', overflow: 'hidden',
+ },
+ fill: { position: 'absolute', left: 0, top: 0 },
+ circleText: { color: colors.text, fontSize: fonts.sizes.md, fontWeight: fonts.weights.semibold },
+ message: { color: colors.accent, fontSize: fonts.sizes.lg, fontWeight: fonts.weights.semibold, marginBottom: spacing.sm },
+ messageFail: { color: colors.error },
+ hint: { color: colors.textMuted, fontSize: fonts.sizes.sm },
+});
diff --git a/src/components/games/GameResult.js b/src/components/games/GameResult.js
new file mode 100644
index 0000000..c707533
--- /dev/null
+++ b/src/components/games/GameResult.js
@@ -0,0 +1,95 @@
+import React, { useRef, useEffect } from 'react';
+import { View, Text, StyleSheet, Animated } from 'react-native';
+import Button from '../Button';
+import { colors, fonts, spacing, borderRadius } from '../../utils/theme';
+import { getResultMessage, getStatLabel } from '../../services/gameService';
+
+export default function GameResult({ gameType, score, details, onSave, onRetry, saving }) {
+ const fadeIn = useRef(new Animated.Value(0)).current;
+ const scaleScore = useRef(new Animated.Value(0)).current;
+
+ useEffect(() => {
+ Animated.sequence([
+ Animated.timing(fadeIn, { toValue: 1, duration: 400, useNativeDriver: true }),
+ Animated.spring(scaleScore, { toValue: 1, useNativeDriver: true, speed: 10, bounciness: 12 }),
+ ]).start();
+ }, []);
+
+ const message = getResultMessage(gameType, score);
+ const statLabel = getStatLabel(gameType);
+
+ return (
+
+ Challenge Complete
+
+
+ Score
+ {score}
+ +{statLabel}
+
+
+ {message}
+
+ {details && (
+
+ {details.avgReaction && (
+
+ )}
+ {details.rounds && (
+
+ )}
+ {details.successes !== undefined && (
+
+ )}
+ {details.perfects !== undefined && (
+ <>
+
+
+
+ >
+ )}
+ {details.correct !== undefined && (
+
+ )}
+
+ )}
+
+
+
+
+
+
+ );
+}
+
+function DetailRow({ label, value, color }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: spacing.lg },
+ title: { color: colors.accent, fontSize: fonts.sizes.xs, fontWeight: fonts.weights.bold, letterSpacing: 3, textTransform: 'uppercase', marginBottom: spacing.xl },
+ scoreCard: {
+ backgroundColor: colors.surface, borderRadius: borderRadius.xl, paddingVertical: spacing.xl, paddingHorizontal: spacing.xxl,
+ alignItems: 'center', borderWidth: 1, borderColor: colors.primary, marginBottom: spacing.lg,
+ shadowColor: colors.primary, shadowOffset: { width: 0, height: 0 }, shadowOpacity: 0.3, shadowRadius: 20, elevation: 8,
+ },
+ scoreLabel: { color: colors.textSecondary, fontSize: fonts.sizes.sm, marginBottom: spacing.xs },
+ scoreValue: { color: colors.text, fontSize: 56, fontWeight: fonts.weights.bold },
+ statBonus: { color: colors.primary, fontSize: fonts.sizes.sm, fontWeight: fonts.weights.semibold, marginTop: spacing.xs },
+ message: { color: colors.text, fontSize: fonts.sizes.md, textAlign: 'center', lineHeight: 24, marginBottom: spacing.xl, paddingHorizontal: spacing.md },
+ detailsCard: {
+ backgroundColor: colors.surface, borderRadius: borderRadius.md, padding: spacing.lg,
+ width: '100%', borderWidth: 1, borderColor: colors.border, marginBottom: spacing.xl,
+ },
+ detailRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: spacing.sm },
+ detailLabel: { color: colors.textSecondary, fontSize: fonts.sizes.sm },
+ detailValue: { color: colors.text, fontSize: fonts.sizes.sm, fontWeight: fonts.weights.semibold },
+ actions: { width: '100%' },
+ btn: { marginBottom: spacing.sm },
+});
diff --git a/src/components/games/ReflexTap.js b/src/components/games/ReflexTap.js
new file mode 100644
index 0000000..316aa12
--- /dev/null
+++ b/src/components/games/ReflexTap.js
@@ -0,0 +1,140 @@
+import React, { useState, useEffect, useRef, useCallback } from 'react';
+import { View, Text, StyleSheet, Pressable, Dimensions, Animated } from 'react-native';
+import { colors, fonts, spacing, borderRadius } from '../../utils/theme';
+import { getDifficulty } from '../../services/gameService';
+
+const { width: SCREEN_W } = Dimensions.get('window');
+const AREA_SIZE = SCREEN_W - spacing.lg * 2;
+const ROUNDS = 10;
+
+export default function ReflexTap({ currentDay, onComplete }) {
+ const difficulty = getDifficulty(currentDay);
+ const targetSize = Math.max(50 - difficulty * 4, 34);
+ const spawnDelay = Math.max(800 - difficulty * 100, 400);
+
+ const [round, setRound] = useState(0);
+ const [targetPos, setTargetPos] = useState(null);
+ const [spawnTime, setSpawnTime] = useState(0);
+ const [totalReaction, setTotalReaction] = useState(0);
+ const [playing, setPlaying] = useState(false);
+
+ const scale = useRef(new Animated.Value(0)).current;
+ const glow = useRef(new Animated.Value(0.3)).current;
+ const timerRef = useRef(null);
+
+ const spawnTarget = useCallback(() => {
+ const x = Math.random() * (AREA_SIZE - targetSize);
+ const y = Math.random() * (AREA_SIZE - targetSize);
+ setTargetPos({ x, y });
+ setSpawnTime(Date.now());
+
+ scale.setValue(0);
+ glow.setValue(0.3);
+ Animated.parallel([
+ Animated.spring(scale, { toValue: 1, useNativeDriver: true, speed: 20, bounciness: 10 }),
+ Animated.loop(
+ Animated.sequence([
+ Animated.timing(glow, { toValue: 0.8, duration: 600, useNativeDriver: true }),
+ Animated.timing(glow, { toValue: 0.3, duration: 600, useNativeDriver: true }),
+ ])
+ ),
+ ]).start();
+ }, [targetSize]);
+
+ const start = () => {
+ setRound(0);
+ setTotalReaction(0);
+ setPlaying(true);
+ };
+
+ useEffect(() => {
+ if (playing && round < ROUNDS) {
+ timerRef.current = setTimeout(spawnTarget, round === 0 ? 500 : spawnDelay);
+ }
+ if (playing && round >= ROUNDS) {
+ setPlaying(false);
+ setTargetPos(null);
+ const avgMs = Math.round(totalReaction / ROUNDS);
+ // Score: faster = higher. Max ~15 points for <200ms avg, min 1 for >1000ms
+ const score = Math.max(1, Math.min(15, Math.round((1000 - avgMs) / 60)));
+ onComplete(score, { avgReaction: avgMs, rounds: ROUNDS });
+ }
+ return () => clearTimeout(timerRef.current);
+ }, [playing, round]);
+
+ const handleTap = () => {
+ if (!targetPos) return;
+ const reaction = Date.now() - spawnTime;
+ setTotalReaction((prev) => prev + reaction);
+ setTargetPos(null);
+
+ // Hit animation
+ Animated.timing(scale, { toValue: 0, duration: 100, useNativeDriver: true }).start(() => {
+ setRound((prev) => prev + 1);
+ });
+ };
+
+ useEffect(() => { start(); }, []);
+
+ return (
+
+
+ {round} / {ROUNDS}
+ {totalReaction > 0 && round > 0 && (
+ {Math.round(totalReaction / round)}ms avg
+ )}
+
+
+
+ {!playing && round === 0 && (
+ Get ready...
+ )}
+
+ {targetPos && (
+
+
+
+
+
+
+ )}
+
+
+ Tap the glowing targets as fast as you can
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: { flex: 1, alignItems: 'center' },
+ header: { flexDirection: 'row', justifyContent: 'space-between', width: '100%', paddingHorizontal: spacing.md, marginBottom: spacing.md },
+ roundText: { color: colors.text, fontSize: fonts.sizes.lg, fontWeight: fonts.weights.bold },
+ avgText: { color: colors.accent, fontSize: fonts.sizes.md, fontWeight: fonts.weights.medium },
+ area: {
+ backgroundColor: 'rgba(19, 24, 49, 0.6)', borderRadius: borderRadius.lg,
+ borderWidth: 1, borderColor: colors.border, overflow: 'hidden',
+ },
+ readyText: { color: colors.textMuted, fontSize: fonts.sizes.lg, position: 'absolute', top: '45%', alignSelf: 'center' },
+ targetHitbox: { position: 'absolute', alignItems: 'center', justifyContent: 'center' },
+ targetGlow: { position: 'absolute', backgroundColor: colors.accentGlow },
+ target: { backgroundColor: 'rgba(0, 229, 255, 0.25)', alignItems: 'center', justifyContent: 'center' },
+ targetCore: {
+ backgroundColor: colors.accent,
+ shadowColor: colors.accent, shadowOffset: { width: 0, height: 0 }, shadowOpacity: 1, shadowRadius: 12, elevation: 8,
+ },
+ hint: { color: colors.textMuted, fontSize: fonts.sizes.sm, marginTop: spacing.md },
+});
diff --git a/src/components/games/TemptationChoice.js b/src/components/games/TemptationChoice.js
new file mode 100644
index 0000000..54d3af1
--- /dev/null
+++ b/src/components/games/TemptationChoice.js
@@ -0,0 +1,173 @@
+import React, { useState, useEffect, useRef } from 'react';
+import { View, Text, StyleSheet, Animated, Pressable } from 'react-native';
+import { colors, fonts, spacing, borderRadius } from '../../utils/theme';
+
+const SCENARIOS = [
+ {
+ situation: "It's 6 AM. Your alarm rings. You're exhausted.",
+ skip: 'Hit snooze and sleep in',
+ cont: 'Get up. The old you would sleep.',
+ correct: 'cont',
+ },
+ {
+ situation: 'Your friends invite you out. You haven\'t done your habit yet.',
+ skip: 'Go out. You\'ll do it tomorrow.',
+ cont: 'Finish first. Identity comes first.',
+ correct: 'cont',
+ },
+ {
+ situation: "You failed yesterday. You feel like giving up.",
+ skip: 'Take a break. Start again next week.',
+ cont: 'One bad day doesn\'t erase progress.',
+ correct: 'cont',
+ },
+ {
+ situation: 'You\'re scrolling social media. 30 minutes have passed.',
+ skip: 'Keep scrolling. You deserve a break.',
+ cont: 'Close the app. Reclaim your time.',
+ correct: 'cont',
+ },
+ {
+ situation: 'Your workout is hard today. Everything hurts.',
+ skip: 'Stop early. You did enough.',
+ cont: 'Finish the set. Pain is temporary.',
+ correct: 'cont',
+ },
+ {
+ situation: "Nobody would know if you skipped today.",
+ skip: 'Skip it. No one is watching.',
+ cont: 'You would know. Do it for yourself.',
+ correct: 'cont',
+ },
+ {
+ situation: "You're tired after work. Your habit feels impossible.",
+ skip: 'Rest tonight. Tomorrow will be better.',
+ cont: 'Do even 5 minutes. Show up.',
+ correct: 'cont',
+ },
+];
+
+const ROUNDS = 5;
+
+export default function TemptationChoice({ onComplete }) {
+ const [round, setRound] = useState(0);
+ const [score, setScore] = useState(0);
+ const [feedback, setFeedback] = useState(null);
+ const [scenarios] = useState(() => {
+ // Shuffle and pick ROUNDS
+ const shuffled = [...SCENARIOS].sort(() => Math.random() - 0.5);
+ return shuffled.slice(0, ROUNDS);
+ });
+
+ const fadeIn = useRef(new Animated.Value(0)).current;
+ const feedbackScale = useRef(new Animated.Value(0)).current;
+
+ useEffect(() => {
+ if (round < ROUNDS) {
+ fadeIn.setValue(0);
+ Animated.timing(fadeIn, { toValue: 1, duration: 400, useNativeDriver: true }).start();
+ }
+ }, [round]);
+
+ useEffect(() => {
+ if (round >= ROUNDS && round > 0) {
+ onComplete(score, { rounds: ROUNDS, correct: score });
+ }
+ }, [round]);
+
+ const handleChoice = (choice) => {
+ if (feedback) return;
+ const scenario = scenarios[round];
+ const isCorrect = choice === scenario.correct;
+
+ if (isCorrect) setScore((prev) => prev + 1);
+
+ setFeedback({
+ correct: isCorrect,
+ message: isCorrect
+ ? 'Your identity speaks louder than comfort.'
+ : 'The old you made that choice. Choose again next time.',
+ });
+
+ feedbackScale.setValue(0);
+ Animated.spring(feedbackScale, { toValue: 1, useNativeDriver: true, speed: 14, bounciness: 8 }).start();
+
+ setTimeout(() => {
+ setFeedback(null);
+ setRound((prev) => prev + 1);
+ }, 1800);
+ };
+
+ if (round >= ROUNDS) return null;
+
+ const scenario = scenarios[round];
+
+ return (
+
+
+ {round + 1} / {ROUNDS}
+ {score} correct
+
+
+
+ {scenario.situation}
+
+
+ {!feedback ? (
+
+ handleChoice('skip')}
+ >
+ {scenario.skip}
+
+
+ handleChoice('cont')}
+ >
+ {scenario.cont}
+
+
+ ) : (
+
+
+
+ {feedback.correct ? 'Identity Aligned' : 'Temptation Won'}
+
+ {feedback.message}
+
+ )}
+
+ Choose how your new identity would respond
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: { flex: 1, alignItems: 'center' },
+ header: { flexDirection: 'row', justifyContent: 'space-between', width: '100%', paddingHorizontal: spacing.md, marginBottom: spacing.xl },
+ roundText: { color: colors.text, fontSize: fonts.sizes.lg, fontWeight: fonts.weights.bold },
+ scoreText: { color: colors.success, fontSize: fonts.sizes.md, fontWeight: fonts.weights.medium },
+ card: {
+ backgroundColor: colors.surface, borderRadius: borderRadius.lg, padding: spacing.xl,
+ borderWidth: 1, borderColor: colors.border, width: '100%', marginBottom: spacing.xl,
+ },
+ situation: { color: colors.text, fontSize: fonts.sizes.lg, fontWeight: fonts.weights.semibold, lineHeight: 28, textAlign: 'center' },
+ choices: { width: '100%', gap: spacing.md },
+ choiceBtn: {
+ paddingVertical: spacing.lg, paddingHorizontal: spacing.lg,
+ borderRadius: borderRadius.md, borderWidth: 1.5,
+ },
+ skipBtn: { borderColor: 'rgba(255, 82, 82, 0.4)', backgroundColor: 'rgba(255, 82, 82, 0.06)' },
+ contBtn: { borderColor: 'rgba(0, 230, 118, 0.4)', backgroundColor: 'rgba(0, 230, 118, 0.06)' },
+ choiceBtnText: { color: colors.text, fontSize: fonts.sizes.md, textAlign: 'center', lineHeight: 22 },
+ feedbackCard: {
+ backgroundColor: colors.surface, borderRadius: borderRadius.lg, padding: spacing.xl,
+ borderWidth: 1, borderColor: colors.border, width: '100%', alignItems: 'center',
+ },
+ feedbackDot: { width: 10, height: 10, borderRadius: 5, marginBottom: spacing.sm },
+ feedbackText: { fontSize: fonts.sizes.lg, fontWeight: fonts.weights.bold, marginBottom: spacing.sm },
+ feedbackMessage: { color: colors.textSecondary, fontSize: fonts.sizes.sm, textAlign: 'center', lineHeight: 20 },
+ hint: { color: colors.textMuted, fontSize: fonts.sizes.sm, marginTop: 'auto', paddingBottom: spacing.md },
+});
diff --git a/src/components/games/TimingTap.js b/src/components/games/TimingTap.js
new file mode 100644
index 0000000..b5ac273
--- /dev/null
+++ b/src/components/games/TimingTap.js
@@ -0,0 +1,189 @@
+import React, { useState, useEffect, useRef, useCallback } from 'react';
+import { View, Text, StyleSheet, Pressable, Animated, Dimensions } from 'react-native';
+import { colors, fonts, spacing, borderRadius } from '../../utils/theme';
+import { getDifficulty } from '../../services/gameService';
+
+const { width: SCREEN_W } = Dimensions.get('window');
+const BAR_WIDTH = SCREEN_W - spacing.lg * 2 - spacing.md * 2;
+const ZONE_WIDTH_PCT = 0.15;
+const ROUNDS = 10;
+
+export default function TimingTap({ currentDay, onComplete }) {
+ const difficulty = getDifficulty(currentDay);
+ const speed = 1200 - difficulty * 150; // ms for one sweep (faster at higher difficulty)
+ const zoneWidth = Math.max(ZONE_WIDTH_PCT - difficulty * 0.015, 0.08);
+
+ const [round, setRound] = useState(0);
+ const [results, setResults] = useState([]); // 'perfect' | 'good' | 'miss'
+ const [lastResult, setLastResult] = useState(null);
+ const [playing, setPlaying] = useState(true);
+
+ const barPos = useRef(new Animated.Value(0)).current;
+ const animRef = useRef(null);
+ const resultScale = useRef(new Animated.Value(0)).current;
+
+ // Zone position (random each round)
+ const [zoneStart] = useState(() => 0.5 - zoneWidth / 2); // centered for first
+
+ const [zonePositions] = useState(() =>
+ Array.from({ length: ROUNDS }, () => {
+ const min = 0.1;
+ const max = 0.9 - zoneWidth;
+ return min + Math.random() * (max - min);
+ })
+ );
+
+ const startSweep = useCallback(() => {
+ barPos.setValue(0);
+ animRef.current = Animated.loop(
+ Animated.sequence([
+ Animated.timing(barPos, { toValue: 1, duration: speed, useNativeDriver: false }),
+ Animated.timing(barPos, { toValue: 0, duration: speed, useNativeDriver: false }),
+ ])
+ );
+ animRef.current.start();
+ }, [speed]);
+
+ useEffect(() => {
+ if (playing && round < ROUNDS) {
+ startSweep();
+ }
+ return () => animRef.current?.stop();
+ }, [round, playing]);
+
+ useEffect(() => {
+ if (round >= ROUNDS && results.length >= ROUNDS) {
+ setPlaying(false);
+ animRef.current?.stop();
+ const perfects = results.filter((r) => r === 'perfect').length;
+ const goods = results.filter((r) => r === 'good').length;
+ const score = perfects * 2 + goods;
+ onComplete(score, { perfects, goods, misses: ROUNDS - perfects - goods });
+ }
+ }, [results]);
+
+ const handleTap = () => {
+ if (!playing || round >= ROUNDS) return;
+
+ // Get current position (0-1)
+ animRef.current?.stop();
+ let currentPos = 0;
+ barPos.addListener(({ value }) => { currentPos = value; });
+ // Read current value
+ const id = barPos.addListener(({ value }) => { currentPos = value; });
+ barPos.removeListener(id);
+
+ // Hacky but works: extract current value
+ barPos.stopAnimation((value) => {
+ const zone = zonePositions[round];
+ const zoneCenter = zone + zoneWidth / 2;
+ const distance = Math.abs(value - zoneCenter);
+
+ let result;
+ if (distance < zoneWidth * 0.3) result = 'perfect';
+ else if (distance < zoneWidth * 0.8) result = 'good';
+ else result = 'miss';
+
+ setLastResult(result);
+ setResults((prev) => [...prev, result]);
+
+ resultScale.setValue(0);
+ Animated.spring(resultScale, { toValue: 1, useNativeDriver: true, speed: 20, bounciness: 10 }).start();
+
+ setTimeout(() => {
+ setLastResult(null);
+ setRound((prev) => prev + 1);
+ }, 600);
+ });
+ };
+
+ const indicatorLeft = barPos.interpolate({
+ inputRange: [0, 1],
+ outputRange: [0, BAR_WIDTH - 4],
+ });
+
+ const currentZone = round < ROUNDS ? zonePositions[round] : 0.4;
+
+ const resultColor = lastResult === 'perfect' ? colors.accent : lastResult === 'good' ? colors.success : colors.error;
+
+ return (
+
+
+ {Math.min(round + 1, ROUNDS)} / {ROUNDS}
+
+ {results.map((r, i) => (
+
+ ))}
+
+
+
+
+ {/* Result flash */}
+ {lastResult && (
+
+
+ {lastResult.toUpperCase()}
+
+
+ )}
+
+ {/* Timing bar */}
+
+ {/* Zone */}
+
+
+ {/* Moving indicator */}
+
+
+ {/* Bar background lines */}
+
+
+
+ TAP when the line hits the zone
+
+
+ Hit the glowing zone with perfect timing
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: { flex: 1, alignItems: 'center' },
+ header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', width: '100%', paddingHorizontal: spacing.md, marginBottom: spacing.xl },
+ roundText: { color: colors.text, fontSize: fonts.sizes.lg, fontWeight: fonts.weights.bold },
+ resultsRow: { flexDirection: 'row', gap: 4 },
+ resultDot: { width: 8, height: 8, borderRadius: 4 },
+ gameArea: { flex: 1, width: '100%', alignItems: 'center', justifyContent: 'center' },
+ resultFlash: { position: 'absolute', top: '30%' },
+ resultText: { fontSize: fonts.sizes.xxl, fontWeight: fonts.weights.bold, letterSpacing: 4 },
+ barContainer: {
+ width: BAR_WIDTH, height: 60,
+ backgroundColor: colors.surface, borderRadius: borderRadius.md,
+ borderWidth: 1, borderColor: colors.border,
+ justifyContent: 'center', overflow: 'hidden',
+ },
+ barLine: {
+ position: 'absolute', width: '100%', height: 2,
+ backgroundColor: 'rgba(108, 99, 255, 0.15)', top: '50%',
+ },
+ zone: {
+ position: 'absolute', height: '100%',
+ backgroundColor: 'rgba(0, 229, 255, 0.12)',
+ borderLeftWidth: 2, borderRightWidth: 2, borderColor: 'rgba(0, 229, 255, 0.4)',
+ },
+ indicator: {
+ position: 'absolute', width: 4, height: '100%',
+ backgroundColor: colors.text, borderRadius: 2,
+ shadowColor: colors.text, shadowOffset: { width: 0, height: 0 }, shadowOpacity: 0.8, shadowRadius: 6,
+ },
+ tapHint: { color: colors.textMuted, fontSize: fonts.sizes.sm, marginTop: spacing.lg },
+ hint: { color: colors.textMuted, fontSize: fonts.sizes.sm, marginTop: 'auto', paddingBottom: spacing.md },
+});
diff --git a/src/config/keys.js b/src/config/keys.js
new file mode 100644
index 0000000..76a659a
--- /dev/null
+++ b/src/config/keys.js
@@ -0,0 +1,2 @@
+// API Keys — move to environment variables for production
+export const GEMINI_API_KEY = 'AIzaSyBmCdxsw9zeDI-KzyRAE1dtFflo9rhKcBc';
diff --git a/src/navigation/AppNavigator.js b/src/navigation/AppNavigator.js
new file mode 100644
index 0000000..5b68b01
--- /dev/null
+++ b/src/navigation/AppNavigator.js
@@ -0,0 +1,119 @@
+import React, { useEffect } from 'react';
+import { View, StyleSheet } from 'react-native';
+import { NavigationContainer } from '@react-navigation/native';
+import { createNativeStackNavigator } from '@react-navigation/native-stack';
+import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
+import { Text } from 'react-native';
+
+import SplashScreen from '../screens/SplashScreen';
+import OnboardingScreen from '../screens/OnboardingScreen';
+import LoginScreen from '../screens/LoginScreen';
+import RegisterScreen from '../screens/RegisterScreen';
+import CreateIdentityScreen from '../screens/CreateIdentityScreen';
+import IdentityStoryScreen from '../screens/IdentityStoryScreen';
+import HabitSelectionScreen from '../screens/HabitSelectionScreen';
+import HomeScreen from '../screens/HomeScreen';
+import DailyScreen from '../screens/DailyScreen';
+import StatsScreen from '../screens/StatsScreen';
+import MirrorScreen from '../screens/MirrorScreen';
+import MiniGameScreen from '../screens/MiniGameScreen';
+import CompletionScreen from '../screens/CompletionScreen';
+import ProfileScreen from '../screens/ProfileScreen';
+import DailyJournalScreen from '../screens/DailyJournalScreen';
+import JournalResultScreen from '../screens/JournalResultScreen';
+
+import useAuthStore from '../store/useAuthStore';
+import * as authService from '../services/authService';
+import { colors } from '../utils/theme';
+
+const Stack = createNativeStackNavigator();
+const Tab = createBottomTabNavigator();
+
+function TabIcon({ label, focused }) {
+ const icons = { Home: '◉', Daily: '◈', Stats: '◎', Profile: '◆' };
+ return (
+
+
+ {label}
+
+
+ {icons[label] || '●'}
+
+
+ );
+}
+
+const tabStyles = StyleSheet.create({
+ iconContainer: { alignItems: 'center', justifyContent: 'center', paddingTop: 6, width: 70 },
+ label: { fontSize: 9, fontWeight: '600', marginBottom: 3 },
+ icon: { fontSize: 18 },
+});
+
+function MainTabs() {
+ return (
+
+ }} />
+ }} />
+ }} />
+ }} />
+
+ );
+}
+
+export default function AppNavigator() {
+ const setSession = useAuthStore((s) => s.setSession);
+
+ useEffect(() => {
+ try {
+ const { data: { subscription } } = authService.onAuthStateChange((_event, session) => {
+ setSession(session);
+ });
+ return () => subscription.unsubscribe();
+ } catch (_) {
+ // Network unavailable — auth listener will be inactive
+ }
+ }, []);
+
+ return (
+
+
+ {/* Splash is always the entry point — it decides where to go */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/screens/CompletionScreen.js b/src/screens/CompletionScreen.js
new file mode 100644
index 0000000..8bc1f6b
--- /dev/null
+++ b/src/screens/CompletionScreen.js
@@ -0,0 +1,324 @@
+import React, { useState, useEffect, useRef } from 'react';
+import {
+ View, Text, StyleSheet, Share,
+ Animated, ActivityIndicator,
+} from 'react-native';
+import { showAlert } from '../components/NovaAlert';
+import ScreenWrapper from '../components/ScreenWrapper';
+import Planet from '../components/Planet';
+import Button from '../components/Button';
+import useIdentityStore from '../store/useIdentityStore';
+import useHabitStore from '../store/useHabitStore';
+import useAuthStore from '../store/useAuthStore';
+import { generateTransformationStory } from '../services/storyService';
+import { colors, fonts, spacing, borderRadius } from '../utils/theme';
+
+export default function CompletionScreen({ navigation }) {
+ const identity = useIdentityStore((s) => s.identity);
+ const loadStats = useIdentityStore((s) => s.loadStats);
+ const loadDailyLogs = useIdentityStore((s) => s.loadDailyLogs);
+ const habits = useHabitStore((s) => s.habits);
+ const logout = useAuthStore((s) => s.logout);
+ const resetIdentity = useIdentityStore((s) => s.reset);
+ const resetHabits = useHabitStore((s) => s.reset);
+
+ const [stats, setStats] = useState(null);
+ const [logs, setLogs] = useState([]);
+ const [story, setStory] = useState(null);
+ const [loadingStory, setLoadingStory] = useState(true);
+
+ const fadeAnim = useRef(new Animated.Value(0)).current;
+ const storyFade = useRef(new Animated.Value(0)).current;
+
+ useEffect(() => {
+ Animated.timing(fadeAnim, { toValue: 1, duration: 800, delay: 300, useNativeDriver: true }).start();
+
+ const load = async () => {
+ const [s, l] = await Promise.all([loadStats(), loadDailyLogs()]);
+ setStats(s);
+ setLogs(l);
+
+ // Generate transformation story
+ try {
+ const result = await generateTransformationStory(identity, habits, l, s);
+ setStory(result);
+ } catch (_) {
+ // fallback is handled inside the service
+ }
+ setLoadingStory(false);
+ Animated.timing(storyFade, { toValue: 1, duration: 600, useNativeDriver: true }).start();
+ };
+ load();
+ }, []);
+
+ const yesCount = logs.filter((l) => l.identity_check === 'yes').length;
+ const totalScore =
+ (stats?.discipline_score || 0) + (stats?.focus_score || 0) + (stats?.consistency_score || 0);
+
+ const handleShare = async () => {
+ const storyText = story
+ ? `${story.title}\n\n${story.paragraphs.join('\n\n')}\n\n${story.closing_line}`
+ : '';
+
+ try {
+ await Share.share({
+ message: `I completed Nova40!\n\n${storyText}\n\n---\n"${identity?.title}"\n${yesCount}/40 days aligned | Score: ${totalScore}\n\n#Nova40 #Transformation`,
+ });
+ } catch (_) { /* cancelled */ }
+ };
+
+ const handleNewJourney = () => {
+ showAlert('New Journey', 'Ready to transform again?', [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Begin',
+ onPress: async () => {
+ resetIdentity();
+ resetHabits();
+ await logout();
+ navigation.reset({ index: 0, routes: [{ name: 'Login' }] });
+ },
+ },
+ ]);
+ };
+
+ return (
+
+
+ {/* Header */}
+
+
+ TRANSFORMATION COMPLETE
+ You made it. You are Nova.
+
+
+ {/* Transformation Story */}
+ {loadingStory ? (
+
+
+ Writing your story...
+
+ ) : story ? (
+
+
+ {story.source === 'ai' ? '✦ AI-Written Story' : '✦ Your Story'}
+
+ {story.title}
+ {story.paragraphs.map((p, i) => (
+ {p}
+ ))}
+ {story.closing_line}
+
+ ) : null}
+
+ {/* Identity Card */}
+
+ YOUR IDENTITY
+ {identity?.title}
+
+
+ {/* Stats Card */}
+
+ FINAL STATS
+
+
+
+ {stats?.discipline_score || 0}
+
+ Discipline
+
+
+
+ {stats?.focus_score || 0}
+
+ Focus
+
+
+
+ {stats?.consistency_score || 0}
+
+ Consistency
+
+
+
+
+
+
+
+
+
+
+ {/* Actions */}
+
+
+
+
+
+
+ );
+}
+
+function StatRow({ label, value, color }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ scroll: { flex: 1 },
+ scrollContent: {
+ padding: spacing.lg,
+ paddingBottom: spacing.xxl * 2,
+ alignItems: 'center',
+ },
+
+ // Header
+ header: {
+ alignItems: 'center',
+ marginBottom: spacing.xl,
+ paddingTop: spacing.lg,
+ },
+ congrats: {
+ color: colors.accent,
+ fontSize: fonts.sizes.xs,
+ fontWeight: fonts.weights.bold,
+ letterSpacing: 3,
+ marginTop: spacing.lg,
+ marginBottom: spacing.sm,
+ },
+ headerTitle: {
+ color: colors.text,
+ fontSize: fonts.sizes.xl,
+ fontWeight: fonts.weights.bold,
+ textAlign: 'center',
+ },
+
+ // Story
+ storyLoading: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingVertical: spacing.xl,
+ gap: spacing.sm,
+ },
+ storyLoadingText: {
+ color: colors.textSecondary,
+ fontSize: fonts.sizes.sm,
+ },
+ storyCard: {
+ backgroundColor: colors.surface,
+ borderRadius: borderRadius.lg,
+ padding: spacing.xl,
+ width: '100%',
+ marginBottom: spacing.lg,
+ borderWidth: 1,
+ borderColor: 'rgba(108, 99, 255, 0.3)',
+ },
+ storyBadge: {
+ color: colors.primaryLight,
+ fontSize: fonts.sizes.xs,
+ fontWeight: fonts.weights.medium,
+ marginBottom: spacing.lg,
+ },
+ storyTitle: {
+ color: colors.text,
+ fontSize: fonts.sizes.xl,
+ fontWeight: fonts.weights.bold,
+ marginBottom: spacing.lg,
+ lineHeight: 30,
+ },
+ storyParagraph: {
+ color: colors.textSecondary,
+ fontSize: fonts.sizes.md,
+ lineHeight: 26,
+ marginBottom: spacing.md,
+ },
+ closingLine: {
+ color: colors.accent,
+ fontSize: fonts.sizes.md,
+ fontWeight: fonts.weights.semibold,
+ fontStyle: 'italic',
+ marginTop: spacing.md,
+ lineHeight: 24,
+ },
+
+ // Cards
+ card: {
+ backgroundColor: colors.surface,
+ borderRadius: borderRadius.lg,
+ padding: spacing.lg,
+ width: '100%',
+ marginBottom: spacing.md,
+ borderWidth: 1,
+ borderColor: colors.border,
+ },
+ cardLabel: {
+ color: colors.primary,
+ fontSize: fonts.sizes.xs,
+ fontWeight: fonts.weights.bold,
+ letterSpacing: 2,
+ marginBottom: spacing.sm,
+ },
+ cardTitle: {
+ color: colors.text,
+ fontSize: fonts.sizes.lg,
+ fontWeight: fonts.weights.semibold,
+ },
+
+ // Stats
+ statGrid: {
+ flexDirection: 'row',
+ justifyContent: 'space-around',
+ marginBottom: spacing.md,
+ },
+ statItem: { alignItems: 'center' },
+ statValue: {
+ fontSize: fonts.sizes.xxl,
+ fontWeight: fonts.weights.bold,
+ },
+ statLabel: {
+ color: colors.textSecondary,
+ fontSize: fonts.sizes.xs,
+ marginTop: spacing.xs,
+ },
+ divider: {
+ height: 1,
+ backgroundColor: colors.border,
+ marginVertical: spacing.md,
+ },
+ summaryRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ paddingVertical: spacing.sm,
+ },
+ summaryLabel: {
+ color: colors.textSecondary,
+ fontSize: fonts.sizes.sm,
+ },
+ summaryValue: {
+ color: colors.text,
+ fontSize: fonts.sizes.sm,
+ fontWeight: fonts.weights.semibold,
+ },
+
+ // Actions
+ actions: {
+ width: '100%',
+ marginTop: spacing.lg,
+ },
+ actionBtn: {
+ marginBottom: spacing.sm,
+ },
+});
diff --git a/src/screens/CreateIdentityScreen.js b/src/screens/CreateIdentityScreen.js
new file mode 100644
index 0000000..7d782b3
--- /dev/null
+++ b/src/screens/CreateIdentityScreen.js
@@ -0,0 +1,330 @@
+import React, { useState, useEffect, useRef, useMemo } from 'react';
+import {
+ View, Text, StyleSheet, ScrollView,
+ KeyboardAvoidingView, Platform, Animated, Pressable,
+} from 'react-native';
+import { showAlert } from '../components/NovaAlert';
+import ScreenWrapper from '../components/ScreenWrapper';
+import Input from '../components/Input';
+import HabitInput from '../components/HabitInput';
+import Button from '../components/Button';
+import useIdentityStore from '../store/useIdentityStore';
+import useAppStore from '../store/useAppStore';
+import { colors, fonts, spacing, borderRadius } from '../utils/theme';
+import { getSuggestionsForIdentity } from '../utils/helpers';
+
+const MAX_HABITS = 5;
+
+export default function CreateIdentityScreen({ navigation }) {
+ const initialIdentity = useAppStore((s) => s.initialIdentity);
+ const createIdentity = useIdentityStore((s) => s.createIdentity);
+
+ const [title, setTitle] = useState(initialIdentity || '');
+ const [habits, setHabits] = useState([{ id: 1, value: '' }]);
+ const [loading, setLoading] = useState(false);
+ const [lastAddedId, setLastAddedId] = useState(null);
+ const nextId = useRef(2);
+
+ // Entrance animation
+ const fadeAnim = useRef(new Animated.Value(0)).current;
+ const slideAnim = useRef(new Animated.Value(20)).current;
+
+ useEffect(() => {
+ Animated.parallel([
+ Animated.timing(fadeAnim, { toValue: 1, duration: 500, useNativeDriver: true }),
+ Animated.timing(slideAnim, { toValue: 0, duration: 500, useNativeDriver: true }),
+ ]).start();
+ }, []);
+
+ // Smart suggestions based on identity input
+ const suggestions = useMemo(() => {
+ const existing = habits.map((h) => h.value.toLowerCase().trim()).filter(Boolean);
+ return getSuggestionsForIdentity(title).filter(
+ (s) => !existing.some((e) => e === s.toLowerCase())
+ );
+ }, [title, habits]);
+
+ // --- Habit CRUD ---
+ const addHabit = (prefill = '') => {
+ if (habits.length >= MAX_HABITS) return;
+ const id = nextId.current++;
+ setHabits((prev) => [...prev, { id, value: prefill }]);
+ setLastAddedId(id);
+ };
+
+ const updateHabit = (id, value) => {
+ setHabits((prev) => prev.map((h) => (h.id === id ? { ...h, value } : h)));
+ };
+
+ const deleteHabit = (id) => {
+ if (habits.length <= 1) return; // keep at least 1
+ setHabits((prev) => prev.filter((h) => h.id !== id));
+ };
+
+ const addSuggestion = (text) => {
+ // Fill first empty habit, or add new one
+ const emptyHabit = habits.find((h) => !h.value.trim());
+ if (emptyHabit) {
+ updateHabit(emptyHabit.id, text);
+ } else {
+ addHabit(text);
+ }
+ };
+
+ // --- Submit ---
+ const handleCreate = async () => {
+ const trimmedTitle = title.trim();
+ if (!trimmedTitle) {
+ showAlert('Missing Identity', 'Please enter who you want to become.');
+ return;
+ }
+
+ const validHabits = habits
+ .map((h) => h.value.trim())
+ .filter(Boolean);
+
+ if (validHabits.length === 0) {
+ showAlert('No Habits', 'Please add at least 1 habit.');
+ return;
+ }
+
+ // Check for duplicates
+ const unique = [...new Set(validHabits.map((h) => h.toLowerCase()))];
+ if (unique.length < validHabits.length) {
+ showAlert('Duplicates', 'Please remove duplicate habits.');
+ return;
+ }
+
+ setLoading(true);
+ try {
+ const customHabits = validHabits.map((h) => ({ title: h }));
+ await createIdentity(trimmedTitle, '', customHabits, '');
+ navigation.reset({ index: 0, routes: [{ name: 'MainTabs' }] });
+ } catch (error) {
+ showAlert('Error', error.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const validCount = habits.filter((h) => h.value.trim()).length;
+
+ return (
+
+
+
+
+ {/* Header */}
+ I want to become...
+ Define your new identity and the habits that support it
+
+ {/* Identity Input */}
+
+
+ {/* Habit Section */}
+
+
+ Your Habits
+ {validCount} / {MAX_HABITS}
+
+
+ {/* Habit Inputs */}
+ {habits.map((habit, idx) => (
+ updateHabit(habit.id, text)}
+ onDelete={() => deleteHabit(habit.id)}
+ autoFocus={habit.id === lastAddedId}
+ />
+ ))}
+
+ {/* Add Habit Button */}
+ {habits.length < MAX_HABITS && (
+ [styles.addBtn, pressed && styles.addBtnPressed]}
+ onPress={() => addHabit()}
+ >
+ +
+ Add Habit
+
+ )}
+
+
+ {/* Smart Suggestions */}
+ {suggestions.length > 0 && (
+
+ Suggested habits
+
+ {suggestions.map((s) => (
+ [styles.suggestionChip, pressed && styles.suggestionChipPressed]}
+ onPress={() => addSuggestion(s)}
+ disabled={habits.length >= MAX_HABITS && !habits.some((h) => !h.value.trim())}
+ >
+ +
+ {s}
+
+ ))}
+
+
+ )}
+
+ {/* Submit */}
+
+
+
+ {validCount === 0
+ ? 'Add at least 1 habit to begin'
+ : `${validCount} habit${validCount > 1 ? 's' : ''} ready. Let's go.`}
+
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ flex: { flex: 1 },
+ scroll: { flex: 1 },
+ scrollContent: {
+ paddingHorizontal: spacing.lg,
+ paddingTop: spacing.xxl,
+ paddingBottom: spacing.xxl,
+ },
+
+ // Header
+ title: {
+ color: colors.text,
+ fontSize: fonts.sizes.xxl,
+ fontWeight: fonts.weights.bold,
+ marginBottom: spacing.xs,
+ },
+ subtitle: {
+ color: colors.textSecondary,
+ fontSize: fonts.sizes.md,
+ lineHeight: 22,
+ marginBottom: spacing.xl,
+ },
+
+ // Habits
+ habitSection: {
+ marginTop: spacing.md,
+ },
+ habitHeader: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginBottom: spacing.md,
+ },
+ sectionTitle: {
+ color: colors.text,
+ fontSize: fonts.sizes.lg,
+ fontWeight: fonts.weights.semibold,
+ },
+ habitCounter: {
+ color: colors.textMuted,
+ fontSize: fonts.sizes.sm,
+ fontWeight: fonts.weights.medium,
+ },
+
+ // Add button
+ addBtn: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingVertical: spacing.md,
+ borderRadius: borderRadius.md,
+ borderWidth: 1.5,
+ borderColor: colors.border,
+ borderStyle: 'dashed',
+ marginTop: spacing.xs,
+ },
+ addBtnPressed: { opacity: 0.6 },
+ addBtnIcon: {
+ color: colors.primary,
+ fontSize: fonts.sizes.lg,
+ fontWeight: fonts.weights.bold,
+ marginRight: spacing.sm,
+ },
+ addBtnText: {
+ color: colors.primary,
+ fontSize: fonts.sizes.md,
+ fontWeight: fonts.weights.medium,
+ },
+
+ // Suggestions
+ suggestionsSection: {
+ marginTop: spacing.xl,
+ },
+ suggestionsTitle: {
+ color: colors.textSecondary,
+ fontSize: fonts.sizes.sm,
+ fontWeight: fonts.weights.medium,
+ marginBottom: spacing.sm,
+ },
+ suggestionsGrid: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ gap: spacing.sm,
+ },
+ suggestionChip: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: colors.surface,
+ borderRadius: borderRadius.full,
+ paddingVertical: spacing.sm,
+ paddingHorizontal: spacing.md,
+ borderWidth: 1,
+ borderColor: colors.border,
+ },
+ suggestionChipPressed: { opacity: 0.6, borderColor: colors.primary },
+ suggestionPlus: {
+ color: colors.accent,
+ fontSize: fonts.sizes.sm,
+ fontWeight: fonts.weights.bold,
+ marginRight: spacing.xs,
+ },
+ suggestionText: {
+ color: colors.text,
+ fontSize: fonts.sizes.sm,
+ },
+
+ // Submit
+ submitArea: {
+ marginTop: spacing.xxl,
+ alignItems: 'center',
+ },
+ submitBtn: {
+ width: '100%',
+ },
+ submitHint: {
+ color: colors.textMuted,
+ fontSize: fonts.sizes.sm,
+ marginTop: spacing.md,
+ textAlign: 'center',
+ },
+});
diff --git a/src/screens/DailyJournalScreen.js b/src/screens/DailyJournalScreen.js
new file mode 100644
index 0000000..d6d1e62
--- /dev/null
+++ b/src/screens/DailyJournalScreen.js
@@ -0,0 +1,298 @@
+import React, { useState, useRef, useEffect } from 'react';
+import {
+ View, Text, StyleSheet, ScrollView, Pressable,
+ Animated, KeyboardAvoidingView, Platform,
+} from 'react-native';
+import { showAlert } from '../components/NovaAlert';
+import ScreenWrapper from '../components/ScreenWrapper';
+import Input from '../components/Input';
+import Button from '../components/Button';
+import useIdentityStore from '../store/useIdentityStore';
+import useHabitStore from '../store/useHabitStore';
+import { generateDailyReflection, saveDailyJournal } from '../services/journalService';
+import { colors, fonts, spacing, borderRadius } from '../utils/theme';
+
+const MOODS = [
+ { key: 'great', emoji: '😄', label: 'Great' },
+ { key: 'good', emoji: '🙂', label: 'Good' },
+ { key: 'okay', emoji: '😐', label: 'Okay' },
+ { key: 'bad', emoji: '😔', label: 'Bad' },
+ { key: 'terrible', emoji: '😞', label: 'Terrible' },
+];
+
+const IDENTITY_OPTIONS = [
+ { key: 'yes', label: 'Yes', color: colors.success },
+ { key: 'almost', label: 'Almost', color: colors.warning },
+ { key: 'no', label: 'No', color: colors.error },
+];
+
+export default function DailyJournalScreen({ navigation }) {
+ const identity = useIdentityStore((s) => s.identity);
+ const currentDay = useIdentityStore((s) => s.currentDay);
+ const habits = useHabitStore((s) => s.habits);
+ const habitLogs = useHabitStore((s) => s.habitLogs);
+
+ const [mood, setMood] = useState('');
+ const [win, setWin] = useState('');
+ const [struggle, setStruggle] = useState('');
+ const [highlight, setHighlight] = useState('');
+ const [note, setNote] = useState('');
+ const [identityCheck, setIdentityCheck] = useState('');
+ const [saving, setSaving] = useState(false);
+
+ const fadeAnim = useRef(new Animated.Value(0)).current;
+
+ useEffect(() => {
+ Animated.timing(fadeAnim, { toValue: 1, duration: 500, useNativeDriver: true }).start();
+ }, []);
+
+ const habitsCompleted = Object.values(habitLogs).filter(Boolean).length;
+
+ const handleSave = async () => {
+ if (!mood) {
+ showAlert('Mood', 'How are you feeling today?');
+ return;
+ }
+ if (!identityCheck) {
+ showAlert('Identity Check', 'Did you embody your identity today?');
+ return;
+ }
+
+ setSaving(true);
+ try {
+ // Generate AI reflection
+ const ai = await generateDailyReflection({ mood, win, struggle, highlight, note });
+
+ // Save everything
+ await saveDailyJournal(identity.id, currentDay, {
+ identityCheck,
+ habitsCompleted,
+ mood,
+ win,
+ struggle,
+ highlight,
+ note,
+ aiTitle: ai.title,
+ aiSummary: ai.summary,
+ aiQuote: ai.quote,
+ });
+
+ // Navigate to result
+ navigation.navigate('JournalResult', {
+ aiTitle: ai.title,
+ aiSummary: ai.summary,
+ aiQuote: ai.quote,
+ source: ai.source,
+ dayNumber: currentDay,
+ date: new Date().toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }),
+ mood,
+ identityTitle: identity?.title,
+ });
+ } catch (e) {
+ console.warn('Journal save error:', e);
+ showAlert('Error', e?.message || 'Failed to save. Try again.');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+
+
+ {/* Header */}
+ Daily Journal
+ Day {currentDay} — How was your day?
+
+ {/* Mood */}
+
+ How are you feeling?
+
+ {MOODS.map((m) => (
+ setMood(m.key)}
+ >
+ {m.emoji}
+
+ {m.label}
+
+
+ ))}
+
+
+
+ {/* Identity Check */}
+
+ Did you embody "{identity?.title}"?
+
+ {IDENTITY_OPTIONS.map((opt) => (
+ setIdentityCheck(opt.key)}
+ >
+
+ {opt.label}
+
+
+ ))}
+
+
+
+ {/* Habits summary */}
+
+
+ {habitsCompleted}/{habits.length} habits completed today
+
+
+
+ {/* Journal inputs */}
+
+
+
+
+
+
+
+ {/* Save */}
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ flex: { flex: 1 },
+ scrollContent: {
+ paddingHorizontal: spacing.lg,
+ paddingTop: spacing.xl,
+ paddingBottom: spacing.xxl * 2,
+ },
+ title: {
+ color: colors.text,
+ fontSize: fonts.sizes.xxl,
+ fontWeight: fonts.weights.bold,
+ marginBottom: spacing.xs,
+ },
+ subtitle: {
+ color: colors.textSecondary,
+ fontSize: fonts.sizes.md,
+ marginBottom: spacing.xl,
+ },
+ section: {
+ marginBottom: spacing.lg,
+ },
+ sectionTitle: {
+ color: colors.text,
+ fontSize: fonts.sizes.md,
+ fontWeight: fonts.weights.semibold,
+ marginBottom: spacing.md,
+ },
+
+ // Mood
+ moodRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ },
+ moodBtn: {
+ alignItems: 'center',
+ paddingVertical: spacing.sm,
+ paddingHorizontal: spacing.sm,
+ borderRadius: borderRadius.md,
+ borderWidth: 1,
+ borderColor: colors.border,
+ backgroundColor: colors.surface,
+ flex: 1,
+ marginHorizontal: 3,
+ },
+ moodBtnSelected: {
+ borderColor: colors.primary,
+ backgroundColor: 'rgba(108, 99, 255, 0.1)',
+ },
+ moodEmoji: { fontSize: 22, marginBottom: 4 },
+ moodLabel: { color: colors.textMuted, fontSize: 10, fontWeight: fonts.weights.medium },
+ moodLabelSelected: { color: colors.primary },
+
+ // Identity check
+ identityRow: {
+ flexDirection: 'row',
+ gap: spacing.sm,
+ },
+ identityBtn: {
+ flex: 1,
+ alignItems: 'center',
+ paddingVertical: spacing.md,
+ borderRadius: borderRadius.md,
+ borderWidth: 1,
+ borderColor: colors.border,
+ backgroundColor: colors.surface,
+ },
+ identityLabel: {
+ color: colors.textSecondary,
+ fontSize: fonts.sizes.sm,
+ fontWeight: fonts.weights.medium,
+ },
+
+ // Habits summary
+ habitSummary: {
+ backgroundColor: colors.surface,
+ borderRadius: borderRadius.md,
+ padding: spacing.md,
+ borderWidth: 1,
+ borderColor: colors.border,
+ marginBottom: spacing.lg,
+ alignItems: 'center',
+ },
+ habitSummaryText: {
+ color: colors.textSecondary,
+ fontSize: fonts.sizes.sm,
+ },
+
+ saveBtn: {
+ marginTop: spacing.md,
+ },
+});
diff --git a/src/screens/DailyScreen.js b/src/screens/DailyScreen.js
new file mode 100644
index 0000000..b635f1f
--- /dev/null
+++ b/src/screens/DailyScreen.js
@@ -0,0 +1,466 @@
+import React, { useState, useRef, useCallback } from 'react';
+import {
+ View, Text, StyleSheet, Pressable,
+ TouchableOpacity, Animated, KeyboardAvoidingView, Platform,
+} from 'react-native';
+import { useFocusEffect } from '@react-navigation/native';
+let ViewShot, Sharing, MediaLibrary, Print;
+try {
+ ViewShot = require('react-native-view-shot').default;
+ Sharing = require('expo-sharing');
+ MediaLibrary = require('expo-media-library');
+ Print = require('expo-print');
+} catch (_) {
+ // Modules unavailable — share features disabled
+}
+import { showAlert } from '../components/NovaAlert';
+import ScreenWrapper from '../components/ScreenWrapper';
+import Button from '../components/Button';
+import Input from '../components/Input';
+import useIdentityStore from '../store/useIdentityStore';
+import useHabitStore from '../store/useHabitStore';
+import { generateDailyReflection, saveDailyJournal, getJournalEntry } from '../services/journalService';
+import { colors, fonts, spacing, borderRadius } from '../utils/theme';
+import { getDayNarrative } from '../utils/helpers';
+
+const MOODS = [
+ { key: 'great', emoji: '😄', label: 'Great' },
+ { key: 'good', emoji: '🙂', label: 'Good' },
+ { key: 'okay', emoji: '😐', label: 'Okay' },
+ { key: 'bad', emoji: '😔', label: 'Bad' },
+ { key: 'terrible', emoji: '😞', label: 'Awful' },
+];
+
+const MOOD_EMOJIS = { great: '😄', good: '🙂', okay: '😐', bad: '😔', terrible: '😞' };
+
+function HabitItem({ habit, completed, onToggle, disabled }) {
+ return (
+
+
+ {completed && ✓}
+
+
+ {habit.title}
+ {habit.description ? {habit.description} : null}
+
+
+ );
+}
+
+export default function DailyScreen() {
+ const identity = useIdentityStore((s) => s.identity);
+ const currentDay = useIdentityStore((s) => s.currentDay);
+ const habits = useHabitStore((s) => s.habits);
+ const habitLogs = useHabitStore((s) => s.habitLogs);
+ const toggleHabit = useHabitStore((s) => s.toggleHabit);
+ const loadTodayLogs = useHabitStore((s) => s.loadTodayLogs);
+
+ const [identityCheck, setIdentityCheck] = useState('');
+ const [mood, setMood] = useState('');
+ const [win, setWin] = useState('');
+ const [struggle, setStruggle] = useState('');
+ const [highlight, setHighlight] = useState('');
+ const [note, setNote] = useState('');
+ const [saving, setSaving] = useState(false);
+
+ // Already completed today
+ const [completed, setCompleted] = useState(false);
+ const [savedEntry, setSavedEntry] = useState(null);
+
+ const fadeAnim = useRef(new Animated.Value(0)).current;
+ const viewShotRef = useRef(null);
+
+ useFocusEffect(
+ useCallback(() => {
+ const init = async () => {
+ loadTodayLogs();
+
+ // Check if today is already completed
+ if (identity?.id) {
+ const existing = await getJournalEntry(identity.id);
+ if (existing && existing.ai_title) {
+ setCompleted(true);
+ setSavedEntry(existing);
+ // Fill form with saved data
+ setIdentityCheck(existing.identity_check || '');
+ setMood(existing.mood || '');
+ setWin(existing.win || '');
+ setStruggle(existing.struggle || '');
+ setHighlight(existing.highlight || '');
+ setNote(existing.note || '');
+ } else {
+ setCompleted(false);
+ setSavedEntry(null);
+ setIdentityCheck('');
+ setMood('');
+ setWin('');
+ setStruggle('');
+ setHighlight('');
+ setNote('');
+ }
+ }
+
+ Animated.timing(fadeAnim, { toValue: 1, duration: 600, useNativeDriver: true }).start();
+ };
+ init();
+ }, [identity?.id])
+ );
+
+ const habitsCompleted = Object.values(habitLogs).filter(Boolean).length;
+
+ const handleCompleteDay = async () => {
+ if (!identityCheck) {
+ showAlert('Identity Check', 'Did you embody your identity today?');
+ return;
+ }
+ if (!mood) {
+ showAlert('Mood', 'How are you feeling today?');
+ return;
+ }
+
+ setSaving(true);
+ try {
+ const ai = await generateDailyReflection({ mood, win, struggle, highlight, note });
+
+ const saved = await saveDailyJournal(identity.id, currentDay, {
+ identityCheck,
+ habitsCompleted,
+ mood, win, struggle, highlight, note,
+ aiTitle: ai.title,
+ aiSummary: ai.summary,
+ aiQuote: ai.quote,
+ });
+
+ setCompleted(true);
+ setSavedEntry({ ...saved, ai_title: ai.title, ai_summary: ai.summary, ai_quote: ai.quote });
+ } catch (error) {
+ console.warn('DailyScreen save error:', error);
+ showAlert('Error', error?.message || 'Failed to save. Try again.');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ // --- Share/Save/PDF actions ---
+ const handleShare = async () => {
+ try {
+ const uri = await viewShotRef.current.capture();
+ await Sharing.shareAsync(uri, { mimeType: 'image/png' });
+ } catch (_) { showAlert('Error', 'Could not share.'); }
+ };
+
+ const handleSaveImage = async () => {
+ try {
+ const { status } = await MediaLibrary.requestPermissionsAsync();
+ if (status !== 'granted') { showAlert('Permission', 'Allow access to save images.'); return; }
+ const uri = await viewShotRef.current.capture();
+ await MediaLibrary.saveToLibraryAsync(uri);
+ showAlert('Saved', 'Journal saved to your gallery.');
+ } catch (_) { showAlert('Error', 'Could not save image.'); }
+ };
+
+ const handleExportPDF = async () => {
+ try {
+ const e = savedEntry;
+ const dateStr = new Date().toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
+ const html = `
+ NOVA40 — DAILY JOURNAL
+ ${e?.ai_title || ''}
+ ${e?.ai_summary || ''}
+
+ "${e?.ai_quote || ''}"
+
+ Day ${currentDay} — ${dateStr}
+ ${identity?.title || ''}
+ `;
+ const { uri } = await Print.printToFileAsync({ html });
+ await Sharing.shareAsync(uri, { mimeType: 'application/pdf' });
+ } catch (_) { showAlert('Error', 'Could not generate PDF.'); }
+ };
+
+ const narrative = getDayNarrative(currentDay);
+
+ if (!identity) {
+ return (
+
+
+ Loading...
+
+
+ );
+ }
+
+ return (
+
+
+
+ DAY {currentDay} OF 40
+ {narrative}
+
+ {/* === COMPLETED STATE: show journal card + actions === */}
+ {completed && savedEntry?.ai_title ? (
+ <>
+ {/* Completion badge */}
+
+ ✓ Day {currentDay} Complete
+
+
+ {/* Shareable journal card */}
+ {ViewShot ? (
+
+
+ NOVA40
+ {MOOD_EMOJIS[savedEntry.mood] || '✦'}
+ {savedEntry.ai_title}
+ {savedEntry.ai_summary}
+
+ "{savedEntry.ai_quote}"
+
+ Day {currentDay}
+
+ {new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
+
+
+ {identity?.title && {identity.title}}
+
+
+ ) : (
+
+ NOVA40
+ {MOOD_EMOJIS[savedEntry.mood] || '✦'}
+ {savedEntry.ai_title}
+ {savedEntry.ai_summary}
+
+ "{savedEntry.ai_quote}"
+
+ Day {currentDay}
+
+ {new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
+
+
+ {identity?.title && {identity.title}}
+
+ )}
+
+ {/* Share actions */}
+
+
+
+
+
+
+
+
+ {/* Show what was logged */}
+
+ What you logged
+ {savedEntry.win ? : null}
+ {savedEntry.struggle ? : null}
+ {savedEntry.highlight ? : null}
+ {savedEntry.note ? : null}
+
+ >
+ ) : (
+ <>
+ {/* === FORM STATE: habits + journal inputs === */}
+
+ {/* Habits */}
+
+ Today's Habits
+ {habits.map((habit) => (
+ toggleHabit(habit.id)}
+ disabled={false}
+ />
+ ))}
+ {habitsCompleted}/{habits.length} completed
+
+
+ {/* Identity Check */}
+
+ Identity Check
+ Did you embody "{identity?.title}" today?
+
+ {[
+ { key: 'yes', label: 'Yes', color: colors.success },
+ { key: 'almost', label: 'Almost', color: colors.warning },
+ { key: 'no', label: 'No', color: colors.error },
+ ].map((opt) => (
+ setIdentityCheck(opt.key)}
+ >
+ {opt.label}
+
+ ))}
+
+
+
+ {/* Mood */}
+
+ How are you feeling?
+
+ {MOODS.map((m) => (
+ setMood(m.key)}>
+ {m.emoji}
+ {m.label}
+
+ ))}
+
+
+
+ {/* Journal */}
+
+ Daily Journal
+
+
+
+
+
+
+ {/* Complete Day */}
+
+ >
+ )}
+
+
+
+ );
+}
+
+function LoggedRow({ label, value }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ flex: { flex: 1 },
+ loadingContainer: { flex: 1, alignItems: 'center', justifyContent: 'center' },
+ loadingText: { color: colors.textSecondary, fontSize: fonts.sizes.md },
+ scrollContent: { padding: spacing.lg, paddingBottom: spacing.xxl * 2 },
+ dayLabel: { color: colors.accent, fontSize: fonts.sizes.xs, fontWeight: fonts.weights.bold, letterSpacing: 3, marginBottom: spacing.sm },
+ narrative: { color: colors.text, fontSize: fonts.sizes.xl, fontWeight: fonts.weights.semibold, lineHeight: 32, marginBottom: spacing.xl },
+
+ section: { marginBottom: spacing.xl },
+ sectionTitle: { color: colors.text, fontSize: fonts.sizes.lg, fontWeight: fonts.weights.semibold, marginBottom: spacing.sm },
+ sectionSubtitle: { color: colors.textSecondary, fontSize: fonts.sizes.sm, marginBottom: spacing.md },
+
+ // Habits
+ habitItem: { flexDirection: 'row', alignItems: 'center', backgroundColor: colors.surface, borderRadius: borderRadius.md, padding: spacing.md, marginBottom: spacing.sm, borderWidth: 1, borderColor: colors.border },
+ habitCompleted: { borderColor: colors.success, backgroundColor: 'rgba(0, 230, 118, 0.05)' },
+ checkbox: { width: 28, height: 28, borderRadius: 14, borderWidth: 2, borderColor: colors.textMuted, alignItems: 'center', justifyContent: 'center', marginRight: spacing.md },
+ checkboxChecked: { borderColor: colors.success, backgroundColor: colors.success },
+ checkmark: { color: colors.background, fontSize: 14, fontWeight: fonts.weights.bold },
+ habitContent: { flex: 1 },
+ habitTitle: { color: colors.text, fontSize: fonts.sizes.md, fontWeight: fonts.weights.medium },
+ habitTitleDone: { textDecorationLine: 'line-through', opacity: 0.6 },
+ habitDesc: { color: colors.textSecondary, fontSize: fonts.sizes.sm, marginTop: 2 },
+ habitCount: { color: colors.textMuted, fontSize: fonts.sizes.sm, textAlign: 'center', marginTop: spacing.sm },
+
+ // Identity check
+ identityRow: { flexDirection: 'row', gap: spacing.sm },
+ identityBtn: { flex: 1, alignItems: 'center', paddingVertical: spacing.md, borderRadius: borderRadius.md, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.surface },
+ identityLabel: { color: colors.textSecondary, fontSize: fonts.sizes.sm, fontWeight: fonts.weights.medium },
+
+ // Mood
+ moodRow: { flexDirection: 'row', justifyContent: 'space-between' },
+ moodBtn: { alignItems: 'center', paddingVertical: spacing.sm, paddingHorizontal: spacing.sm, borderRadius: borderRadius.md, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.surface, flex: 1, marginHorizontal: 3 },
+ moodBtnSelected: { borderColor: colors.primary, backgroundColor: 'rgba(108, 99, 255, 0.1)' },
+ moodEmoji: { fontSize: 20, marginBottom: 3 },
+ moodLabel: { color: colors.textMuted, fontSize: 9, fontWeight: fonts.weights.medium },
+ moodLabelSelected: { color: colors.primary },
+
+ completeBtn: { marginTop: spacing.md },
+
+ // === Completed state ===
+ completedBadge: {
+ alignSelf: 'center',
+ backgroundColor: 'rgba(0, 230, 118, 0.1)',
+ borderRadius: borderRadius.full,
+ paddingVertical: spacing.sm,
+ paddingHorizontal: spacing.lg,
+ borderWidth: 1,
+ borderColor: 'rgba(0, 230, 118, 0.3)',
+ marginBottom: spacing.xl,
+ },
+ completedBadgeText: {
+ color: colors.success,
+ fontSize: fonts.sizes.sm,
+ fontWeight: fonts.weights.semibold,
+ },
+
+ // Journal card
+ journalCard: {
+ backgroundColor: colors.background,
+ borderRadius: borderRadius.xl,
+ padding: spacing.xl,
+ borderWidth: 1,
+ borderColor: 'rgba(108, 99, 255, 0.25)',
+ alignItems: 'center',
+ },
+ cardBrand: { color: colors.primary, fontSize: fonts.sizes.xs, fontWeight: fonts.weights.bold, letterSpacing: 5, marginBottom: spacing.lg },
+ cardMoodEmoji: { fontSize: 36, marginBottom: spacing.md },
+ cardTitle: { color: colors.text, fontSize: fonts.sizes.xl, fontWeight: fonts.weights.bold, textAlign: 'center', lineHeight: 30, marginBottom: spacing.md },
+ cardSummary: { color: colors.textSecondary, fontSize: fonts.sizes.md, textAlign: 'center', lineHeight: 24, marginBottom: spacing.lg, paddingHorizontal: spacing.sm },
+ cardDivider: { width: 40, height: 2, backgroundColor: colors.primary, borderRadius: 1, marginBottom: spacing.lg },
+ cardQuote: { color: colors.accent, fontSize: fonts.sizes.md, fontStyle: 'italic', textAlign: 'center', lineHeight: 24, marginBottom: spacing.lg, paddingHorizontal: spacing.sm },
+ cardFooter: { flexDirection: 'row', justifyContent: 'center', gap: spacing.md, marginBottom: spacing.sm },
+ cardDay: { color: colors.textMuted, fontSize: fonts.sizes.sm, fontWeight: fonts.weights.semibold },
+ cardDate: { color: colors.textMuted, fontSize: fonts.sizes.sm },
+ cardIdentity: { color: colors.textMuted, fontSize: fonts.sizes.xs, fontStyle: 'italic' },
+
+ // Actions
+ actions: { marginTop: spacing.xl },
+ actionBtn: { marginBottom: spacing.sm },
+ actionRow: { flexDirection: 'row', gap: spacing.sm, marginBottom: spacing.sm },
+ halfBtn: { flex: 1 },
+
+ // Logged data
+ loggedSection: {
+ marginTop: spacing.xl,
+ backgroundColor: colors.surface,
+ borderRadius: borderRadius.md,
+ padding: spacing.lg,
+ borderWidth: 1,
+ borderColor: colors.border,
+ },
+ loggedTitle: { color: colors.text, fontSize: fonts.sizes.md, fontWeight: fonts.weights.semibold, marginBottom: spacing.md },
+ loggedRow: { marginBottom: spacing.md },
+ loggedLabel: { color: colors.textMuted, fontSize: fonts.sizes.xs, fontWeight: fonts.weights.medium, marginBottom: spacing.xs },
+ loggedValue: { color: colors.textSecondary, fontSize: fonts.sizes.sm, lineHeight: 20 },
+});
diff --git a/src/screens/HabitSelectionScreen.js b/src/screens/HabitSelectionScreen.js
new file mode 100644
index 0000000..78e7008
--- /dev/null
+++ b/src/screens/HabitSelectionScreen.js
@@ -0,0 +1,418 @@
+import React, { useState, useRef, useEffect } from 'react';
+import {
+ View, Text, StyleSheet, Pressable,
+ TextInput, Animated,
+} from 'react-native';
+import { showAlert } from '../components/NovaAlert';
+import ScreenWrapper from '../components/ScreenWrapper';
+import Button from '../components/Button';
+import useIdentityStore from '../store/useIdentityStore';
+import { colors, fonts, spacing, borderRadius } from '../utils/theme';
+
+const MAX_HABITS = 5;
+
+function HabitChip({ text, selected, onToggle, disabled }) {
+ return (
+
+
+ {selected ? '✓' : '+'}
+
+ {text}
+
+ );
+}
+
+export default function HabitSelectionScreen({ navigation, route }) {
+ const { aiResult, story } = route.params;
+ const createIdentity = useIdentityStore((s) => s.createIdentity);
+
+ const [selectedHabits, setSelectedHabits] = useState([]);
+ const [customHabit, setCustomHabit] = useState('');
+ const [customHabits, setCustomHabits] = useState([]);
+ const [editingTitle, setEditingTitle] = useState(false);
+ const [title, setTitle] = useState(aiResult.identity_title);
+ const [loading, setLoading] = useState(false);
+
+ const fadeAnim = useRef(new Animated.Value(0)).current;
+
+ useEffect(() => {
+ Animated.timing(fadeAnim, { toValue: 1, duration: 500, useNativeDriver: true }).start();
+ }, []);
+
+ const allSelected = [...selectedHabits, ...customHabits];
+ const atMax = allSelected.length >= MAX_HABITS;
+
+ const toggleHabit = (habit) => {
+ setSelectedHabits((prev) =>
+ prev.includes(habit)
+ ? prev.filter((h) => h !== habit)
+ : atMax ? prev : [...prev, habit]
+ );
+ };
+
+ const addCustomHabit = () => {
+ const trimmed = customHabit.trim();
+ if (!trimmed) return;
+ if (allSelected.length >= MAX_HABITS) {
+ showAlert('Limit', `Maximum ${MAX_HABITS} habits allowed.`);
+ return;
+ }
+ if ([...selectedHabits, ...customHabits].some((h) => h.toLowerCase() === trimmed.toLowerCase())) {
+ showAlert('Duplicate', 'This habit is already in your list.');
+ return;
+ }
+ setCustomHabits((prev) => [...prev, trimmed]);
+ setCustomHabit('');
+ };
+
+ const removeCustom = (habit) => {
+ setCustomHabits((prev) => prev.filter((h) => h !== habit));
+ };
+
+ const handleStart = async () => {
+ if (allSelected.length === 0) {
+ showAlert('No Habits', 'Please select at least 1 habit.');
+ return;
+ }
+
+ setLoading(true);
+ try {
+ const habits = allSelected.map((h) => ({ title: h }));
+ const identityTitle = title.trim() || aiResult.identity_title;
+ const description = aiResult.identity_summary || '';
+ await createIdentity(identityTitle, description, habits, story || '');
+ navigation.reset({ index: 0, routes: [{ name: 'MainTabs' }] });
+ } catch (e) {
+ showAlert('Error', e.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+ {/* Source badge */}
+
+
+ {aiResult.source === 'ai' ? '✦ AI Generated' : '⚡ Smart Suggestions'}
+
+
+
+ {/* Identity Title */}
+
+ YOUR NEW IDENTITY
+ {editingTitle ? (
+ setEditingTitle(false)}
+ autoFocus
+ selectionColor={colors.primary}
+ />
+ ) : (
+ setEditingTitle(true)}>
+ {title}
+ Tap to edit
+
+ )}
+
+
+ {/* Summary */}
+ {aiResult.identity_summary}
+
+ {/* Habit selection */}
+
+
+ Select Your Habits
+
+ {allSelected.length} / {MAX_HABITS}
+
+
+
+ {/* AI suggested habits */}
+ {aiResult.suggested_habits.map((habit) => (
+ toggleHabit(habit)}
+ disabled={atMax}
+ />
+ ))}
+
+ {/* Custom habits */}
+ {customHabits.map((habit) => (
+
+
+ ✓
+ {habit}
+
+ removeCustom(habit)} style={styles.removeBtn}>
+ ✕
+
+
+ ))}
+
+ {/* Add custom */}
+ {!atMax && (
+
+
+
+ +
+
+
+ )}
+
+
+ {/* Start button */}
+
+
+
+ {allSelected.length === 0
+ ? 'Select at least 1 habit to begin'
+ : `${allSelected.length} habit${allSelected.length > 1 ? 's' : ''} selected. Ready to transform.`}
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ scroll: { flex: 1 },
+ scrollContent: {
+ paddingHorizontal: spacing.lg,
+ paddingTop: spacing.xl,
+ paddingBottom: spacing.xxl,
+ },
+
+ // Source badge
+ sourceBadge: {
+ alignSelf: 'flex-start',
+ backgroundColor: 'rgba(108, 99, 255, 0.08)',
+ borderRadius: borderRadius.full,
+ paddingVertical: spacing.xs,
+ paddingHorizontal: spacing.md,
+ borderWidth: 1,
+ borderColor: 'rgba(108, 99, 255, 0.2)',
+ marginBottom: spacing.lg,
+ },
+ sourceBadgeText: {
+ color: colors.primaryLight,
+ fontSize: fonts.sizes.xs,
+ fontWeight: fonts.weights.medium,
+ },
+
+ // Identity card
+ identityCard: {
+ backgroundColor: colors.surface,
+ borderRadius: borderRadius.lg,
+ padding: spacing.lg,
+ borderWidth: 1,
+ borderColor: colors.primary,
+ marginBottom: spacing.md,
+ },
+ identityLabel: {
+ color: colors.primary,
+ fontSize: fonts.sizes.xs,
+ fontWeight: fonts.weights.bold,
+ letterSpacing: 2,
+ marginBottom: spacing.sm,
+ },
+ identityTitle: {
+ color: colors.text,
+ fontSize: fonts.sizes.xl,
+ fontWeight: fonts.weights.bold,
+ lineHeight: 32,
+ },
+ titleInput: {
+ color: colors.text,
+ fontSize: fonts.sizes.xl,
+ fontWeight: fonts.weights.bold,
+ borderBottomWidth: 1,
+ borderBottomColor: colors.primary,
+ paddingVertical: spacing.xs,
+ },
+ editHint: {
+ color: colors.textMuted,
+ fontSize: fonts.sizes.xs,
+ marginTop: spacing.xs,
+ },
+
+ // Summary
+ summary: {
+ color: colors.textSecondary,
+ fontSize: fonts.sizes.md,
+ lineHeight: 24,
+ marginBottom: spacing.xl,
+ },
+
+ // Habits
+ habitSection: {
+ marginBottom: spacing.xl,
+ },
+ habitHeader: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginBottom: spacing.md,
+ },
+ sectionTitle: {
+ color: colors.text,
+ fontSize: fonts.sizes.lg,
+ fontWeight: fonts.weights.semibold,
+ },
+ counter: {
+ color: colors.textMuted,
+ fontSize: fonts.sizes.sm,
+ fontWeight: fonts.weights.medium,
+ },
+ counterFull: {
+ color: colors.warning,
+ },
+
+ // Chips
+ chip: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: colors.surface,
+ borderRadius: borderRadius.md,
+ paddingVertical: spacing.md,
+ paddingHorizontal: spacing.md,
+ borderWidth: 1,
+ borderColor: colors.border,
+ marginBottom: spacing.sm,
+ },
+ chipSelected: {
+ borderColor: colors.primary,
+ backgroundColor: 'rgba(108, 99, 255, 0.08)',
+ },
+ chipDisabled: {
+ opacity: 0.4,
+ },
+ chipCheck: {
+ width: 24,
+ height: 24,
+ borderRadius: 12,
+ borderWidth: 1.5,
+ borderColor: colors.textMuted,
+ textAlign: 'center',
+ lineHeight: 22,
+ fontSize: 12,
+ color: colors.textMuted,
+ marginRight: spacing.md,
+ flexShrink: 0,
+ },
+ chipCheckSelected: {
+ borderColor: colors.primary,
+ backgroundColor: colors.primary,
+ color: colors.text,
+ },
+ chipText: {
+ color: colors.text,
+ fontSize: fonts.sizes.md,
+ flex: 1,
+ flexWrap: 'wrap',
+ },
+ chipTextSelected: {
+ color: colors.text,
+ fontWeight: fonts.weights.medium,
+ },
+
+ // Custom chips
+ customChipRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginBottom: spacing.sm,
+ },
+ customChip: {
+ flex: 1,
+ marginBottom: 0,
+ },
+ removeBtn: {
+ width: 36,
+ height: 36,
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginLeft: spacing.sm,
+ },
+ removeBtnText: {
+ color: colors.error,
+ fontSize: 16,
+ },
+
+ // Add custom
+ addRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginTop: spacing.xs,
+ },
+ addInput: {
+ flex: 1,
+ backgroundColor: colors.surface,
+ borderRadius: borderRadius.md,
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.md,
+ color: colors.text,
+ fontSize: fonts.sizes.md,
+ borderWidth: 1,
+ borderColor: colors.border,
+ borderStyle: 'dashed',
+ },
+ addBtn: {
+ width: 44,
+ height: 44,
+ borderRadius: 22,
+ backgroundColor: colors.primary,
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginLeft: spacing.sm,
+ },
+ addBtnDisabled: {
+ opacity: 0.4,
+ },
+ addBtnText: {
+ color: colors.text,
+ fontSize: 22,
+ fontWeight: fonts.weights.bold,
+ lineHeight: 24,
+ },
+
+ // Start
+ startBtn: {},
+ startHint: {
+ color: colors.textMuted,
+ fontSize: fonts.sizes.sm,
+ textAlign: 'center',
+ marginTop: spacing.md,
+ },
+});
diff --git a/src/screens/HomeScreen.js b/src/screens/HomeScreen.js
new file mode 100644
index 0000000..fea37de
--- /dev/null
+++ b/src/screens/HomeScreen.js
@@ -0,0 +1,294 @@
+import React, { useEffect, useState, useRef, useCallback } from 'react';
+import { View, Text, StyleSheet, Animated, Dimensions } from 'react-native';
+import { CommonActions, useFocusEffect } from '@react-navigation/native';
+import { showAlert } from '../components/NovaAlert';
+import ScreenWrapper from '../components/ScreenWrapper';
+import OrbitContainer from '../components/OrbitContainer';
+import Button from '../components/Button';
+import useIdentityStore from '../store/useIdentityStore';
+import { colors, fonts, spacing } from '../utils/theme';
+import { getDayPhase, isCriticalDay } from '../utils/helpers';
+
+const { width: SCREEN_WIDTH } = Dimensions.get('window');
+const ORBIT_SIZE = Math.min(SCREEN_WIDTH * 0.82, 340);
+const PLANET_SIZE = ORBIT_SIZE * 0.28;
+
+export default function HomeScreen({ navigation }) {
+ const identity = useIdentityStore((s) => s.identity);
+ const currentDay = useIdentityStore((s) => s.currentDay);
+ const loadIdentity = useIdentityStore((s) => s.fetchIdentity);
+ const [loading, setLoading] = useState(true);
+
+ // Entrance animations
+ const fadeHeader = useRef(new Animated.Value(0)).current;
+ const fadeOrbit = useRef(new Animated.Value(0)).current;
+ const scaleOrbit = useRef(new Animated.Value(0.85)).current;
+ const fadeBottom = useRef(new Animated.Value(0)).current;
+ const slideBottom = useRef(new Animated.Value(30)).current;
+
+ useFocusEffect(
+ useCallback(() => {
+ let active = true;
+ const init = async () => {
+ await loadIdentity();
+ if (active) setLoading(false);
+ };
+ init();
+ return () => { active = false; };
+ }, [])
+ );
+
+ useEffect(() => {
+ if (!loading && !identity) {
+ navigation.dispatch(
+ CommonActions.reset({ index: 0, routes: [{ name: 'IdentityStory' }] })
+ );
+ }
+ if (!loading && identity) {
+ Animated.stagger(150, [
+ Animated.timing(fadeHeader, { toValue: 1, duration: 600, useNativeDriver: true }),
+ Animated.parallel([
+ Animated.timing(fadeOrbit, { toValue: 1, duration: 800, useNativeDriver: true }),
+ Animated.spring(scaleOrbit, { toValue: 1, useNativeDriver: true, speed: 8, bounciness: 6 }),
+ ]),
+ Animated.parallel([
+ Animated.timing(fadeBottom, { toValue: 1, duration: 500, useNativeDriver: true }),
+ Animated.timing(slideBottom, { toValue: 0, duration: 500, useNativeDriver: true }),
+ ]),
+ ]).start();
+ }
+ }, [loading, identity]);
+
+ useEffect(() => {
+ if (identity && currentDay > 0 && isCriticalDay(currentDay)) {
+ showAlert(
+ 'Critical Day',
+ `Day ${currentDay} is a pivotal moment in your journey. Complete a focus challenge to power through.`,
+ [
+ { text: 'Later', style: 'cancel' },
+ { text: 'Take Challenge', onPress: () => navigation.navigate('MiniGame') },
+ ]
+ );
+ }
+ }, [currentDay]);
+
+ const handleDotPress = useCallback((_dayIndex, status) => {
+ if (status === 'current') {
+ navigation.navigate('Daily');
+ }
+ // Future: show past day summary for completed dots
+ }, [navigation]);
+
+ if (loading || !identity) {
+ return (
+
+
+ Loading your universe...
+
+
+ );
+ }
+
+ if (currentDay > 40) {
+ navigation.dispatch(
+ CommonActions.reset({ index: 0, routes: [{ name: 'Completion' }] })
+ );
+ return null;
+ }
+
+ const phase = getDayPhase(currentDay);
+ const glowIntensity = 0.3 + (currentDay / 40) * 0.7;
+ const progress = ((currentDay - 1) / 40) * 100;
+
+ return (
+
+
+ {/* Phase & Identity Header */}
+
+ {phase.toUpperCase()} PHASE
+ {identity.title}
+
+
+ {/* Orbit System */}
+
+
+
+
+ {/* Bottom Info */}
+
+ {/* Day Counter */}
+
+ Day {currentDay}
+ of 40
+
+
+ {/* Progress Bar */}
+
+
+
+
+
+
+ {/* Critical Day Badge */}
+ {isCriticalDay(currentDay) && (
+
+
+ CRITICAL DAY — Stay focused
+
+ )}
+
+ {/* Start Button */}
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingTop: spacing.lg,
+ paddingBottom: spacing.md,
+ paddingHorizontal: spacing.lg,
+ },
+ loadingContainer: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ loadingText: {
+ color: colors.textSecondary,
+ fontSize: fonts.sizes.md,
+ },
+
+ // Header
+ header: {
+ alignItems: 'center',
+ paddingTop: spacing.sm,
+ },
+ phase: {
+ color: colors.accent,
+ fontSize: fonts.sizes.xs,
+ fontWeight: fonts.weights.bold,
+ letterSpacing: 4,
+ marginBottom: spacing.xs,
+ },
+ identity: {
+ color: colors.text,
+ fontSize: fonts.sizes.lg,
+ fontWeight: fonts.weights.semibold,
+ textAlign: 'center',
+ lineHeight: 28,
+ },
+
+ // Orbit
+ orbitArea: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+
+ // Bottom
+ bottomSection: {
+ width: '100%',
+ alignItems: 'center',
+ },
+ dayRow: {
+ flexDirection: 'row',
+ alignItems: 'baseline',
+ marginBottom: spacing.md,
+ },
+ dayNumber: {
+ color: colors.text,
+ fontSize: fonts.sizes.xxl,
+ fontWeight: fonts.weights.bold,
+ },
+ dayTotal: {
+ color: colors.textMuted,
+ fontSize: fonts.sizes.lg,
+ fontWeight: fonts.weights.medium,
+ },
+
+ // Progress
+ progressBar: {
+ width: '100%',
+ height: 4,
+ backgroundColor: colors.surface,
+ borderRadius: 2,
+ marginBottom: spacing.lg,
+ overflow: 'hidden',
+ },
+ progressFill: {
+ height: '100%',
+ backgroundColor: colors.primary,
+ borderRadius: 2,
+ position: 'relative',
+ },
+ progressGlow: {
+ position: 'absolute',
+ right: 0,
+ top: -2,
+ width: 16,
+ height: 8,
+ borderRadius: 4,
+ backgroundColor: colors.primaryLight,
+ shadowColor: colors.primary,
+ shadowOffset: { width: 0, height: 0 },
+ shadowOpacity: 1,
+ shadowRadius: 6,
+ elevation: 4,
+ },
+
+ // Critical
+ criticalBadge: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: 'rgba(255, 82, 82, 0.1)',
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.sm,
+ borderRadius: 20,
+ borderWidth: 1,
+ borderColor: 'rgba(255, 82, 82, 0.3)',
+ marginBottom: spacing.md,
+ },
+ criticalDot: {
+ width: 6,
+ height: 6,
+ borderRadius: 3,
+ backgroundColor: colors.error,
+ marginRight: spacing.sm,
+ },
+ criticalText: {
+ color: colors.error,
+ fontSize: fonts.sizes.xs,
+ fontWeight: fonts.weights.bold,
+ letterSpacing: 1,
+ },
+
+ // Button
+ startButton: {
+ width: '100%',
+ },
+});
diff --git a/src/screens/IdentityStoryScreen.js b/src/screens/IdentityStoryScreen.js
new file mode 100644
index 0000000..66f7530
--- /dev/null
+++ b/src/screens/IdentityStoryScreen.js
@@ -0,0 +1,233 @@
+import React, { useState, useRef, useEffect } from 'react';
+import {
+ View, Text, TextInput, StyleSheet,
+ KeyboardAvoidingView, Platform, Animated, ScrollView,
+} from 'react-native';
+import { showAlert } from '../components/NovaAlert';
+import ScreenWrapper from '../components/ScreenWrapper';
+import Button from '../components/Button';
+import { generateFromStory } from '../services/aiService';
+import { colors, fonts, spacing, borderRadius } from '../utils/theme';
+
+const MIN_CHARS = 30;
+const MAX_CHARS = 1000;
+
+const PROMPTS = [
+ '"I want to stop procrastinating and start showing up for myself every day..."',
+ '"I used to be fit and confident, but I lost myself. I want that person back..."',
+ '"I dream of being someone who reads, learns, and never stops growing..."',
+ '"I\'m tired of being anxious. I want to be calm, focused, and in control..."',
+];
+
+export default function IdentityStoryScreen({ navigation }) {
+ const [story, setStory] = useState('');
+ const [loading, setLoading] = useState(false);
+ const [promptIndex] = useState(() => Math.floor(Math.random() * PROMPTS.length));
+
+ const fadeAnim = useRef(new Animated.Value(0)).current;
+ const slideAnim = useRef(new Animated.Value(20)).current;
+
+ useEffect(() => {
+ Animated.parallel([
+ Animated.timing(fadeAnim, { toValue: 1, duration: 600, useNativeDriver: true }),
+ Animated.timing(slideAnim, { toValue: 0, duration: 600, useNativeDriver: true }),
+ ]).start();
+ }, []);
+
+ const handleGenerate = async () => {
+ const trimmed = story.trim();
+ if (trimmed.length < MIN_CHARS) {
+ showAlert('Tell us more', `Please write at least ${MIN_CHARS} characters about yourself.`);
+ return;
+ }
+
+ setLoading(true);
+ try {
+ const result = await generateFromStory(trimmed);
+ if (result.source === 'fallback') {
+ showAlert(
+ 'AI Unavailable',
+ 'We generated suggestions based on keywords instead. You can edit everything on the next screen.',
+ [{ text: 'Continue', onPress: () => navigation.navigate('HabitSelection', { aiResult: result, story: trimmed }) }]
+ );
+ } else {
+ navigation.navigate('HabitSelection', { aiResult: result, story: trimmed });
+ }
+ } catch (e) {
+ showAlert('Error', e.message || 'Something went wrong. Please try again.');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleSkip = () => {
+ navigation.navigate('CreateIdentity');
+ };
+
+ const charCount = story.trim().length;
+ const isValid = charCount >= MIN_CHARS;
+
+ return (
+
+
+
+
+ {/* Header */}
+ Your Story
+
+ Tell us about yourself — who you are now, who you want to become, and why this matters to you.
+
+
+ {/* AI badge */}
+
+ ✦
+ AI will craft your identity and habits
+
+
+ {/* Story input */}
+
+ setStory(t.slice(0, MAX_CHARS))}
+ placeholder={PROMPTS[promptIndex]}
+ placeholderTextColor={colors.textMuted}
+ multiline
+ textAlignVertical="top"
+ selectionColor={colors.primary}
+ />
+
+ {charCount} / {MIN_CHARS}+
+
+
+
+ {/* Tips */}
+
+ What to write about
+ • What do you struggle with right now?
+ • What kind of person do you want to become?
+ • What would change if you transformed?
+
+
+ {/* Actions */}
+
+
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ flex: { flex: 1 },
+ scrollContent: {
+ paddingHorizontal: spacing.lg,
+ paddingTop: spacing.xxl,
+ paddingBottom: spacing.xxl,
+ },
+ title: {
+ color: colors.text,
+ fontSize: fonts.sizes.xxl,
+ fontWeight: fonts.weights.bold,
+ marginBottom: spacing.xs,
+ },
+ subtitle: {
+ color: colors.textSecondary,
+ fontSize: fonts.sizes.md,
+ lineHeight: 22,
+ marginBottom: spacing.lg,
+ },
+ aiBadge: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: 'rgba(108, 99, 255, 0.08)',
+ borderRadius: borderRadius.full,
+ paddingVertical: spacing.sm,
+ paddingHorizontal: spacing.md,
+ alignSelf: 'flex-start',
+ marginBottom: spacing.lg,
+ borderWidth: 1,
+ borderColor: 'rgba(108, 99, 255, 0.2)',
+ },
+ aiBadgeIcon: {
+ color: colors.primary,
+ fontSize: 14,
+ marginRight: spacing.sm,
+ },
+ aiBadgeText: {
+ color: colors.primaryLight,
+ fontSize: fonts.sizes.xs,
+ fontWeight: fonts.weights.medium,
+ },
+ inputContainer: {
+ marginBottom: spacing.lg,
+ },
+ storyInput: {
+ backgroundColor: colors.surface,
+ borderRadius: borderRadius.md,
+ borderWidth: 1,
+ borderColor: colors.border,
+ paddingHorizontal: spacing.md,
+ paddingTop: spacing.md,
+ paddingBottom: spacing.xl,
+ color: colors.text,
+ fontSize: fonts.sizes.md,
+ minHeight: 160,
+ lineHeight: 24,
+ },
+ charCount: {
+ position: 'absolute',
+ bottom: spacing.sm,
+ right: spacing.md,
+ color: colors.textMuted,
+ fontSize: fonts.sizes.xs,
+ },
+ charCountValid: {
+ color: colors.success,
+ },
+ tipsCard: {
+ backgroundColor: colors.surface,
+ borderRadius: borderRadius.md,
+ padding: spacing.md,
+ borderWidth: 1,
+ borderColor: colors.border,
+ marginBottom: spacing.xl,
+ },
+ tipsTitle: {
+ color: colors.textSecondary,
+ fontSize: fonts.sizes.sm,
+ fontWeight: fonts.weights.semibold,
+ marginBottom: spacing.sm,
+ },
+ tipItem: {
+ color: colors.textMuted,
+ fontSize: fonts.sizes.sm,
+ lineHeight: 22,
+ },
+ generateBtn: {
+ marginBottom: spacing.sm,
+ },
+ skipBtn: {},
+});
diff --git a/src/screens/JournalResultScreen.js b/src/screens/JournalResultScreen.js
new file mode 100644
index 0000000..87ae919
--- /dev/null
+++ b/src/screens/JournalResultScreen.js
@@ -0,0 +1,298 @@
+import React, { useRef, useEffect } from 'react';
+import {
+ View, Text, StyleSheet, Animated, Platform,
+} from 'react-native';
+import { showAlert } from '../components/NovaAlert';
+import ViewShot from 'react-native-view-shot';
+import * as Sharing from 'expo-sharing';
+import * as MediaLibrary from 'expo-media-library';
+import * as Print from 'expo-print';
+import ScreenWrapper from '../components/ScreenWrapper';
+import Button from '../components/Button';
+import { colors, fonts, spacing, borderRadius } from '../utils/theme';
+
+export default function JournalResultScreen({ navigation, route }) {
+ const {
+ aiTitle, aiSummary, aiQuote,
+ source, dayNumber, date, mood, identityTitle,
+ } = route.params;
+
+ const viewShotRef = useRef(null);
+ const fadeAnim = useRef(new Animated.Value(0)).current;
+ const cardScale = useRef(new Animated.Value(0.9)).current;
+
+ useEffect(() => {
+ Animated.parallel([
+ Animated.timing(fadeAnim, { toValue: 1, duration: 600, useNativeDriver: true }),
+ Animated.spring(cardScale, { toValue: 1, useNativeDriver: true, speed: 10, bounciness: 8 }),
+ ]).start();
+ }, []);
+
+ const moodEmojis = { great: '😄', good: '🙂', okay: '😐', bad: '😔', terrible: '😞' };
+
+ // --- Share image ---
+ const handleShare = async () => {
+ try {
+ const uri = await viewShotRef.current.capture();
+ await Sharing.shareAsync(uri, { mimeType: 'image/png' });
+ } catch (e) {
+ showAlert('Error', 'Could not share image.');
+ }
+ };
+
+ // --- Save to gallery ---
+ const handleSaveImage = async () => {
+ try {
+ const { status } = await MediaLibrary.requestPermissionsAsync();
+ if (status !== 'granted') {
+ showAlert('Permission needed', 'Allow access to save images.');
+ return;
+ }
+ const uri = await viewShotRef.current.capture();
+ await MediaLibrary.saveToLibraryAsync(uri);
+ showAlert('Saved', 'Journal card saved to your gallery.');
+ } catch (e) {
+ showAlert('Error', 'Could not save image.');
+ }
+ };
+
+ // --- Export PDF ---
+ const handleExportPDF = async () => {
+ try {
+ const html = `
+
+
+
+
+
+
+ ${aiTitle}
+ ${aiSummary}
+
+ "${aiQuote}"
+
+ Day ${dayNumber} — ${date}
+ ${identityTitle || ''}
+
+
+ `;
+
+ const { uri } = await Print.printToFileAsync({ html });
+
+ if (Platform.OS === 'ios') {
+ await Sharing.shareAsync(uri);
+ } else {
+ await Sharing.shareAsync(uri, { mimeType: 'application/pdf' });
+ }
+ } catch (e) {
+ showAlert('Error', 'Could not generate PDF.');
+ }
+ };
+
+ return (
+
+
+ {/* Source badge */}
+
+
+ {source === 'ai' ? '✦ AI Reflection' : '✦ Daily Reflection'}
+
+
+
+ {/* Shareable card */}
+
+
+
+ {/* Card header */}
+ NOVA40
+
+ {/* Mood */}
+ {moodEmojis[mood] || '✦'}
+
+ {/* Title */}
+ {aiTitle}
+
+ {/* Summary */}
+ {aiSummary}
+
+ {/* Divider */}
+
+
+ {/* Quote */}
+ "{aiQuote}"
+
+ {/* Footer */}
+
+ Day {dayNumber}
+ {date}
+
+
+ {identityTitle && (
+ {identityTitle}
+ )}
+
+
+
+
+ {/* Actions */}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ scroll: { flex: 1 },
+ scrollContent: {
+ paddingHorizontal: spacing.lg,
+ paddingTop: spacing.xl,
+ paddingBottom: spacing.xxl * 2,
+ alignItems: 'center',
+ },
+
+ badge: {
+ alignSelf: 'center',
+ backgroundColor: 'rgba(108, 99, 255, 0.08)',
+ borderRadius: borderRadius.full,
+ paddingVertical: spacing.xs,
+ paddingHorizontal: spacing.md,
+ borderWidth: 1,
+ borderColor: 'rgba(108, 99, 255, 0.2)',
+ marginBottom: spacing.lg,
+ },
+ badgeText: {
+ color: colors.primaryLight,
+ fontSize: fonts.sizes.xs,
+ fontWeight: fonts.weights.medium,
+ },
+
+ // Card
+ card: {
+ backgroundColor: colors.background,
+ borderRadius: borderRadius.xl,
+ padding: spacing.xl,
+ borderWidth: 1,
+ borderColor: 'rgba(108, 99, 255, 0.25)',
+ alignItems: 'center',
+ width: '100%',
+ },
+ cardBrand: {
+ color: colors.primary,
+ fontSize: fonts.sizes.xs,
+ fontWeight: fonts.weights.bold,
+ letterSpacing: 5,
+ marginBottom: spacing.lg,
+ },
+ moodEmoji: {
+ fontSize: 36,
+ marginBottom: spacing.md,
+ },
+ cardTitle: {
+ color: colors.text,
+ fontSize: fonts.sizes.xl,
+ fontWeight: fonts.weights.bold,
+ textAlign: 'center',
+ lineHeight: 30,
+ marginBottom: spacing.md,
+ },
+ cardSummary: {
+ color: colors.textSecondary,
+ fontSize: fonts.sizes.md,
+ textAlign: 'center',
+ lineHeight: 24,
+ marginBottom: spacing.lg,
+ paddingHorizontal: spacing.sm,
+ },
+ cardDivider: {
+ width: 40,
+ height: 2,
+ backgroundColor: colors.primary,
+ borderRadius: 1,
+ marginBottom: spacing.lg,
+ },
+ cardQuote: {
+ color: colors.accent,
+ fontSize: fonts.sizes.md,
+ fontStyle: 'italic',
+ textAlign: 'center',
+ lineHeight: 24,
+ marginBottom: spacing.lg,
+ paddingHorizontal: spacing.sm,
+ },
+ cardFooter: {
+ flexDirection: 'row',
+ justifyContent: 'center',
+ gap: spacing.md,
+ marginBottom: spacing.sm,
+ },
+ cardDay: {
+ color: colors.textMuted,
+ fontSize: fonts.sizes.sm,
+ fontWeight: fonts.weights.semibold,
+ },
+ cardDate: {
+ color: colors.textMuted,
+ fontSize: fonts.sizes.sm,
+ },
+ cardIdentity: {
+ color: colors.textMuted,
+ fontSize: fonts.sizes.xs,
+ fontStyle: 'italic',
+ },
+
+ // Actions
+ actions: {
+ width: '100%',
+ marginTop: spacing.xl,
+ },
+ actionBtn: {
+ marginBottom: spacing.sm,
+ },
+ actionRow: {
+ flexDirection: 'row',
+ gap: spacing.sm,
+ marginBottom: spacing.sm,
+ },
+ halfBtn: {
+ flex: 1,
+ },
+});
diff --git a/src/screens/LoginScreen.js b/src/screens/LoginScreen.js
new file mode 100644
index 0000000..4ddfca9
--- /dev/null
+++ b/src/screens/LoginScreen.js
@@ -0,0 +1,325 @@
+import React, { useState, useEffect } from 'react';
+import {
+ View, Text, StyleSheet, TouchableOpacity, Pressable,
+ KeyboardAvoidingView, Platform, Linking,
+} from 'react-native';
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import { showAlert } from '../components/NovaAlert';
+import ScreenWrapper from '../components/ScreenWrapper';
+import Input from '../components/Input';
+import Button from '../components/Button';
+import useAuthStore from '../store/useAuthStore';
+import { colors, fonts, spacing, borderRadius } from '../utils/theme';
+
+const REMEMBER_KEY = 'remember_email';
+const DEMO_EMAIL = 'demo@nova40.app';
+const DEMO_PASSWORD = '123456';
+
+export default function LoginScreen({ navigation }) {
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [showPassword, setShowPassword] = useState(false);
+ const [rememberMe, setRememberMe] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [demoLoading, setDemoLoading] = useState(false);
+ const [googleLoading, setGoogleLoading] = useState(false);
+
+ const login = useAuthStore((s) => s.login);
+ const loginAsDemo = useAuthStore((s) => s.loginAsDemo);
+ const loginWithGoogle = useAuthStore((s) => s.loginWithGoogle);
+
+ // Load remembered email on mount
+ useEffect(() => {
+ const load = async () => {
+ try {
+ const saved = await AsyncStorage.getItem(REMEMBER_KEY);
+ if (saved) {
+ setEmail(saved);
+ setRememberMe(true);
+ } else {
+ setEmail(DEMO_EMAIL);
+ setPassword(DEMO_PASSWORD);
+ }
+ } catch (_) {
+ setEmail(DEMO_EMAIL);
+ setPassword(DEMO_PASSWORD);
+ }
+ };
+ load();
+ }, []);
+
+ const goToApp = () => {
+ navigation.reset({ index: 0, routes: [{ name: 'MainTabs' }] });
+ };
+
+ const handleLogin = async () => {
+ if (!email.trim()) {
+ showAlert('Validation Error', 'Please enter your email address.');
+ return;
+ }
+ if (!password.trim()) {
+ showAlert('Validation Error', 'Please enter your password.');
+ return;
+ }
+
+ setLoading(true);
+ try {
+ // Save or clear remembered email
+ if (rememberMe) {
+ await AsyncStorage.setItem(REMEMBER_KEY, email.trim());
+ } else {
+ await AsyncStorage.removeItem(REMEMBER_KEY);
+ }
+
+ await login(email.trim(), password);
+ goToApp();
+ } catch (error) {
+ showAlert('Login Failed', error.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleDemoLogin = async () => {
+ setDemoLoading(true);
+ try {
+ await loginAsDemo();
+ goToApp();
+ } catch (error) {
+ showAlert('Demo Login Failed', error.message);
+ } finally {
+ setDemoLoading(false);
+ }
+ };
+
+ const handleGoogleLogin = async () => {
+ setGoogleLoading(true);
+ try {
+ const data = await loginWithGoogle();
+ // OAuth opens a browser — session will be picked up by onAuthStateChange
+ if (data?.url) {
+ await Linking.openURL(data.url);
+ }
+ } catch (error) {
+ showAlert('Google Login Failed', error.message);
+ } finally {
+ setGoogleLoading(false);
+ }
+ };
+
+ const anyLoading = loading || demoLoading || googleLoading;
+
+ return (
+
+
+ {/* Header */}
+
+ Welcome Back
+ Continue your transformation
+
+
+ {/* Form */}
+
+
+
+ setShowPassword((prev) => !prev)}
+ />
+
+ {/* Remember Me */}
+ setRememberMe((prev) => !prev)}
+ >
+
+ {rememberMe && ✓}
+
+ Remember Me
+
+
+ {/* Sign In */}
+
+
+ {/* Divider */}
+
+
+ or
+
+
+
+ {/* Google Login */}
+ [styles.googleBtn, pressed && styles.googleBtnPressed]}
+ onPress={handleGoogleLogin}
+ disabled={anyLoading}
+ >
+ G
+ Continue with Google
+
+
+ {/* Demo Login */}
+
+
+
+ {/* Footer */}
+
+ Don't have an account?
+ navigation.navigate('Register')}>
+ Create one
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ justifyContent: 'center',
+ paddingHorizontal: spacing.lg,
+ },
+ header: {
+ marginBottom: spacing.xxl,
+ },
+ title: {
+ color: colors.text,
+ fontSize: fonts.sizes.xxl,
+ fontWeight: fonts.weights.bold,
+ marginBottom: spacing.xs,
+ },
+ subtitle: {
+ color: colors.textSecondary,
+ fontSize: fonts.sizes.md,
+ },
+ form: {
+ marginBottom: spacing.xl,
+ },
+
+ // Remember Me
+ rememberRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginBottom: spacing.md,
+ marginTop: spacing.xs,
+ },
+ checkbox: {
+ width: 22,
+ height: 22,
+ borderRadius: 6,
+ borderWidth: 1.5,
+ borderColor: colors.textMuted,
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginRight: spacing.sm,
+ },
+ checkboxChecked: {
+ borderColor: colors.primary,
+ backgroundColor: colors.primary,
+ },
+ checkmark: {
+ color: colors.text,
+ fontSize: 13,
+ fontWeight: fonts.weights.bold,
+ },
+ rememberText: {
+ color: colors.textSecondary,
+ fontSize: fonts.sizes.sm,
+ },
+
+ // Buttons
+ loginBtn: {
+ marginTop: spacing.sm,
+ },
+
+ // Divider
+ divider: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginVertical: spacing.lg,
+ },
+ dividerLine: {
+ flex: 1,
+ height: 1,
+ backgroundColor: colors.border,
+ },
+ dividerText: {
+ color: colors.textMuted,
+ fontSize: fonts.sizes.sm,
+ marginHorizontal: spacing.md,
+ },
+
+ // Google
+ googleBtn: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingVertical: spacing.md,
+ borderRadius: borderRadius.lg,
+ borderWidth: 1.5,
+ borderColor: colors.border,
+ backgroundColor: colors.surface,
+ marginBottom: spacing.sm,
+ },
+ googleBtnPressed: {
+ opacity: 0.7,
+ },
+ googleIcon: {
+ fontSize: 20,
+ fontWeight: fonts.weights.bold,
+ color: '#4285F4',
+ marginRight: spacing.sm,
+ },
+ googleText: {
+ color: colors.text,
+ fontSize: fonts.sizes.md,
+ fontWeight: fonts.weights.medium,
+ },
+
+ demoBtn: {
+ marginTop: spacing.xs,
+ },
+
+ // Footer
+ footer: {
+ flexDirection: 'row',
+ justifyContent: 'center',
+ },
+ footerText: {
+ color: colors.textSecondary,
+ fontSize: fonts.sizes.sm,
+ },
+ footerLink: {
+ color: colors.primary,
+ fontSize: fonts.sizes.sm,
+ fontWeight: fonts.weights.semibold,
+ },
+});
diff --git a/src/screens/MiniGameScreen.js b/src/screens/MiniGameScreen.js
new file mode 100644
index 0000000..6eb0beb
--- /dev/null
+++ b/src/screens/MiniGameScreen.js
@@ -0,0 +1,229 @@
+import React, { useState, useCallback } from 'react';
+import { View, Text, StyleSheet, Pressable, Animated, ScrollView } from 'react-native';
+import { showAlert } from '../components/NovaAlert';
+import ScreenWrapper from '../components/ScreenWrapper';
+import ReflexTap from '../components/games/ReflexTap';
+import FocusHold from '../components/games/FocusHold';
+import TemptationChoice from '../components/games/TemptationChoice';
+import TimingTap from '../components/games/TimingTap';
+import GameResult from '../components/games/GameResult';
+import useIdentityStore from '../store/useIdentityStore';
+import { saveGameSession } from '../services/gameService';
+import { colors, fonts, spacing, borderRadius } from '../utils/theme';
+
+const GAMES = [
+ {
+ id: 'reflex_tap',
+ name: 'Reflex Tap',
+ icon: '⚡',
+ desc: 'Train quick action',
+ stat: 'Discipline',
+ color: colors.accent,
+ },
+ {
+ id: 'focus_hold',
+ name: 'Focus Hold',
+ icon: '🎯',
+ desc: 'Train focus & stability',
+ stat: 'Focus',
+ color: colors.primary,
+ },
+ {
+ id: 'temptation_choice',
+ name: 'Temptation',
+ icon: '🧠',
+ desc: 'Real-life decisions',
+ stat: 'Consistency',
+ color: colors.success,
+ },
+ {
+ id: 'timing_tap',
+ name: 'Timing Tap',
+ icon: '⏱',
+ desc: 'Precision & awareness',
+ stat: 'Focus',
+ color: colors.warning,
+ },
+];
+
+// Phase: 'select' | 'playing' | 'result'
+export default function MiniGameScreen({ navigation, route }) {
+ const currentDay = useIdentityStore((s) => s.currentDay);
+ const identity = useIdentityStore((s) => s.identity);
+
+ const [phase, setPhase] = useState(route?.params?.gameType ? 'playing' : 'select');
+ const [activeGame, setActiveGame] = useState(route?.params?.gameType || null);
+ const [result, setResult] = useState(null);
+ const [saving, setSaving] = useState(false);
+
+ const selectGame = (gameId) => {
+ setActiveGame(gameId);
+ setPhase('playing');
+ setResult(null);
+ };
+
+ const handleGameComplete = useCallback((score, details) => {
+ setResult({ score, details });
+ setPhase('result');
+ }, []);
+
+ const handleSave = async () => {
+ if (!identity || !result) return;
+ setSaving(true);
+ try {
+ await saveGameSession(identity.id, activeGame, result.score);
+ navigation.goBack();
+ } catch (e) {
+ showAlert('Error', 'Failed to save score.');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleRetry = () => {
+ setResult(null);
+ setPhase('playing');
+ };
+
+ // === SELECT PHASE ===
+ if (phase === 'select') {
+ return (
+
+
+ Mind Training
+ Choose your challenge
+
+
+ {GAMES.map((game) => (
+ [styles.gameCard, pressed && styles.gameCardPressed]}
+ onPress={() => selectGame(game.id)}
+ >
+ {game.icon}
+ {game.name}
+ {game.desc}
+
+ +{game.stat}
+
+
+ ))}
+
+
+
+ );
+ }
+
+ // === RESULT PHASE ===
+ if (phase === 'result' && result) {
+ return (
+
+
+
+ );
+ }
+
+ // === PLAYING PHASE ===
+ const GameComponent = {
+ reflex_tap: ReflexTap,
+ focus_hold: FocusHold,
+ temptation_choice: TemptationChoice,
+ timing_tap: TimingTap,
+ }[activeGame];
+
+ if (!GameComponent) {
+ setPhase('select');
+ return null;
+ }
+
+ return (
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ // Select
+ selectContainer: {
+ flex: 1,
+ paddingHorizontal: spacing.lg,
+ paddingTop: spacing.xl,
+ },
+ selectTitle: {
+ color: colors.text,
+ fontSize: fonts.sizes.xxl,
+ fontWeight: fonts.weights.bold,
+ marginBottom: spacing.xs,
+ },
+ selectSubtitle: {
+ color: colors.textSecondary,
+ fontSize: fonts.sizes.md,
+ marginBottom: spacing.xl,
+ },
+ gameGrid: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ justifyContent: 'space-between',
+ gap: spacing.md,
+ paddingBottom: spacing.xxl,
+ },
+ gameCard: {
+ width: '47%',
+ backgroundColor: colors.surface,
+ borderRadius: borderRadius.lg,
+ padding: spacing.lg,
+ borderWidth: 1,
+ borderColor: colors.border,
+ alignItems: 'center',
+ },
+ gameCardPressed: {
+ opacity: 0.7,
+ transform: [{ scale: 0.97 }],
+ },
+ gameIcon: {
+ fontSize: 32,
+ marginBottom: spacing.sm,
+ },
+ gameName: {
+ color: colors.text,
+ fontSize: fonts.sizes.md,
+ fontWeight: fonts.weights.semibold,
+ marginBottom: spacing.xs,
+ },
+ gameDesc: {
+ color: colors.textSecondary,
+ fontSize: fonts.sizes.xs,
+ textAlign: 'center',
+ marginBottom: spacing.md,
+ },
+ statBadge: {
+ borderWidth: 1,
+ borderRadius: borderRadius.full,
+ paddingHorizontal: spacing.sm,
+ paddingVertical: 2,
+ },
+ statBadgeText: {
+ fontSize: fonts.sizes.xs,
+ fontWeight: fonts.weights.semibold,
+ },
+
+ // Playing
+ playContainer: {
+ flex: 1,
+ paddingHorizontal: spacing.lg,
+ paddingTop: spacing.lg,
+ },
+});
diff --git a/src/screens/MirrorScreen.js b/src/screens/MirrorScreen.js
new file mode 100644
index 0000000..560d5a4
--- /dev/null
+++ b/src/screens/MirrorScreen.js
@@ -0,0 +1,137 @@
+import React, { useState, useEffect, useRef } from 'react';
+import { View, Text, StyleSheet, ScrollView, Animated } from 'react-native';
+import ScreenWrapper from '../components/ScreenWrapper';
+import useIdentityStore from '../store/useIdentityStore';
+import { colors, fonts, spacing, borderRadius } from '../utils/theme';
+import { formatDate } from '../utils/helpers';
+
+function DayCard({ label, day, log, color }) {
+ return (
+
+ {label}
+ Day {day}
+ {log ? (
+ <>
+
+ Identity Check:
+ {log.identity_check.toUpperCase()}
+
+ {log.note ? (
+
+ Reflection:
+ {log.note}
+
+ ) : (
+ No reflection recorded
+ )}
+ >
+ ) : (
+ No data logged for this day
+ )}
+
+ );
+}
+
+export default function MirrorScreen() {
+ const identity = useIdentityStore((s) => s.identity);
+ const currentDay = useIdentityStore((s) => s.currentDay);
+ const loadDailyLogs = useIdentityStore((s) => s.loadDailyLogs);
+ const [logs, setLogs] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const fadeAnim = useRef(new Animated.Value(0)).current;
+
+ useEffect(() => {
+ const load = async () => {
+ const data = await loadDailyLogs();
+ setLogs(data);
+ setLoading(false);
+ Animated.timing(fadeAnim, { toValue: 1, duration: 500, useNativeDriver: true }).start();
+ };
+ load();
+ }, []);
+
+ const day1Log = logs.find((l) => l.day_number === 1);
+ const todayLog = logs.find((l) => l.day_number === currentDay);
+
+ if (loading) {
+ return (
+
+ Loading your mirror...
+
+ );
+ }
+
+ return (
+
+
+ The Mirror
+ See how far you've come
+
+ {identity && (
+
+ YOUR IDENTITY
+ {identity.title}
+ Started {formatDate(identity.start_date)}
+
+ )}
+
+
+
+ ↓
+ {currentDay - 1} days of growth
+
+
+
+
+ Journey Insights
+
+ Days Completed
+ {logs.length} / {currentDay}
+
+
+ Completion Rate
+ {currentDay > 0 ? Math.round((logs.length / currentDay) * 100) : 0}%
+
+
+ Identity Alignment
+ {logs.filter((l) => l.identity_check === 'yes').length} days fully aligned
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ scroll: { flex: 1 },
+ scrollContent: { padding: spacing.lg, paddingBottom: spacing.xxl },
+ loadingContainer: { flex: 1, alignItems: 'center', justifyContent: 'center' },
+ loadingText: { color: colors.textSecondary, fontSize: fonts.sizes.md },
+ title: { color: colors.text, fontSize: fonts.sizes.xxl, fontWeight: fonts.weights.bold, marginBottom: spacing.xs },
+ subtitle: { color: colors.textSecondary, fontSize: fonts.sizes.md, marginBottom: spacing.xl },
+ identityCard: { backgroundColor: colors.surface, borderRadius: borderRadius.md, padding: spacing.lg, marginBottom: spacing.xl, borderWidth: 1, borderColor: colors.primary, alignItems: 'center' },
+ identityLabel: { color: colors.primary, fontSize: fonts.sizes.xs, fontWeight: fonts.weights.bold, letterSpacing: 2, marginBottom: spacing.sm },
+ identityTitle: { color: colors.text, fontSize: fonts.sizes.lg, fontWeight: fonts.weights.semibold, textAlign: 'center', marginBottom: spacing.xs },
+ identityDate: { color: colors.textMuted, fontSize: fonts.sizes.sm },
+ dayCard: { backgroundColor: colors.surface, borderRadius: borderRadius.md, padding: spacing.lg, borderWidth: 1 },
+ dayCardLabel: { fontSize: fonts.sizes.xs, fontWeight: fonts.weights.bold, letterSpacing: 2, marginBottom: spacing.sm },
+ dayCardDay: { color: colors.text, fontSize: fonts.sizes.lg, fontWeight: fonts.weights.bold, marginBottom: spacing.md },
+ checkRow: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: spacing.sm },
+ checkLabel: { color: colors.textSecondary, fontSize: fonts.sizes.sm },
+ checkValue: { fontSize: fonts.sizes.sm, fontWeight: fonts.weights.bold },
+ noteBox: { backgroundColor: colors.surfaceLight, borderRadius: borderRadius.sm, padding: spacing.md, marginTop: spacing.sm },
+ noteLabel: { color: colors.textSecondary, fontSize: fonts.sizes.xs, marginBottom: spacing.xs },
+ noteText: { color: colors.text, fontSize: fonts.sizes.sm, lineHeight: 20 },
+ noNote: { color: colors.textMuted, fontSize: fonts.sizes.sm, fontStyle: 'italic' },
+ noData: { color: colors.textMuted, fontSize: fonts.sizes.sm, fontStyle: 'italic' },
+ arrow: { alignItems: 'center', paddingVertical: spacing.md },
+ arrowText: { color: colors.primary, fontSize: 28 },
+ arrowLabel: { color: colors.textSecondary, fontSize: fonts.sizes.sm, marginTop: spacing.xs },
+ insightCard: { backgroundColor: colors.surface, borderRadius: borderRadius.md, padding: spacing.lg, marginTop: spacing.xl, borderWidth: 1, borderColor: colors.border },
+ insightTitle: { color: colors.text, fontSize: fonts.sizes.md, fontWeight: fonts.weights.semibold, marginBottom: spacing.md },
+ insightRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: spacing.sm, borderBottomWidth: 1, borderBottomColor: colors.border },
+ insightLabel: { color: colors.textSecondary, fontSize: fonts.sizes.sm },
+ insightValue: { color: colors.text, fontSize: fonts.sizes.sm, fontWeight: fonts.weights.medium },
+});
diff --git a/src/screens/OnboardingScreen.js b/src/screens/OnboardingScreen.js
new file mode 100644
index 0000000..1d079f9
--- /dev/null
+++ b/src/screens/OnboardingScreen.js
@@ -0,0 +1,372 @@
+import React, { useRef, useState, useEffect, useCallback } from 'react';
+import {
+ View, Text, Image, TextInput, StyleSheet, FlatList,
+ Dimensions, TouchableOpacity, Animated,
+ KeyboardAvoidingView, Platform, Keyboard,
+} from 'react-native';
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import StarField from '../components/StarField';
+import Button from '../components/Button';
+import useAppStore from '../store/useAppStore';
+import useAuthStore from '../store/useAuthStore';
+import { colors, fonts, spacing, borderRadius } from '../utils/theme';
+
+const { width } = Dimensions.get('window');
+const ONBOARDING_KEY = 'onboarding_done';
+
+const pages = [
+ {
+ id: '1',
+ title: 'Identity First',
+ text: 'Every transformation begins with identity.\nNot goals. Not habits. Identity.',
+ iconSize: 90,
+ glowIntensity: 0.3,
+ showOrbit: false,
+ },
+ {
+ id: '2',
+ title: '40 Days',
+ text: "In 40 days, you won't just change habits\n— you'll change who you are.",
+ iconSize: 110,
+ glowIntensity: 0.65,
+ showOrbit: false,
+ },
+ {
+ id: '3',
+ title: 'Your Nova Awaits',
+ text: 'Small actions. Massive transformation.',
+ iconSize: 110,
+ glowIntensity: 1,
+ showOrbit: true,
+ isLast: true,
+ },
+];
+
+function PageContent({ item, isActive }) {
+ const fadeAnim = useRef(new Animated.Value(0)).current;
+ const slideAnim = useRef(new Animated.Value(30)).current;
+ const pulse = useRef(new Animated.Value(1)).current;
+
+ useEffect(() => {
+ if (isActive) {
+ fadeAnim.setValue(0);
+ slideAnim.setValue(30);
+ Animated.parallel([
+ Animated.timing(fadeAnim, { toValue: 1, duration: 500, delay: 150, useNativeDriver: true }),
+ Animated.timing(slideAnim, { toValue: 0, duration: 500, delay: 150, useNativeDriver: true }),
+ ]).start();
+ }
+ }, [isActive]);
+
+ useEffect(() => {
+ const anim = Animated.loop(
+ Animated.sequence([
+ Animated.timing(pulse, { toValue: 1.12, duration: 2200, useNativeDriver: true }),
+ Animated.timing(pulse, { toValue: 1, duration: 2200, useNativeDriver: true }),
+ ])
+ );
+ anim.start();
+ return () => anim.stop();
+ }, []);
+
+ return (
+
+ {/* Icon area */}
+
+
+ {item.showOrbit && (
+
+ )}
+
+
+
+ {/* Text */}
+
+ {item.title}
+ {item.text}
+
+
+ );
+}
+
+export default function OnboardingScreen({ navigation }) {
+ const flatListRef = useRef(null);
+ const [currentIndex, setCurrentIndex] = useState(0);
+ const [identityInput, setIdentityInput] = useState('');
+ const [keyboardVisible, setKeyboardVisible] = useState(false);
+ const setInitialIdentity = useAppStore((s) => s.setInitialIdentity);
+ const buttonFade = useRef(new Animated.Value(0)).current;
+ const inputFade = useRef(new Animated.Value(0)).current;
+
+ const isLastPage = currentIndex === pages.length - 1;
+
+ useEffect(() => {
+ const showSub = Keyboard.addListener('keyboardWillShow', () => setKeyboardVisible(true));
+ const hideSub = Keyboard.addListener('keyboardWillHide', () => setKeyboardVisible(false));
+ // Android uses keyboardDidShow/keyboardDidHide
+ const showSubAnd = Keyboard.addListener('keyboardDidShow', () => setKeyboardVisible(true));
+ const hideSubAnd = Keyboard.addListener('keyboardDidHide', () => setKeyboardVisible(false));
+ return () => { showSub.remove(); hideSub.remove(); showSubAnd.remove(); hideSubAnd.remove(); };
+ }, []);
+
+ useEffect(() => {
+ if (isLastPage) {
+ Animated.stagger(200, [
+ Animated.timing(inputFade, { toValue: 1, duration: 400, useNativeDriver: true }),
+ Animated.timing(buttonFade, { toValue: 1, duration: 400, useNativeDriver: true }),
+ ]).start();
+ } else {
+ buttonFade.setValue(0);
+ inputFade.setValue(0);
+ }
+ }, [isLastPage]);
+
+ const completeOnboarding = useCallback(async () => {
+ await AsyncStorage.setItem(ONBOARDING_KEY, 'true');
+ if (identityInput.trim()) {
+ setInitialIdentity(identityInput.trim());
+ }
+
+ // If user is already logged in (came from Register), go to story input
+ // Otherwise go to Login
+ const session = useAuthStore.getState().session;
+ if (session) {
+ navigation.reset({ index: 0, routes: [{ name: 'IdentityStory' }] });
+ } else {
+ navigation.reset({ index: 0, routes: [{ name: 'Login' }] });
+ }
+ }, [identityInput, navigation, setInitialIdentity]);
+
+ const onViewableItemsChanged = useRef(({ viewableItems }) => {
+ if (viewableItems.length > 0) {
+ setCurrentIndex(viewableItems[0].index ?? 0);
+ }
+ }).current;
+
+ return (
+
+
+
+ {/* Skip button — hidden on last page */}
+ {!isLastPage && (
+
+ Skip
+
+ )}
+
+ {/* Hide pager when keyboard is visible on last page to make room */}
+ {!(keyboardVisible && isLastPage) && (
+ item.id}
+ onViewableItemsChanged={onViewableItemsChanged}
+ viewabilityConfig={{ viewAreaCoveragePercentThreshold: 50 }}
+ renderItem={({ item, index }) => (
+
+ )}
+ />
+ )}
+
+ {/* Bottom section: input + button + dots */}
+
+
+ {/* Show title when keyboard hides the pager */}
+ {keyboardVisible && isLastPage && (
+ Who do you want to become?
+ )}
+
+ {/* Identity input on last page */}
+ {isLastPage && (
+
+ {!keyboardVisible && (
+ Who do you want to become?
+ )}
+ Keyboard.dismiss()}
+ />
+
+ )}
+
+ {/* CTA button on last page */}
+ {isLastPage && (
+
+
+
+ )}
+
+ {/* Dots */}
+ {!keyboardVisible && (
+
+ {pages.map((_, i) => (
+
+ ))}
+
+ )}
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: colors.background,
+ },
+ skip: {
+ position: 'absolute',
+ top: 58,
+ right: 24,
+ zIndex: 10,
+ paddingVertical: spacing.xs,
+ paddingHorizontal: spacing.sm,
+ },
+ skipText: {
+ color: colors.textSecondary,
+ fontSize: fonts.sizes.md,
+ fontWeight: fonts.weights.medium,
+ },
+ page: {
+ width,
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingHorizontal: spacing.xl,
+ },
+ iconArea: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ width: 300,
+ height: 300,
+ marginBottom: spacing.xl,
+ },
+ glow: {
+ backgroundColor: colors.planetGlow,
+ position: 'absolute',
+ },
+ orbit: {
+ borderWidth: 1.5,
+ borderColor: colors.orbitLine,
+ borderStyle: 'dashed',
+ position: 'absolute',
+ },
+ pageTitle: {
+ color: colors.accent,
+ fontSize: fonts.sizes.xs,
+ fontWeight: fonts.weights.bold,
+ letterSpacing: 3,
+ textAlign: 'center',
+ textTransform: 'uppercase',
+ marginBottom: spacing.md,
+ },
+ pageText: {
+ color: colors.text,
+ fontSize: fonts.sizes.xl,
+ fontWeight: fonts.weights.semibold,
+ textAlign: 'center',
+ lineHeight: 34,
+ },
+ bottomKAV: {
+ position: 'absolute',
+ bottom: 0,
+ left: 0,
+ right: 0,
+ },
+ bottom: {
+ paddingHorizontal: spacing.lg,
+ paddingBottom: spacing.xl,
+ paddingTop: spacing.md,
+ },
+ keyboardTitle: {
+ color: colors.text,
+ fontSize: fonts.sizes.lg,
+ fontWeight: fonts.weights.semibold,
+ textAlign: 'center',
+ marginBottom: spacing.md,
+ },
+ inputWrapper: {
+ marginBottom: spacing.lg,
+ },
+ inputLabel: {
+ color: colors.textSecondary,
+ fontSize: fonts.sizes.sm,
+ fontWeight: fonts.weights.medium,
+ marginBottom: spacing.sm,
+ textAlign: 'center',
+ },
+ input: {
+ backgroundColor: colors.surface,
+ borderRadius: borderRadius.md,
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.md,
+ color: colors.text,
+ fontSize: fonts.sizes.md,
+ borderWidth: 1,
+ borderColor: colors.border,
+ textAlign: 'center',
+ },
+ ctaWrapper: {
+ marginBottom: spacing.md,
+ },
+ indicators: {
+ flexDirection: 'row',
+ justifyContent: 'center',
+ gap: spacing.sm,
+ paddingBottom: spacing.sm,
+ },
+ dot: {
+ width: 8,
+ height: 8,
+ borderRadius: 4,
+ backgroundColor: colors.textMuted,
+ },
+ dotActive: {
+ backgroundColor: colors.primary,
+ width: 24,
+ },
+});
diff --git a/src/screens/ProfileScreen.js b/src/screens/ProfileScreen.js
new file mode 100644
index 0000000..3729d2e
--- /dev/null
+++ b/src/screens/ProfileScreen.js
@@ -0,0 +1,135 @@
+import React, { useRef, useEffect } from 'react';
+import { View, Text, StyleSheet, Animated } from 'react-native';
+import { CommonActions } from '@react-navigation/native';
+import { showAlert } from '../components/NovaAlert';
+import ScreenWrapper from '../components/ScreenWrapper';
+import Button from '../components/Button';
+import useAuthStore from '../store/useAuthStore';
+import useIdentityStore from '../store/useIdentityStore';
+import useHabitStore from '../store/useHabitStore';
+import { colors, fonts, spacing, borderRadius } from '../utils/theme';
+import { formatDate } from '../utils/helpers';
+
+export default function ProfileScreen({ navigation }) {
+ const user = useAuthStore((s) => s.user);
+ const identity = useIdentityStore((s) => s.identity);
+ const currentDay = useIdentityStore((s) => s.currentDay);
+ const logout = useAuthStore((s) => s.logout);
+ const resetIdentity = useIdentityStore((s) => s.reset);
+ const resetHabits = useHabitStore((s) => s.reset);
+ const fadeAnim = useRef(new Animated.Value(0)).current;
+
+ useEffect(() => {
+ Animated.timing(fadeAnim, { toValue: 1, duration: 500, useNativeDriver: true }).start();
+ }, []);
+
+ const handleSignOut = () => {
+ showAlert('Sign Out', 'Are you sure you want to sign out?', [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Sign Out',
+ style: 'destructive',
+ onPress: async () => {
+ resetIdentity();
+ resetHabits();
+ await logout();
+ navigation.dispatch(
+ CommonActions.reset({ index: 0, routes: [{ name: 'Login' }] })
+ );
+ },
+ },
+ ]);
+ };
+
+ return (
+
+
+
+ {user?.email?.[0]?.toUpperCase() || 'N'}
+
+ {user?.email}
+
+ {identity && (
+
+ ACTIVE IDENTITY
+ {identity.title}
+ Day {currentDay}/40 • Started {formatDate(identity.start_date)}
+
+ )}
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ paddingHorizontal: spacing.lg,
+ paddingTop: spacing.xxl,
+ },
+ avatar: {
+ width: 80,
+ height: 80,
+ borderRadius: 40,
+ backgroundColor: colors.primary,
+ alignItems: 'center',
+ justifyContent: 'center',
+ alignSelf: 'center',
+ marginBottom: spacing.md,
+ shadowColor: colors.primary,
+ shadowOffset: { width: 0, height: 0 },
+ shadowOpacity: 0.5,
+ shadowRadius: 15,
+ elevation: 8,
+ },
+ avatarText: {
+ color: colors.text,
+ fontSize: fonts.sizes.xxl,
+ fontWeight: fonts.weights.bold,
+ },
+ email: {
+ color: colors.textSecondary,
+ fontSize: fonts.sizes.md,
+ marginBottom: spacing.xl,
+ textAlign: 'center',
+ },
+ card: {
+ backgroundColor: colors.surface,
+ borderRadius: borderRadius.md,
+ padding: spacing.lg,
+ borderWidth: 1,
+ borderColor: colors.border,
+ marginBottom: spacing.xl,
+ },
+ cardLabel: {
+ color: colors.primary,
+ fontSize: fonts.sizes.xs,
+ fontWeight: fonts.weights.bold,
+ letterSpacing: 2,
+ marginBottom: spacing.sm,
+ },
+ cardTitle: {
+ color: colors.text,
+ fontSize: fonts.sizes.lg,
+ fontWeight: fonts.weights.semibold,
+ marginBottom: spacing.xs,
+ },
+ cardMeta: {
+ color: colors.textMuted,
+ fontSize: fonts.sizes.sm,
+ },
+ actions: {},
+ actionBtn: {
+ marginBottom: spacing.sm,
+ },
+ signOutBtn: {
+ borderColor: colors.error,
+ marginTop: spacing.md,
+ },
+});
diff --git a/src/screens/RegisterScreen.js b/src/screens/RegisterScreen.js
new file mode 100644
index 0000000..9dabf43
--- /dev/null
+++ b/src/screens/RegisterScreen.js
@@ -0,0 +1,152 @@
+import React, { useState } from 'react';
+import { View, Text, StyleSheet, TouchableOpacity, KeyboardAvoidingView, Platform } from 'react-native';
+import { showAlert } from '../components/NovaAlert';
+import ScreenWrapper from '../components/ScreenWrapper';
+import Input from '../components/Input';
+import Button from '../components/Button';
+import useAuthStore from '../store/useAuthStore';
+import { colors, fonts, spacing } from '../utils/theme';
+
+export default function RegisterScreen({ navigation }) {
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [confirmPassword, setConfirmPassword] = useState('');
+ const [loading, setLoading] = useState(false);
+ const register = useAuthStore((s) => s.register);
+ const setSession = useAuthStore((s) => s.setSession);
+
+ const goToOnboarding = () => {
+ navigation.reset({ index: 0, routes: [{ name: 'Onboarding' }] });
+ };
+
+ const handleRegister = async () => {
+ if (!email.trim() || !password.trim()) {
+ showAlert('Error', 'Please fill in all fields');
+ return;
+ }
+
+ if (password !== confirmPassword) {
+ showAlert('Error', 'Passwords do not match');
+ return;
+ }
+
+ if (password.length < 6) {
+ showAlert('Error', 'Password must be at least 6 characters');
+ return;
+ }
+
+ setLoading(true);
+ try {
+ const data = await register(email.trim(), password);
+ // If registration returned a session (offline mode or auto-confirm), log in directly
+ if (data?.session) {
+ setSession(data.session);
+ goToOnboarding();
+ } else {
+ showAlert(
+ 'Account Created',
+ 'Check your email to confirm your account, then sign in.',
+ [{ text: 'OK', onPress: () => navigation.navigate('Login') }]
+ );
+ }
+ } catch (error) {
+ showAlert('Registration Failed', error.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ Begin Your Journey
+ Create your Nova40 identity
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Already have an account?
+ navigation.goBack()}>
+ Sign In
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ justifyContent: 'center',
+ paddingHorizontal: spacing.lg,
+ },
+ header: {
+ marginBottom: spacing.xxl,
+ },
+ title: {
+ color: colors.text,
+ fontSize: fonts.sizes.xxl,
+ fontWeight: fonts.weights.bold,
+ marginBottom: spacing.xs,
+ },
+ subtitle: {
+ color: colors.textSecondary,
+ fontSize: fonts.sizes.md,
+ },
+ form: {
+ marginBottom: spacing.xl,
+ },
+ button: {
+ marginTop: spacing.md,
+ },
+ footer: {
+ flexDirection: 'row',
+ justifyContent: 'center',
+ },
+ footerText: {
+ color: colors.textSecondary,
+ fontSize: fonts.sizes.sm,
+ },
+ footerLink: {
+ color: colors.primary,
+ fontSize: fonts.sizes.sm,
+ fontWeight: fonts.weights.semibold,
+ },
+});
diff --git a/src/screens/SplashScreen.js b/src/screens/SplashScreen.js
new file mode 100644
index 0000000..1a78c25
--- /dev/null
+++ b/src/screens/SplashScreen.js
@@ -0,0 +1,114 @@
+import React, { useEffect, useRef } from 'react';
+import { View, Text, Image, StyleSheet, Animated, Easing } from 'react-native';
+import StarField from '../components/StarField';
+import useAuthStore from '../store/useAuthStore';
+import useAppStore from '../store/useAppStore';
+import { colors, fonts } from '../utils/theme';
+
+const MIN_SPLASH_MS = 5000;
+
+export default function SplashScreen({ navigation }) {
+ const logoScale = useRef(new Animated.Value(0.5)).current;
+ const logoOpacity = useRef(new Animated.Value(1)).current;
+ const textOpacity = useRef(new Animated.Value(1)).current;
+ const glowScale = useRef(new Animated.Value(0.8)).current;
+
+ useEffect(() => {
+ // Start animations
+ Animated.parallel([
+ Animated.timing(logoScale, {
+ toValue: 1, duration: 2000, easing: Easing.out(Easing.cubic), useNativeDriver: true,
+ }),
+ Animated.loop(
+ Animated.sequence([
+ Animated.timing(glowScale, { toValue: 1.2, duration: 1500, useNativeDriver: true }),
+ Animated.timing(glowScale, { toValue: 0.9, duration: 1500, useNativeDriver: true }),
+ ])
+ ),
+ ]).start();
+
+ // Run logic in parallel with animation
+ const resolve = async () => {
+ try {
+ const start = Date.now();
+
+ // Check onboarding + session in parallel
+ await Promise.all([
+ useAppStore.getState().initApp(),
+ useAuthStore.getState().fetchSession(),
+ ]);
+
+ const onboardingDone = useAppStore.getState().onboardingDone;
+ const session = useAuthStore.getState().session;
+
+ // Wait for minimum splash duration
+ const elapsed = Date.now() - start;
+ if (elapsed < MIN_SPLASH_MS) {
+ await new Promise((r) => setTimeout(r, MIN_SPLASH_MS - elapsed));
+ }
+
+ // Route decision
+ if (!onboardingDone) {
+ navigation.reset({ index: 0, routes: [{ name: 'Onboarding' }] });
+ } else if (!session) {
+ navigation.reset({ index: 0, routes: [{ name: 'Login' }] });
+ } else {
+ navigation.reset({ index: 0, routes: [{ name: 'MainTabs' }] });
+ }
+ } catch (e) {
+ console.warn('Splash resolve error:', e);
+ // Fallback: go to Login
+ navigation.reset({ index: 0, routes: [{ name: 'Login' }] });
+ }
+ };
+
+ resolve();
+ }, []);
+
+ return (
+
+
+
+
+
+
+
+
+
+ NOVA40
+
+
+ You are becoming someone new
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: { flex: 1, backgroundColor: colors.background },
+ content: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 40 },
+ logoContainer: { alignItems: 'center', justifyContent: 'center', marginBottom: 40 },
+ glow: {
+ width: 200, height: 200, borderRadius: 100,
+ backgroundColor: colors.planetGlow, opacity: 0.4, position: 'absolute',
+ },
+ logo: { width: 140, height: 140 },
+ title: {
+ color: colors.text, fontSize: fonts.sizes.hero,
+ fontWeight: fonts.weights.bold, letterSpacing: 8, marginBottom: 16,
+ },
+ subtitle: {
+ color: colors.textSecondary, fontSize: fonts.sizes.md,
+ textAlign: 'center', fontStyle: 'italic',
+ },
+});
diff --git a/src/screens/StatsScreen.js b/src/screens/StatsScreen.js
new file mode 100644
index 0000000..b7c3239
--- /dev/null
+++ b/src/screens/StatsScreen.js
@@ -0,0 +1,154 @@
+import React, { useState, useRef, useCallback } from 'react';
+import { View, Text, StyleSheet, Animated } from 'react-native';
+import { useFocusEffect } from '@react-navigation/native';
+import ScreenWrapper from '../components/ScreenWrapper';
+import useIdentityStore from '../store/useIdentityStore';
+import { colors, fonts, spacing, borderRadius } from '../utils/theme';
+import { getDayPhase } from '../utils/helpers';
+
+function StatCard({ label, value, maxValue, color }) {
+ const percentage = maxValue > 0 ? Math.min((value / maxValue) * 100, 100) : 0;
+ return (
+
+
+ {label}
+ {value}
+
+
+
+
+
+ );
+}
+
+export default function StatsScreen() {
+ const currentDay = useIdentityStore((s) => s.currentDay);
+ const loadStats = useIdentityStore((s) => s.loadStats);
+ const loadDailyLogs = useIdentityStore((s) => s.loadDailyLogs);
+ const [stats, setStats] = useState(null);
+ const [dailyLogs, setDailyLogs] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const fadeAnim = useRef(new Animated.Value(0)).current;
+
+ useFocusEffect(
+ useCallback(() => {
+ let active = true;
+ const load = async () => {
+ setLoading(true);
+ const [s, logs] = await Promise.all([loadStats(), loadDailyLogs()]);
+ if (active) {
+ setStats(s);
+ setDailyLogs(logs);
+ setLoading(false);
+ Animated.timing(fadeAnim, { toValue: 1, duration: 500, useNativeDriver: true }).start();
+ }
+ };
+ load();
+ return () => { active = false; };
+ }, [])
+ );
+
+ if (loading) {
+ return (
+
+
+ Loading stats...
+
+
+ );
+ }
+
+ const maxScore = currentDay * 3;
+ const yesCount = dailyLogs.filter((l) => l.identity_check === 'yes').length;
+ const almostCount = dailyLogs.filter((l) => l.identity_check === 'almost').length;
+ const noCount = dailyLogs.filter((l) => l.identity_check === 'no').length;
+ const totalScore = (stats?.discipline_score || 0) + (stats?.focus_score || 0) + (stats?.consistency_score || 0);
+
+ const sorted = [...dailyLogs].sort((a, b) => b.day_number - a.day_number);
+ let streakDays = 0;
+ for (const log of sorted) {
+ if (log.identity_check === 'yes' || log.identity_check === 'almost') streakDays++;
+ else break;
+ }
+
+ return (
+
+
+ Your Journey
+
+ {getDayPhase(currentDay).toUpperCase()} PHASE — Day {currentDay}/40
+
+
+
+
+
+
+ {/* Total */}
+
+ Total Score
+ {totalScore}
+
+
+ {/* Identity Check Summary */}
+
+ Identity Check Summary
+
+
+ {yesCount}
+ Yes
+
+
+ {almostCount}
+ Almost
+
+
+ {noCount}
+ No
+
+
+
+
+ {/* Streak */}
+
+ {streakDays}
+ Day Streak
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ scroll: { flex: 1 },
+ scrollContent: { padding: spacing.lg, paddingBottom: spacing.xxl },
+ loadingContainer: { flex: 1, alignItems: 'center', justifyContent: 'center' },
+ loadingText: { color: colors.textSecondary, fontSize: fonts.sizes.md },
+ title: { color: colors.text, fontSize: fonts.sizes.xxl, fontWeight: fonts.weights.bold, marginBottom: spacing.xs },
+ subtitle: { color: colors.accent, fontSize: fonts.sizes.xs, fontWeight: fonts.weights.bold, letterSpacing: 2, marginBottom: spacing.xl },
+
+ statCard: { backgroundColor: colors.surface, borderRadius: borderRadius.md, padding: spacing.md, marginBottom: spacing.md, borderWidth: 1, borderColor: colors.border },
+ statHeader: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: spacing.sm },
+ statLabel: { color: colors.textSecondary, fontSize: fonts.sizes.md, fontWeight: fonts.weights.medium },
+ statValue: { fontSize: fonts.sizes.lg, fontWeight: fonts.weights.bold },
+ barBg: { height: 6, backgroundColor: colors.surfaceLight, borderRadius: 3, overflow: 'hidden' },
+ barFill: { height: '100%', borderRadius: 3 },
+
+ totalCard: { backgroundColor: colors.surface, borderRadius: borderRadius.md, padding: spacing.lg, marginBottom: spacing.md, borderWidth: 1, borderColor: colors.accent, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
+ totalLabel: { color: colors.textSecondary, fontSize: fonts.sizes.md, fontWeight: fonts.weights.medium },
+ totalValue: { color: colors.accent, fontSize: fonts.sizes.xxl, fontWeight: fonts.weights.bold },
+
+ summaryCard: { backgroundColor: colors.surface, borderRadius: borderRadius.md, padding: spacing.lg, marginBottom: spacing.md, borderWidth: 1, borderColor: colors.border },
+ summaryTitle: { color: colors.text, fontSize: fonts.sizes.md, fontWeight: fonts.weights.semibold, marginBottom: spacing.md },
+ summaryRow: { flexDirection: 'row', justifyContent: 'space-around' },
+ summaryItem: { alignItems: 'center' },
+ summaryValue: { fontSize: fonts.sizes.xxl, fontWeight: fonts.weights.bold },
+ summaryLabel: { color: colors.textSecondary, fontSize: fonts.sizes.sm, marginTop: spacing.xs },
+
+ streakCard: { backgroundColor: colors.surface, borderRadius: borderRadius.md, padding: spacing.xl, alignItems: 'center', borderWidth: 1, borderColor: colors.primary },
+ streakNumber: { color: colors.primary, fontSize: fonts.sizes.hero, fontWeight: fonts.weights.bold },
+ streakLabel: { color: colors.textSecondary, fontSize: fonts.sizes.md, marginTop: spacing.xs },
+});
diff --git a/src/services/aiService.js b/src/services/aiService.js
new file mode 100644
index 0000000..b5d9f73
--- /dev/null
+++ b/src/services/aiService.js
@@ -0,0 +1,206 @@
+import safeParseAI from '../utils/safeParseAI';
+import { getSuggestionsForIdentity } from '../utils/helpers';
+import { GEMINI_API_KEY } from '../config/keys';
+
+const GEMINI_URL =
+ 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent';
+
+function buildPrompt(story) {
+ return `You are a thoughtful and emotionally intelligent personal growth coach.
+
+A user has written a personal story about their current life and who they want to become.
+
+Your job is NOT just to generate habits.
+Your job is to deeply understand the person and reflect their intention back in a meaningful way.
+
+---
+
+INSTRUCTIONS:
+
+1. Read the user's story carefully.
+2. Understand:
+ - their struggles
+ - their desires
+ - their emotional tone
+
+3. Then generate:
+
+A. Identity Title
+- A short, powerful sentence
+- Feels personal and motivating
+- Example: "I am someone who shows up even when it's hard"
+
+B. Identity Summary
+- 2-3 sentences
+- Reflect their story in a human tone
+- Make it feel like someone truly understands them
+- Avoid generic phrases
+
+C. Suggested Habits (5-8 items)
+
+Rules:
+- Very simple and actionable
+- Can be done daily
+- No complexity
+- Each habit max 1 sentence
+- Feels aligned with their story (not random)
+
+IMPORTANT:
+- Habits should feel like "small proof of identity"
+- Not tasks, but expressions of who they want to become
+
+D. Tone Style:
+- Warm
+- Supportive
+- Slightly motivational
+- Never robotic
+- Never overly formal
+- Avoid buzzwords like "optimize", "maximize"
+
+---
+
+OUTPUT FORMAT (JSON ONLY):
+Return ONLY valid JSON. No explanation, no markdown, no extra text.
+
+{
+ "identity_title": "",
+ "identity_summary": "",
+ "suggested_habits": [
+ "",
+ "",
+ "",
+ "",
+ ""
+ ]
+}
+
+---
+
+EXAMPLE:
+
+User story: "I feel like I procrastinate a lot and I want to be more focused and disciplined."
+
+Bad output: "Improve productivity with structured routines"
+Good output: "I am someone who takes action even when I don't feel ready"
+
+Make the user feel understood, not analyzed.
+
+---
+
+User story:
+"""
+${story}
+"""`;
+}
+
+const MAX_RETRIES = 2;
+const RETRY_DELAY_MS = 4000;
+
+async function callGemini(story, attempt = 1) {
+ const response = await fetch(`${GEMINI_URL}?key=${GEMINI_API_KEY}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ contents: [{ parts: [{ text: buildPrompt(story) }] }],
+ generationConfig: {
+ temperature: 0.7,
+ topP: 0.9,
+ maxOutputTokens: 1024,
+ },
+ }),
+ });
+
+ // Rate limited — retry after delay
+ if (response.status === 429 && attempt <= MAX_RETRIES) {
+ console.warn(`Gemini rate limited, retrying in ${RETRY_DELAY_MS}ms (attempt ${attempt})...`);
+ await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
+ return callGemini(story, attempt + 1);
+ }
+
+ if (!response.ok) {
+ const errText = await response.text();
+ console.warn('Gemini API error:', response.status, errText);
+ throw new Error(`AI request failed (${response.status})`);
+ }
+
+ return response.json();
+}
+
+/**
+ * Generate identity + habits from user's personal story using Gemini AI.
+ * Retries on rate limit. Falls back to local generation if AI fails.
+ */
+export async function generateFromStory(story) {
+ try {
+ const json = await callGemini(story);
+ const rawText = json?.candidates?.[0]?.content?.parts?.[0]?.text || '';
+
+ const parsed = safeParseAI(rawText);
+
+ if (
+ parsed.identity_title &&
+ parsed.identity_summary &&
+ parsed.suggested_habits.length > 0
+ ) {
+ return {
+ ...parsed,
+ suggested_habits: parsed.suggested_habits.slice(0, 8),
+ source: 'ai',
+ };
+ }
+
+ throw new Error('Invalid AI response');
+ } catch (e) {
+ console.warn('AI generation failed, using fallback:', e.message);
+ return generateFallback(story);
+ }
+}
+
+/**
+ * Local fallback when AI is unavailable.
+ */
+function generateFallback(story) {
+ const words = story.toLowerCase();
+
+ // Extract identity title from story patterns
+ let title = 'A better version of myself';
+ const patterns = [
+ { match: /want to be(come)?\s+(.+?)[\.\,\!\n]/i, group: 2 },
+ { match: /i want to\s+(.+?)[\.\,\!\n]/i, group: 1 },
+ { match: /dream of\s+(.+?)[\.\,\!\n]/i, group: 1 },
+ { match: /goal is to\s+(.+?)[\.\,\!\n]/i, group: 1 },
+ { match: /i wish i (was|were|could)\s+(.+?)[\.\,\!\n]/i, group: 2 },
+ { match: /i need to\s+(.+?)[\.\,\!\n]/i, group: 1 },
+ { match: /i('m| am) tired of\s+(.+?)[\.\,\!\n]/i, group: 2 },
+ { match: /i struggle with\s+(.+?)[\.\,\!\n]/i, group: 1 },
+ ];
+
+ for (const p of patterns) {
+ const m = story.match(p.match);
+ if (m && m[p.group]) {
+ let extracted = m[p.group].trim();
+ // Convert negative patterns to positive identity
+ if (p.match.source.includes('tired of') || p.match.source.includes('struggle')) {
+ extracted = 'overcoming ' + extracted;
+ }
+ title = extracted.charAt(0).toUpperCase() + extracted.slice(1);
+ if (title.length > 50) title = title.slice(0, 50);
+ break;
+ }
+ }
+
+ // Generate varied habits from the story text
+ let habits = getSuggestionsForIdentity(words);
+ if (habits.length < 3) {
+ // Also try with the extracted title
+ const titleHabits = getSuggestionsForIdentity(title);
+ titleHabits.forEach((h) => { if (!habits.includes(h)) habits.push(h); });
+ }
+
+ return {
+ identity_title: title,
+ identity_summary: `Based on your story, this journey is about becoming ${title.toLowerCase()}. Every day is a step closer to the person you described.`,
+ suggested_habits: habits.slice(0, 8),
+ source: 'fallback',
+ };
+}
diff --git a/src/services/authService.js b/src/services/authService.js
new file mode 100644
index 0000000..e781757
--- /dev/null
+++ b/src/services/authService.js
@@ -0,0 +1,151 @@
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import { supabase } from './supabase';
+
+// ============================================
+// OFFLINE-FIRST AUTH (dummy mode)
+// Uses AsyncStorage when Supabase is unreachable.
+// Switch USE_OFFLINE to false for real Supabase auth.
+// ============================================
+const USE_OFFLINE = true;
+const USERS_KEY = 'nova40_users';
+const SESSION_KEY = 'nova40_session';
+
+// --- Offline helpers ---
+
+function generateUUID() {
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
+ const r = (Math.random() * 16) | 0;
+ const v = c === 'x' ? r : (r & 0x3) | 0x8;
+ return v.toString(16);
+ });
+}
+
+function makeUser(email) {
+ return {
+ id: generateUUID(),
+ email,
+ email_confirmed_at: new Date().toISOString(),
+ };
+}
+
+function makeSession(user) {
+ return {
+ access_token: 'offline_' + user.id,
+ user,
+ };
+}
+
+async function getStoredUsers() {
+ const raw = await AsyncStorage.getItem(USERS_KEY);
+ return raw ? JSON.parse(raw) : {};
+}
+
+async function saveUsers(users) {
+ await AsyncStorage.setItem(USERS_KEY, JSON.stringify(users));
+}
+
+async function saveSession(session) {
+ await AsyncStorage.setItem(SESSION_KEY, JSON.stringify(session));
+}
+
+async function clearSession() {
+ await AsyncStorage.removeItem(SESSION_KEY);
+}
+
+// --- Public API ---
+
+export async function signIn(email, password) {
+ if (!USE_OFFLINE) {
+ const { data, error } = await supabase.auth.signInWithPassword({ email, password });
+ if (error) throw error;
+ return data;
+ }
+
+ const users = await getStoredUsers();
+ const key = email.toLowerCase();
+ const stored = users[key];
+ if (!stored) throw new Error('No account found with this email. Please register first.');
+ if (stored.password !== password) throw new Error('Incorrect password.');
+
+ // Migrate old non-UUID IDs to proper UUIDs
+ const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
+ if (!uuidRegex.test(stored.id)) {
+ stored.id = generateUUID();
+ users[key] = stored;
+ await saveUsers(users);
+ }
+
+ const user = makeUser(email);
+ user.id = stored.id;
+ const session = makeSession(user);
+ await saveSession(session);
+ return { session, user };
+}
+
+export async function signUp(email, password) {
+ if (!USE_OFFLINE) {
+ const { data, error } = await supabase.auth.signUp({ email, password });
+ if (error) throw error;
+ return data;
+ }
+
+ const users = await getStoredUsers();
+ const key = email.toLowerCase();
+ if (users[key]) throw new Error('An account with this email already exists.');
+
+ const user = makeUser(email);
+ users[key] = { id: user.id, password };
+ await saveUsers(users);
+
+ // Auto-login (no email confirmation needed)
+ const session = makeSession(user);
+ await saveSession(session);
+ return { session, user };
+}
+
+export async function signOut() {
+ if (!USE_OFFLINE) {
+ const { error } = await supabase.auth.signOut();
+ if (error) throw error;
+ return;
+ }
+ await clearSession();
+}
+
+export async function getSession() {
+ if (!USE_OFFLINE) {
+ const { data: { session } } = await supabase.auth.getSession();
+ return session;
+ }
+
+ const raw = await AsyncStorage.getItem(SESSION_KEY);
+ return raw ? JSON.parse(raw) : null;
+}
+
+export async function signInAsDemo() {
+ return signIn('demo@nova40.app', '123456').catch(async () => {
+ // Auto-register demo user if not found
+ const data = await signUp('demo@nova40.app', '123456');
+ return data;
+ });
+}
+
+export async function signInWithGoogle() {
+ if (!USE_OFFLINE) {
+ const { data, error } = await supabase.auth.signInWithOAuth({
+ provider: 'google',
+ options: { redirectTo: 'nova40://auth/callback' },
+ });
+ if (error) throw error;
+ return data;
+ }
+ throw new Error('Google login is not available in offline mode.');
+}
+
+export function onAuthStateChange(callback) {
+ if (!USE_OFFLINE) {
+ return supabase.auth.onAuthStateChange(callback);
+ }
+ // Return a no-op subscription for offline mode
+ return { data: { subscription: { unsubscribe: () => {} } } };
+}
diff --git a/src/services/gameService.js b/src/services/gameService.js
new file mode 100644
index 0000000..c3da4ed
--- /dev/null
+++ b/src/services/gameService.js
@@ -0,0 +1,112 @@
+import { supabase } from './supabase';
+import * as offline from './offlineStorage';
+
+const USE_OFFLINE = true;
+
+// Score-to-stat mapping per game type
+const STAT_BONUSES = {
+ reflex_tap: { discipline: 1.0, focus: 0.3, consistency: 0.2 },
+ focus_hold: { discipline: 0.3, focus: 1.0, consistency: 0.3 },
+ temptation_choice: { discipline: 0.4, focus: 0.2, consistency: 1.0 },
+ timing_tap: { discipline: 0.5, focus: 0.8, consistency: 0.2 },
+};
+
+export async function saveGameSession(identityId, gameType, score) {
+ const bonuses = STAT_BONUSES[gameType] || { discipline: 0.5, focus: 0.5, consistency: 0.5 };
+
+ if (USE_OFFLINE) {
+ await offline.insert('game_sessions', {
+ identity_id: identityId,
+ game_type: gameType,
+ score,
+ });
+
+ const stats = await offline.getOne('stats', { identity_id: identityId });
+ if (stats) {
+ await offline.update('stats', { id: stats.id }, {
+ discipline_score: stats.discipline_score + Math.round(score * bonuses.discipline),
+ focus_score: stats.focus_score + Math.round(score * bonuses.focus),
+ consistency_score: stats.consistency_score + Math.round(score * bonuses.consistency),
+ });
+ }
+ return;
+ }
+
+ const { error } = await supabase.from('game_sessions').insert({
+ identity_id: identityId,
+ game_type: gameType,
+ score,
+ });
+ if (error) throw error;
+
+ const { data: stats } = await supabase
+ .from('stats')
+ .select('*')
+ .eq('identity_id', identityId)
+ .single();
+
+ if (stats) {
+ await supabase
+ .from('stats')
+ .update({
+ discipline_score: stats.discipline_score + Math.round(score * bonuses.discipline),
+ focus_score: stats.focus_score + Math.round(score * bonuses.focus),
+ consistency_score: stats.consistency_score + Math.round(score * bonuses.consistency),
+ })
+ .eq('id', stats.id);
+ }
+}
+
+export function getResultMessage(gameType, score) {
+ const messages = {
+ reflex_tap: [
+ { min: 0, text: 'Slow start. Sharpen your reflexes.' },
+ { min: 5, text: 'Getting faster. Keep pushing.' },
+ { min: 10, text: 'Quick hands. Your discipline is showing.' },
+ { min: 15, text: 'Lightning reflexes. You act without hesitation.' },
+ ],
+ focus_hold: [
+ { min: 0, text: 'Your focus wavers. Train your stillness.' },
+ { min: 2, text: 'Decent focus. Room to grow.' },
+ { min: 4, text: 'Strong focus. You hold your ground.' },
+ { min: 5, text: 'Unshakeable. Your mind is iron.' },
+ ],
+ temptation_choice: [
+ { min: 0, text: 'You gave in. Next time, choose differently.' },
+ { min: 3, text: 'Some good choices. Stay aware.' },
+ { min: 4, text: 'Strong willpower. You chose your identity.' },
+ { min: 5, text: 'Perfect clarity. Temptation has no power over you.' },
+ ],
+ timing_tap: [
+ { min: 0, text: 'Off-beat. Train your timing.' },
+ { min: 3, text: 'Getting in rhythm. Stay present.' },
+ { min: 6, text: 'Great timing. You know when to strike.' },
+ { min: 9, text: 'Perfect precision. You are in the zone.' },
+ ],
+ };
+
+ const list = messages[gameType] || messages.reflex_tap;
+ let result = list[0].text;
+ for (const entry of list) {
+ if (score >= entry.min) result = entry.text;
+ }
+ return result;
+}
+
+export function getStatLabel(gameType) {
+ const labels = {
+ reflex_tap: 'Discipline',
+ focus_hold: 'Focus',
+ temptation_choice: 'Consistency',
+ timing_tap: 'Focus',
+ };
+ return labels[gameType] || 'Score';
+}
+
+// Difficulty scaling based on current day
+export function getDifficulty(currentDay) {
+ if (currentDay <= 10) return 1;
+ if (currentDay <= 20) return 2;
+ if (currentDay <= 30) return 3;
+ return 4;
+}
diff --git a/src/services/habitService.js b/src/services/habitService.js
new file mode 100644
index 0000000..ec472a7
--- /dev/null
+++ b/src/services/habitService.js
@@ -0,0 +1,83 @@
+import { supabase } from './supabase';
+import * as offline from './offlineStorage';
+import { todayISO } from '../utils/date';
+
+// Must match authService.js
+const USE_OFFLINE = true;
+
+export async function getHabits(identityId) {
+ if (USE_OFFLINE) {
+ return offline.getAll('habits', { identity_id: identityId });
+ }
+
+ const { data, error } = await supabase
+ .from('habits')
+ .select('*')
+ .eq('identity_id', identityId);
+
+ if (error) throw error;
+ return data || [];
+}
+
+export async function getTodayHabitLogs(habitIds) {
+ if (!habitIds.length) return {};
+ const today = todayISO();
+
+ if (USE_OFFLINE) {
+ const allLogs = await offline.getAll('habit_logs');
+ const logs = {};
+ allLogs.forEach((log) => {
+ if (habitIds.includes(log.habit_id) && log.date === today) {
+ logs[log.habit_id] = log.completed;
+ }
+ });
+ return logs;
+ }
+
+ const { data, error } = await supabase
+ .from('habit_logs')
+ .select('*')
+ .in('habit_id', habitIds)
+ .eq('date', today);
+
+ if (error) throw error;
+
+ const logs = {};
+ (data || []).forEach((log) => {
+ logs[log.habit_id] = log.completed;
+ });
+ return logs;
+}
+
+export async function saveHabitLog(habitId, completed) {
+ const today = todayISO();
+
+ if (USE_OFFLINE) {
+ await offline.upsert(
+ 'habit_logs',
+ { habit_id: habitId, date: today },
+ { completed }
+ );
+ return;
+ }
+
+ const { data: existing } = await supabase
+ .from('habit_logs')
+ .select('id')
+ .eq('habit_id', habitId)
+ .eq('date', today)
+ .maybeSingle();
+
+ if (existing) {
+ const { error } = await supabase
+ .from('habit_logs')
+ .update({ completed })
+ .eq('id', existing.id);
+ if (error) throw error;
+ } else {
+ const { error } = await supabase
+ .from('habit_logs')
+ .insert({ habit_id: habitId, date: today, completed });
+ if (error) throw error;
+ }
+}
diff --git a/src/services/identityService.js b/src/services/identityService.js
new file mode 100644
index 0000000..1cf2aa0
--- /dev/null
+++ b/src/services/identityService.js
@@ -0,0 +1,242 @@
+import { supabase } from './supabase';
+import * as offline from './offlineStorage';
+import { generateHabitsFromIdentity } from '../utils/helpers';
+import { todayISO, addDays } from '../utils/date';
+
+// Must match authService.js
+const USE_OFFLINE = true;
+
+// ========================
+// GET IDENTITY
+// ========================
+export async function getIdentity(userId) {
+ if (USE_OFFLINE) {
+ return offline.getOne('identities', { user_id: userId });
+ }
+
+ const { data, error } = await supabase
+ .from('identities')
+ .select('*')
+ .eq('user_id', userId)
+ .order('created_at', { ascending: false })
+ .limit(1)
+ .single();
+
+ if (error && error.code !== 'PGRST116') throw error;
+ return data || null;
+}
+
+// ========================
+// CREATE IDENTITY
+// ========================
+export async function createIdentity(userId, title, description, customHabits, storyText) {
+ const startDate = todayISO();
+ const endDate = addDays(new Date(), 40);
+
+ if (USE_OFFLINE) {
+ const identity = await offline.insert('identities', {
+ user_id: userId,
+ title,
+ description: description || title,
+ story_text: storyText || '',
+ start_date: startDate,
+ end_date: endDate,
+ status: 'active',
+ });
+
+ const habitsList = customHabits && customHabits.length > 0
+ ? customHabits
+ : generateHabitsFromIdentity(title);
+
+ const habitsToInsert = habitsList.map((h) => ({
+ identity_id: identity.id,
+ title: h.title,
+ description: h.description || '',
+ }));
+
+ const habits = await offline.insertMany('habits', habitsToInsert);
+
+ await offline.insert('stats', {
+ identity_id: identity.id,
+ discipline_score: 0,
+ focus_score: 0,
+ consistency_score: 0,
+ });
+
+ return { identity, habits };
+ }
+
+ // --- Supabase path ---
+ const { data, error } = await supabase
+ .from('identities')
+ .insert({
+ user_id: userId,
+ title,
+ description: description || title,
+ story_text: storyText || '',
+ start_date: startDate,
+ end_date: endDate,
+ status: 'active',
+ })
+ .select()
+ .single();
+
+ if (error) throw error;
+
+ const habitsList = customHabits && customHabits.length > 0
+ ? customHabits
+ : generateHabitsFromIdentity(title);
+
+ const habitsToInsert = habitsList.map((h) => ({
+ identity_id: data.id,
+ title: h.title,
+ description: h.description || '',
+ }));
+
+ const { data: habits, error: habitsError } = await supabase
+ .from('habits')
+ .insert(habitsToInsert)
+ .select();
+
+ if (habitsError) throw habitsError;
+
+ await supabase.from('stats').insert({
+ identity_id: data.id,
+ discipline_score: 0,
+ focus_score: 0,
+ consistency_score: 0,
+ });
+
+ return { identity: data, habits: habits || [] };
+}
+
+// ========================
+// DAILY LOGS
+// ========================
+export async function getDailyLogs(identityId) {
+ if (USE_OFFLINE) {
+ const logs = await offline.getAll('daily_logs', { identity_id: identityId });
+ return logs.sort((a, b) => a.day_number - b.day_number);
+ }
+
+ const { data, error } = await supabase
+ .from('daily_logs')
+ .select('*')
+ .eq('identity_id', identityId)
+ .order('day_number', { ascending: true });
+
+ if (error) throw error;
+ return data || [];
+}
+
+export async function saveDailyLog(identityId, dayNumber, identityCheck, note) {
+ const today = todayISO();
+
+ if (USE_OFFLINE) {
+ await offline.upsert(
+ 'daily_logs',
+ { identity_id: identityId, date: today },
+ { day_number: dayNumber, identity_check: identityCheck, note: note || '' }
+ );
+
+ // Update stats
+ const scoreAdd = identityCheck === 'yes' ? 3 : identityCheck === 'almost' ? 1 : 0;
+ if (scoreAdd > 0) {
+ const stats = await offline.getOne('stats', { identity_id: identityId });
+ if (stats) {
+ await offline.update('stats', { id: stats.id }, {
+ discipline_score: stats.discipline_score + scoreAdd,
+ consistency_score: stats.consistency_score + (identityCheck === 'yes' ? 2 : 1),
+ focus_score: stats.focus_score + scoreAdd,
+ });
+ }
+ }
+ return;
+ }
+
+ // --- Supabase path ---
+ const { data: existing } = await supabase
+ .from('daily_logs')
+ .select('id')
+ .eq('identity_id', identityId)
+ .eq('date', today)
+ .maybeSingle();
+
+ if (existing) {
+ const { error } = await supabase
+ .from('daily_logs')
+ .update({ identity_check: identityCheck, note: note || '' })
+ .eq('id', existing.id);
+ if (error) throw error;
+ } else {
+ const { error } = await supabase
+ .from('daily_logs')
+ .insert({
+ identity_id: identityId,
+ date: today,
+ day_number: dayNumber,
+ identity_check: identityCheck,
+ note: note || '',
+ });
+ if (error) throw error;
+ }
+
+ const scoreAdd = identityCheck === 'yes' ? 3 : identityCheck === 'almost' ? 1 : 0;
+ if (scoreAdd > 0) {
+ const { data: stats } = await supabase
+ .from('stats')
+ .select('*')
+ .eq('identity_id', identityId)
+ .maybeSingle();
+
+ if (stats) {
+ await supabase
+ .from('stats')
+ .update({
+ discipline_score: stats.discipline_score + scoreAdd,
+ consistency_score: stats.consistency_score + (identityCheck === 'yes' ? 2 : 1),
+ focus_score: stats.focus_score + scoreAdd,
+ })
+ .eq('id', stats.id);
+ }
+ }
+}
+
+// ========================
+// STATS
+// ========================
+export async function getStats(identityId) {
+ if (USE_OFFLINE) {
+ return offline.getOne('stats', { identity_id: identityId });
+ }
+
+ const { data, error } = await supabase
+ .from('stats')
+ .select('*')
+ .eq('identity_id', identityId)
+ .single();
+
+ if (error && error.code !== 'PGRST116') throw error;
+ return data || null;
+}
+
+// ========================
+// GAME SESSIONS
+// ========================
+export async function saveGameScore(identityId, gameType, score) {
+ if (USE_OFFLINE) {
+ await offline.insert('game_sessions', {
+ identity_id: identityId,
+ game_type: gameType,
+ score,
+ });
+ return;
+ }
+
+ const { error } = await supabase.from('game_sessions').insert({
+ identity_id: identityId,
+ game_type: gameType,
+ score,
+ });
+ if (error) throw error;
+}
diff --git a/src/services/journalService.js b/src/services/journalService.js
new file mode 100644
index 0000000..2a8a09e
--- /dev/null
+++ b/src/services/journalService.js
@@ -0,0 +1,151 @@
+import * as offline from './offlineStorage';
+import safeParseJournal from '../utils/safeParseJournal';
+import { GEMINI_API_KEY } from '../config/keys';
+import { todayISO } from '../utils/date';
+
+const GEMINI_URL =
+ 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent';
+
+function buildPrompt({ mood, win, struggle, highlight, note }) {
+ return `You are a reflective and thoughtful journal companion.
+
+A user wrote a daily journal.
+
+Your job: Turn it into a meaningful reflection.
+
+INPUT:
+- Mood: "${mood || 'not specified'}"
+- What went well: "${win || 'not specified'}"
+- What was difficult: "${struggle || 'not specified'}"
+- Highlight of the day: "${highlight || 'not specified'}"
+- Reflection: "${note || 'not specified'}"
+
+OUTPUT:
+
+1. title: Short, emotional, personal (max 8 words)
+2. summary: 2-3 sentences, natural, human, not generic. Reflect what they actually experienced.
+3. quote: 1 short powerful sentence, relatable, feels like it was written for them.
+
+STYLE:
+- calm and reflective
+- real, not a motivational speech
+- avoid generic phrases like "keep going" or "believe in yourself"
+
+Return ONLY valid JSON:
+{
+ "title": "",
+ "summary": "",
+ "quote": ""
+}`;
+}
+
+/**
+ * Generate AI reflection from journal inputs.
+ */
+export async function generateDailyReflection(data) {
+ try {
+ const response = await fetch(`${GEMINI_URL}?key=${GEMINI_API_KEY}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ contents: [{ parts: [{ text: buildPrompt(data) }] }],
+ generationConfig: { temperature: 0.8, topP: 0.9, maxOutputTokens: 512 },
+ }),
+ });
+
+ if (response.status === 429) {
+ await new Promise((r) => setTimeout(r, 4000));
+ const retry = await fetch(`${GEMINI_URL}?key=${GEMINI_API_KEY}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ contents: [{ parts: [{ text: buildPrompt(data) }] }],
+ generationConfig: { temperature: 0.8, topP: 0.9, maxOutputTokens: 512 },
+ }),
+ });
+ if (!retry.ok) throw new Error('Rate limited');
+ const json = await retry.json();
+ return { ...safeParseJournal(json?.candidates?.[0]?.content?.parts?.[0]?.text), source: 'ai' };
+ }
+
+ if (!response.ok) throw new Error('AI failed');
+
+ const json = await response.json();
+ const rawText = json?.candidates?.[0]?.content?.parts?.[0]?.text || '';
+ return { ...safeParseJournal(rawText), source: 'ai' };
+ } catch (e) {
+ console.warn('Journal AI failed, using fallback:', e.message);
+ return { ...buildFallback(data), source: 'fallback' };
+ }
+}
+
+function buildFallback({ mood, win, struggle }) {
+ const titles = {
+ great: 'A Day That Lifted You',
+ good: 'Quiet Progress',
+ okay: 'Steady and Present',
+ bad: 'Through the Hard Parts',
+ terrible: 'Surviving Is Enough',
+ };
+
+ return {
+ title: titles[mood?.toLowerCase()] || 'Another Step Forward',
+ summary: win
+ ? `Today you noticed something good: ${win.toLowerCase()}. ${struggle ? `Even though ${struggle.toLowerCase()}, you kept moving.` : 'That awareness is growth.'}`
+ : 'You showed up today. Some days that is the whole victory.',
+ quote: struggle
+ ? 'The hard days are proof that you are still trying.'
+ : 'You are closer than yesterday.',
+ };
+}
+
+/**
+ * Save daily journal with all fields.
+ */
+export async function saveDailyJournal(identityId, dayNumber, data) {
+ const today = todayISO();
+
+ const record = {
+ identity_id: identityId,
+ date: today,
+ day_number: dayNumber,
+ identity_check: data.identityCheck || 'almost',
+ habits_completed: data.habitsCompleted || 0,
+ mood: data.mood || '',
+ win: data.win || '',
+ struggle: data.struggle || '',
+ highlight: data.highlight || '',
+ note: data.note || '',
+ ai_title: data.aiTitle || '',
+ ai_summary: data.aiSummary || '',
+ ai_quote: data.aiQuote || '',
+ };
+
+ await offline.upsert(
+ 'daily_logs',
+ { identity_id: identityId, date: today },
+ record
+ );
+
+ // Update stats
+ const scoreAdd = data.identityCheck === 'yes' ? 3 : data.identityCheck === 'almost' ? 1 : 0;
+ if (scoreAdd > 0) {
+ const stats = await offline.getOne('stats', { identity_id: identityId });
+ if (stats) {
+ await offline.update('stats', { id: stats.id }, {
+ discipline_score: stats.discipline_score + scoreAdd,
+ consistency_score: stats.consistency_score + (data.identityCheck === 'yes' ? 2 : 1),
+ focus_score: stats.focus_score + scoreAdd,
+ });
+ }
+ }
+
+ return record;
+}
+
+/**
+ * Get journal entry for a specific date.
+ */
+export async function getJournalEntry(identityId, date) {
+ return offline.getOne('daily_logs', { identity_id: identityId, date: date || todayISO() });
+}
diff --git a/src/services/offlineStorage.js b/src/services/offlineStorage.js
new file mode 100644
index 0000000..bfdf7e9
--- /dev/null
+++ b/src/services/offlineStorage.js
@@ -0,0 +1,85 @@
+import AsyncStorage from '@react-native-async-storage/async-storage';
+
+const PREFIX = 'nova40_data_';
+
+function key(collection, id) {
+ return id ? `${PREFIX}${collection}_${id}` : `${PREFIX}${collection}`;
+}
+
+function generateId() {
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
+ const r = (Math.random() * 16) | 0;
+ const v = c === 'x' ? r : (r & 0x3) | 0x8;
+ return v.toString(16);
+ });
+}
+
+// Get all items in a collection for a given filter
+async function getCollection(collection) {
+ const raw = await AsyncStorage.getItem(key(collection));
+ return raw ? JSON.parse(raw) : [];
+}
+
+async function saveCollection(collection, items) {
+ await AsyncStorage.setItem(key(collection), JSON.stringify(items));
+}
+
+// --- CRUD helpers ---
+
+export async function getAll(collection, filter = {}) {
+ const items = await getCollection(collection);
+ return items.filter((item) => {
+ for (const [k, v] of Object.entries(filter)) {
+ if (item[k] !== v) return false;
+ }
+ return true;
+ });
+}
+
+export async function getOne(collection, filter = {}) {
+ const items = await getAll(collection, filter);
+ return items[0] || null;
+}
+
+export async function insert(collection, data) {
+ const items = await getCollection(collection);
+ const newItem = { id: generateId(), ...data, created_at: new Date().toISOString() };
+ items.push(newItem);
+ await saveCollection(collection, items);
+ return newItem;
+}
+
+export async function insertMany(collection, dataArray) {
+ const items = await getCollection(collection);
+ const newItems = dataArray.map((d) => ({
+ id: generateId(),
+ ...d,
+ created_at: new Date().toISOString(),
+ }));
+ items.push(...newItems);
+ await saveCollection(collection, items);
+ return newItems;
+}
+
+export async function update(collection, filter, updates) {
+ const items = await getCollection(collection);
+ let updated = null;
+ const newItems = items.map((item) => {
+ const matches = Object.entries(filter).every(([k, v]) => item[k] === v);
+ if (matches && !updated) {
+ updated = { ...item, ...updates };
+ return updated;
+ }
+ return item;
+ });
+ await saveCollection(collection, newItems);
+ return updated;
+}
+
+export async function upsert(collection, filter, data) {
+ const existing = await getOne(collection, filter);
+ if (existing) {
+ return update(collection, { id: existing.id }, data);
+ }
+ return insert(collection, { ...filter, ...data });
+}
diff --git a/src/services/storyService.js b/src/services/storyService.js
new file mode 100644
index 0000000..d2b4324
--- /dev/null
+++ b/src/services/storyService.js
@@ -0,0 +1,137 @@
+import safeParseStory from '../utils/safeParseStory';
+import { GEMINI_API_KEY } from '../config/keys';
+
+const GEMINI_URL =
+ 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent';
+
+function buildPrompt(identity, habits, logs) {
+ const yesCount = logs.filter((l) => l.identity_check === 'yes').length;
+ const almostCount = logs.filter((l) => l.identity_check === 'almost').length;
+ const noCount = logs.filter((l) => l.identity_check === 'no').length;
+ const totalLogged = logs.length;
+
+ // Collect reflections (notes) from journal
+ const reflections = logs
+ .filter((l) => l.note && l.note.trim())
+ .map((l) => `Day ${l.day_number}: "${l.note.trim()}"`)
+ .slice(0, 15); // max 15 to keep prompt size reasonable
+
+ const habitNames = habits.map((h) => h.title).join(', ');
+
+ return `You are a reflective storyteller.
+
+A user has completed a 40-day personal transformation journey.
+
+Here is their data:
+
+Identity: "${identity.title}"
+${identity.story_text ? `Original story: "${identity.story_text}"` : ''}
+${identity.description ? `Description: "${identity.description}"` : ''}
+Habits they practiced: ${habitNames}
+Days fully aligned: ${yesCount}/40
+Days almost aligned: ${almostCount}
+Days missed: ${noCount}
+Total days logged: ${totalLogged}
+
+${reflections.length > 0 ? `Their journal entries:\n${reflections.join('\n')}` : 'No journal entries recorded.'}
+
+---
+
+Your job: Turn this into a meaningful and emotional story.
+
+STRUCTURE:
+
+1. Beginning — Who they were. What they wanted.
+2. Struggles — Challenges they faced. Moments they almost gave up.
+3. Breakthrough — When things started to change. Small wins.
+4. Transformation — Who they became. What changed.
+
+STYLE:
+- Personal and human
+- Emotional but not dramatic
+- Simple language
+- Feels real, not a motivational speech
+- Written in third person or second person ("you")
+
+OUTPUT FORMAT (JSON ONLY):
+Return ONLY valid JSON. No markdown, no explanation.
+
+{
+ "title": "short and powerful story title",
+ "paragraphs": [
+ "paragraph 1 - the beginning",
+ "paragraph 2 - the struggles",
+ "paragraph 3 - the breakthrough",
+ "paragraph 4 - the transformation"
+ ],
+ "closing_line": "a memorable final sentence"
+}
+
+IMPORTANT: Make it feel like this story belongs to ONE person, not a template.`;
+}
+
+/**
+ * Generate a transformation story using AI.
+ * Falls back to local generation if AI fails.
+ */
+export async function generateTransformationStory(identity, habits, logs, stats) {
+ try {
+ const response = await fetch(`${GEMINI_URL}?key=${GEMINI_API_KEY}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ contents: [{ parts: [{ text: buildPrompt(identity, habits, logs) }] }],
+ generationConfig: {
+ temperature: 0.8,
+ topP: 0.9,
+ maxOutputTokens: 2048,
+ },
+ }),
+ });
+
+ if (response.status === 429) {
+ // Rate limited — wait and retry once
+ await new Promise((r) => setTimeout(r, 4000));
+ const retry = await fetch(`${GEMINI_URL}?key=${GEMINI_API_KEY}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ contents: [{ parts: [{ text: buildPrompt(identity, habits, logs) }] }],
+ generationConfig: { temperature: 0.8, topP: 0.9, maxOutputTokens: 2048 },
+ }),
+ });
+ if (!retry.ok) throw new Error('AI rate limited');
+ const retryJson = await retry.json();
+ const rawText = retryJson?.candidates?.[0]?.content?.parts?.[0]?.text || '';
+ return { ...safeParseStory(rawText, identity, logs, stats), source: 'ai' };
+ }
+
+ if (!response.ok) throw new Error('AI request failed');
+
+ const json = await response.json();
+ const rawText = json?.candidates?.[0]?.content?.parts?.[0]?.text || '';
+ const parsed = safeParseStory(rawText, identity, logs, stats);
+ return { ...parsed, source: 'ai' };
+ } catch (e) {
+ console.warn('Story AI failed, using fallback:', e.message);
+ return { ...buildFallbackStory(identity, habits, logs, stats), source: 'fallback' };
+ }
+}
+
+function buildFallbackStory(identity, habits, logs, stats) {
+ const yesCount = logs.filter((l) => l.identity_check === 'yes').length;
+ const totalLogged = logs.length;
+ const title = identity.title || 'A New Beginning';
+ const habitNames = habits.slice(0, 3).map((h) => h.title).join(', ');
+
+ return {
+ title: `The ${totalLogged}-Day Journey`,
+ paragraphs: [
+ `It started with a decision. You said: "${title}". That was the moment everything began to shift — not because the world changed, but because you did.`,
+ `The first days were hard. Building habits like ${habitNames || 'showing up daily'} felt unnatural. There were moments when giving up seemed easier than continuing. But you kept going.`,
+ `Somewhere around the middle, something clicked. The habits stopped feeling forced. You weren't just doing things — you were becoming someone. ${yesCount > 20 ? 'More often than not, you showed up fully aligned with who you wanted to be.' : 'Even on the hard days, you found a way to keep moving forward.'}`,
+ `40 days later, you are not the same person who started this journey. You logged ${totalLogged} days. You showed up ${yesCount} times fully aligned. The numbers tell a story, but the real transformation happened inside.`,
+ ],
+ closing_line: 'You didn\'t just change your habits. You changed who you are.',
+ };
+}
diff --git a/src/services/supabase.js b/src/services/supabase.js
new file mode 100644
index 0000000..a512cb0
--- /dev/null
+++ b/src/services/supabase.js
@@ -0,0 +1,15 @@
+import 'react-native-url-polyfill/auto';
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import { createClient } from '@supabase/supabase-js';
+
+const SUPABASE_URL = 'https://fvysqunfasbmzqjwxjew.supabase.co';
+const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImZ2eXNxdW5mYXNibXpxand4amV3Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzYxNDE2MDUsImV4cCI6MjA5MTcxNzYwNX0.0oHcbFz48YYT8aBSjEK2kkCqbRChWAN6dx7u-kLl-Jk';
+
+export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
+ auth: {
+ storage: AsyncStorage,
+ autoRefreshToken: true,
+ persistSession: true,
+ detectSessionInUrl: false,
+ },
+});
diff --git a/src/store/useAppStore.js b/src/store/useAppStore.js
new file mode 100644
index 0000000..4ec9c28
--- /dev/null
+++ b/src/store/useAppStore.js
@@ -0,0 +1,28 @@
+import { create } from 'zustand';
+import AsyncStorage from '@react-native-async-storage/async-storage';
+
+const ONBOARDING_KEY = 'onboarding_done';
+
+const useAppStore = create((set) => ({
+ onboardingDone: false,
+ initialIdentity: '',
+ appLoading: true,
+
+ initApp: async () => {
+ try {
+ const value = await AsyncStorage.getItem(ONBOARDING_KEY);
+ set({ onboardingDone: value === 'true', appLoading: false });
+ } catch (_) {
+ set({ onboardingDone: false, appLoading: false });
+ }
+ },
+
+ setOnboardingDone: async () => {
+ await AsyncStorage.setItem(ONBOARDING_KEY, 'true');
+ set({ onboardingDone: true });
+ },
+
+ setInitialIdentity: (value) => set({ initialIdentity: value }),
+}));
+
+export default useAppStore;
diff --git a/src/store/useAuthStore.js b/src/store/useAuthStore.js
new file mode 100644
index 0000000..796bbb0
--- /dev/null
+++ b/src/store/useAuthStore.js
@@ -0,0 +1,98 @@
+import { create } from 'zustand';
+import * as authService from '../services/authService';
+
+const useAuthStore = create((set) => ({
+ session: null,
+ user: null,
+ loading: false,
+ error: null,
+
+ setSession: (session) =>
+ set({
+ session,
+ user: session?.user ?? null,
+ loading: false,
+ error: null,
+ }),
+
+ login: async (email, password) => {
+ set({ loading: true, error: null });
+ try {
+ const data = await authService.signIn(email, password);
+ set({ session: data.session, user: data.user, loading: false });
+ return data;
+ } catch (e) {
+ set({ loading: false, error: e.message });
+ throw e;
+ }
+ },
+
+ loginAsDemo: async () => {
+ set({ loading: true, error: null });
+ try {
+ const data = await authService.signInAsDemo();
+ set({ session: data.session, user: data.user, loading: false });
+ return data;
+ } catch (e) {
+ set({ loading: false, error: e.message });
+ throw e;
+ }
+ },
+
+ loginWithGoogle: async () => {
+ set({ loading: true, error: null });
+ try {
+ const data = await authService.signInWithGoogle();
+ set({ loading: false });
+ return data;
+ } catch (e) {
+ set({ loading: false, error: e.message });
+ throw e;
+ }
+ },
+
+ register: async (email, password) => {
+ set({ loading: true, error: null });
+ try {
+ const data = await authService.signUp(email, password);
+ // If signup returned a session (offline mode or auto-confirm), set it
+ if (data?.session) {
+ set({
+ session: data.session,
+ user: data.session?.user ?? data.user ?? null,
+ loading: false,
+ });
+ } else {
+ set({ loading: false });
+ }
+ return data;
+ } catch (e) {
+ set({ loading: false, error: e.message });
+ throw e;
+ }
+ },
+
+ logout: async () => {
+ try {
+ await authService.signOut();
+ } catch (_) {
+ // proceed even if signOut fails (e.g. network)
+ }
+ set({ session: null, user: null, error: null });
+ },
+
+ fetchSession: async () => {
+ try {
+ const session = await authService.getSession();
+ set({ session, user: session?.user ?? null });
+ return session;
+ } catch (_) {
+ set({ session: null, user: null });
+ return null;
+ }
+ },
+
+ clearError: () => set({ error: null }),
+}));
+
+export default useAuthStore;
diff --git a/src/store/useHabitStore.js b/src/store/useHabitStore.js
new file mode 100644
index 0000000..23a054c
--- /dev/null
+++ b/src/store/useHabitStore.js
@@ -0,0 +1,55 @@
+import { create } from 'zustand';
+import * as habitService from '../services/habitService';
+
+const useHabitStore = create((set, get) => ({
+ habits: [],
+ habitLogs: {},
+ loading: false,
+ error: null,
+
+ setHabits: (habits) => set({ habits }),
+
+ fetchHabits: async (identityId) => {
+ set({ loading: true, error: null });
+ try {
+ const habits = await habitService.getHabits(identityId);
+ set({ habits, loading: false });
+ } catch (e) {
+ set({ loading: false, error: e.message });
+ }
+ },
+
+ loadTodayLogs: async () => {
+ const { habits } = get();
+ if (!habits.length) return {};
+
+ try {
+ const logs = await habitService.getTodayHabitLogs(habits.map((h) => h.id));
+ set({ habitLogs: logs });
+ return logs;
+ } catch (e) {
+ set({ error: e.message });
+ return {};
+ }
+ },
+
+ toggleHabit: async (habitId) => {
+ const { habitLogs } = get();
+ const newValue = !habitLogs[habitId];
+
+ // Optimistic update
+ set({ habitLogs: { ...habitLogs, [habitId]: newValue } });
+
+ try {
+ await habitService.saveHabitLog(habitId, newValue);
+ } catch (e) {
+ // Rollback
+ set({ habitLogs: { ...get().habitLogs, [habitId]: !newValue }, error: e.message });
+ }
+ },
+
+ // Reset on logout
+ reset: () => set({ habits: [], habitLogs: {}, loading: false, error: null }),
+}));
+
+export default useHabitStore;
diff --git a/src/store/useIdentityStore.js b/src/store/useIdentityStore.js
new file mode 100644
index 0000000..95bc5c4
--- /dev/null
+++ b/src/store/useIdentityStore.js
@@ -0,0 +1,82 @@
+import { create } from 'zustand';
+import * as identityService from '../services/identityService';
+import { calculateCurrentDay } from '../utils/date';
+import useAuthStore from './useAuthStore';
+import useHabitStore from './useHabitStore';
+
+const useIdentityStore = create((set, get) => ({
+ identity: null,
+ currentDay: 0,
+ loading: false,
+ error: null,
+
+ fetchIdentity: async () => {
+ const user = useAuthStore.getState().user;
+ if (!user) return;
+
+ set({ loading: true, error: null });
+ try {
+ const data = await identityService.getIdentity(user.id);
+ if (data) {
+ const day = calculateCurrentDay(data.start_date);
+ set({ identity: data, currentDay: day, loading: false });
+ // Also load habits into habit store
+ await useHabitStore.getState().fetchHabits(data.id);
+ } else {
+ set({ identity: null, currentDay: 0, loading: false });
+ }
+ } catch (e) {
+ set({ loading: false, error: e.message });
+ }
+ },
+
+ createIdentity: async (title, description, customHabits, storyText) => {
+ const user = useAuthStore.getState().user;
+ if (!user) throw new Error('Not authenticated');
+
+ set({ loading: true, error: null });
+ try {
+ const { identity, habits } = await identityService.createIdentity(
+ user.id, title, description, customHabits, storyText
+ );
+ set({ identity, currentDay: 1, loading: false });
+ useHabitStore.getState().setHabits(habits);
+ return identity;
+ } catch (e) {
+ set({ loading: false, error: e.message });
+ throw e;
+ }
+ },
+
+ // Daily logs
+ saveDailyLog: async (identityCheck, note) => {
+ const { identity, currentDay } = get();
+ if (!identity) return;
+ await identityService.saveDailyLog(identity.id, currentDay, identityCheck, note);
+ },
+
+ loadDailyLogs: async () => {
+ const { identity } = get();
+ if (!identity) return [];
+ return identityService.getDailyLogs(identity.id);
+ },
+
+ // Stats
+ loadStats: async () => {
+ const { identity } = get();
+ if (!identity) return null;
+ return identityService.getStats(identity.id);
+ },
+
+ // Game
+ saveGameScore: async (gameType, score) => {
+ const { identity } = get();
+ if (!identity) return;
+ await identityService.saveGameScore(identity.id, gameType, score);
+ },
+
+ // Reset on logout
+ reset: () => set({ identity: null, currentDay: 0, loading: false, error: null }),
+}));
+
+export default useIdentityStore;
diff --git a/src/utils/date.js b/src/utils/date.js
new file mode 100644
index 0000000..b938e87
--- /dev/null
+++ b/src/utils/date.js
@@ -0,0 +1,19 @@
+export function todayISO() {
+ return new Date().toISOString().split('T')[0];
+}
+
+export function calculateCurrentDay(startDate) {
+ if (!startDate) return 0;
+ const start = new Date(startDate);
+ const today = new Date();
+ start.setHours(0, 0, 0, 0);
+ today.setHours(0, 0, 0, 0);
+ const diffDays = Math.floor((today - start) / (1000 * 60 * 60 * 24)) + 1;
+ return Math.max(1, Math.min(diffDays, 40));
+}
+
+export function addDays(date, days) {
+ return new Date(date.getTime() + days * 24 * 60 * 60 * 1000)
+ .toISOString()
+ .split('T')[0];
+}
diff --git a/src/utils/helpers.js b/src/utils/helpers.js
new file mode 100644
index 0000000..d73ebd6
--- /dev/null
+++ b/src/utils/helpers.js
@@ -0,0 +1,301 @@
+export function calculateCurrentDay(startDate) {
+ if (!startDate) return 0;
+ const start = new Date(startDate);
+ const today = new Date();
+ start.setHours(0, 0, 0, 0);
+ today.setHours(0, 0, 0, 0);
+ const diffTime = today - start;
+ const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)) + 1;
+ return Math.max(1, Math.min(diffDays, 40));
+}
+
+export function getDayPhase(day) {
+ if (day <= 7) return 'awakening';
+ if (day <= 14) return 'building';
+ if (day <= 21) return 'testing';
+ if (day <= 30) return 'strengthening';
+ return 'transcending';
+}
+
+export function getDayNarrative(day) {
+ const phase = getDayPhase(day);
+ const narratives = {
+ awakening: [
+ 'The seed has been planted. Your old self watches curiously.',
+ 'Small cracks appear in old patterns. Keep going.',
+ 'Your identity is shifting beneath the surface.',
+ 'The resistance you feel is proof of change.',
+ 'Day by day, you become who you decided to be.',
+ 'Your habits are writing a new story.',
+ 'One week in. The foundation is forming.',
+ ],
+ building: [
+ 'Momentum is building. Can you feel it?',
+ 'Your new self is taking shape.',
+ 'The gap between who you were and who you are is growing.',
+ 'Discipline is becoming your default.',
+ 'You are proving something to yourself.',
+ 'Two weeks. This is no longer a phase.',
+ 'Your identity is crystallizing.',
+ ],
+ testing: [
+ 'This is where most people quit. You are not most people.',
+ 'The test is not the habit — it is who you become when it gets hard.',
+ 'Three weeks. Your neural pathways are rewiring.',
+ 'Comfort zone? You left that behind.',
+ 'Every day you show up, you vote for your new identity.',
+ 'The old you would have stopped by now.',
+ 'You are becoming undeniable.',
+ ],
+ strengthening: [
+ 'Your transformation is visible now.',
+ 'What was hard is becoming natural.',
+ 'You do not do these things — you ARE this person.',
+ 'The orbit is nearly complete.',
+ 'Identity is not what you say. It is what you do repeatedly.',
+ 'You have proven you can change.',
+ 'The final stretch. Every day counts.',
+ ],
+ transcending: [
+ 'The metamorphosis nears completion.',
+ 'You are not the same person who started this.',
+ 'Look back at Day 1. See how far you have come.',
+ 'These habits are now part of your DNA.',
+ 'The person you imagined? You are becoming them.',
+ 'Almost there. The universe is watching.',
+ 'Your new identity is forged.',
+ 'The final days. Make them count.',
+ 'Tomorrow, you complete your transformation.',
+ 'Day 40. You made it. You are Nova.',
+ ],
+ };
+
+ const phaseNarratives = narratives[phase];
+ const phaseStart =
+ phase === 'awakening' ? 1
+ : phase === 'building' ? 8
+ : phase === 'testing' ? 15
+ : phase === 'strengthening' ? 22
+ : 31;
+ const index = Math.min(day - phaseStart, phaseNarratives.length - 1);
+ return phaseNarratives[index];
+}
+
+export function isCriticalDay(day) {
+ return [3, 10, 21, 30].includes(day);
+}
+
+// Shuffle array (Fisher-Yates)
+function shuffle(arr) {
+ const a = [...arr];
+ for (let i = a.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [a[i], a[j]] = [a[j], a[i]];
+ }
+ return a;
+}
+
+// Habit pool — each keyword maps to many habits, pick random subset
+const HABIT_POOL = {
+ // Fitness / Body
+ 'fit,gym,exercise,workout,body,weight,muscle,run,jog,active': [
+ { title: 'Move your body 30 minutes', description: 'Walk, run, lift — just move' },
+ { title: 'Take 10,000 steps', description: 'Stay active throughout the day' },
+ { title: 'Do a morning stretch', description: '10 minutes to wake your body up' },
+ { title: 'No elevator — take stairs', description: 'Small movement adds up' },
+ { title: 'Try a new workout', description: 'Keep your body guessing' },
+ { title: 'Track your workout', description: 'Log sets, reps, or distance' },
+ { title: 'Cool down after exercise', description: '5 min stretch post-workout' },
+ ],
+ // Health / Nutrition
+ 'health,eat,food,diet,nutrition,sugar,meal,cook,clean': [
+ { title: 'Eat one clean meal', description: 'Whole foods, no processed stuff' },
+ { title: 'Drink 2L of water', description: 'Hydrate before you caffeinate' },
+ { title: 'No junk food today', description: 'Choose fuel over comfort' },
+ { title: 'Eat a fruit or vegetable', description: 'Add color to your plate' },
+ { title: 'Cook one meal at home', description: 'Control what goes in your body' },
+ { title: 'Skip sugar after 6pm', description: 'Let your body rest clean' },
+ { title: 'Eat slowly — no screen', description: 'Be present with your meal' },
+ ],
+ // Sleep / Rest
+ 'sleep,rest,tired,exhaust,energy,insomnia,bed': [
+ { title: 'Sleep by 11pm', description: 'Protect your rest window' },
+ { title: 'No screens 30min before bed', description: 'Let your brain wind down' },
+ { title: 'Sleep 7+ hours', description: 'Make rest non-negotiable' },
+ { title: 'Create a bedtime ritual', description: 'Tea, book, stretch — your call' },
+ { title: 'Wake at the same time daily', description: 'Consistency over alarm snoozing' },
+ { title: 'No caffeine after 2pm', description: 'Protect your sleep quality' },
+ ],
+ // Reading / Learning
+ 'read,book,learn,study,knowledge,smart,curious,educat': [
+ { title: 'Read 15 pages', description: 'A small chapter changes everything' },
+ { title: 'Listen to a podcast', description: 'Learn while you commute' },
+ { title: 'Write one thing you learned', description: 'Retention through reflection' },
+ { title: 'Replace 30min scrolling with reading', description: 'Swap consumption types' },
+ { title: 'Teach someone what you learned', description: 'Teaching deepens understanding' },
+ { title: 'Read before bed instead of phone', description: 'End the day growing' },
+ { title: 'Take handwritten notes', description: 'Pen to paper locks it in' },
+ ],
+ // Discipline / Routine
+ 'disciplin,routine,consist,habit,lazy,procrastinat,structure': [
+ { title: 'Wake up at the same time', description: 'Discipline starts with your alarm' },
+ { title: 'Make your bed immediately', description: 'First win of the day' },
+ { title: 'Follow a written schedule', description: 'Plan it, then do it' },
+ { title: 'Do the hardest task first', description: 'Eat the frog before noon' },
+ { title: 'No snooze button', description: 'Rise when the alarm rings' },
+ { title: 'End the day with a review', description: 'What went well? What to fix?' },
+ { title: 'Say no to one distraction', description: 'Guard your time like your life' },
+ ],
+ // Focus / Productivity
+ 'focus,product,distract,attention,deep work,concentrat,procrast': [
+ { title: 'One 90-min deep work block', description: 'Phone off, door closed, create' },
+ { title: 'Use a Pomodoro timer', description: '25 on, 5 off — repeat' },
+ { title: 'Plan your top 3 tasks tonight', description: 'Wake up with clarity' },
+ { title: 'Clear your workspace', description: 'Clean space, clear mind' },
+ { title: 'Single-task for 1 hour', description: 'No tabs, no switching' },
+ { title: 'Delete one app that wastes time', description: 'Remove temptation at the source' },
+ { title: 'Review your week every Sunday', description: 'Reflect and recalibrate' },
+ ],
+ // Confidence / Social
+ 'confiden,shy,social,anxi,fear,brave,courage,speak,voice': [
+ { title: 'Give one genuine compliment', description: 'Kindness builds confidence' },
+ { title: 'Speak up once in a group', description: 'Your voice matters' },
+ { title: 'Make eye contact with strangers', description: 'Presence over avoidance' },
+ { title: 'Write 3 wins from today', description: 'Train your brain to see strength' },
+ { title: 'Say no to something you don\'t want', description: 'Boundaries are self-respect' },
+ { title: 'Do one thing that scares you', description: 'Growth lives outside comfort' },
+ { title: 'Record a voice note to yourself', description: 'Hear your own conviction' },
+ ],
+ // Calm / Mental health
+ 'calm,stress,anxious,peace,mental,meditat,mindful,relax,overwhelm,worry': [
+ { title: 'Meditate for 10 minutes', description: 'Sit still. Breathe. That\'s enough' },
+ { title: 'Write in a gratitude journal', description: '3 things you\'re thankful for' },
+ { title: 'Take a walk in nature', description: 'No phone, just presence' },
+ { title: 'Do a 5-minute breathing exercise', description: 'Inhale 4, hold 4, exhale 4' },
+ { title: 'Digital detox for 1 hour', description: 'Unplug and reconnect with yourself' },
+ { title: 'Stretch for 10 minutes', description: 'Release tension from your body' },
+ { title: 'Listen to calming music', description: 'Let sound reset your mood' },
+ ],
+ // Creative / Art
+ 'creat,art,music,draw,paint,design,write,craft,imagin,innovat': [
+ { title: 'Create something for 20 minutes', description: 'Draw, write, build — anything' },
+ { title: 'Brainstorm 5 ideas', description: 'Quantity unlocks quality' },
+ { title: 'Practice your craft', description: 'Show up even without inspiration' },
+ { title: 'Consume inspiring work', description: 'Study someone you admire' },
+ { title: 'Share one thing you made', description: 'Put your work into the world' },
+ { title: 'Try a new creative technique', description: 'Experiment without pressure' },
+ { title: 'Freewrite for 10 minutes', description: 'No editing, just flow' },
+ ],
+ // Finance / Career
+ 'money,financ,save,earn,career,business,invest,rich,wealth,job,work': [
+ { title: 'Track every expense today', description: 'Awareness is the first step' },
+ { title: 'Save before you spend', description: 'Pay yourself first' },
+ { title: 'Learn one new skill for your career', description: '15 minutes of growth' },
+ { title: 'Review your financial goals', description: 'Are you on track?' },
+ { title: 'Avoid one impulse purchase', description: 'Ask: do I need this or want this?' },
+ { title: 'Network — reach out to someone', description: 'Opportunities come through people' },
+ { title: 'Read about your industry', description: 'Stay sharp and informed' },
+ ],
+ // Relationships
+ 'relationship,friend,family,love,connect,lonel,kind,empath,partner': [
+ { title: 'Call or text someone you care about', description: 'Connection takes 2 minutes' },
+ { title: 'Practice active listening', description: 'Listen to understand, not to reply' },
+ { title: 'Give an unexpected compliment', description: 'Brighten someone\'s day' },
+ { title: 'Be fully present in a conversation', description: 'Phone down, eyes up' },
+ { title: 'Write a thank-you message', description: 'Gratitude strengthens bonds' },
+ { title: 'Forgive one small thing', description: 'Carrying grudges weighs you down' },
+ { title: 'Plan quality time with someone', description: 'Make people a priority, not an afterthought' },
+ ],
+ // Morning / Routine
+ 'morn,wake,alarm,sunrise,start,begin,early': [
+ { title: 'Wake before 6:30 AM', description: 'Own the morning, own the day' },
+ { title: 'No phone for first 30 minutes', description: 'Your mind deserves a slow start' },
+ { title: 'Drink water before coffee', description: 'Rehydrate first' },
+ { title: 'Move your body within 15 min of waking', description: 'Jumpstart your energy' },
+ { title: 'Set 3 intentions for the day', description: 'Know what matters before you start' },
+ { title: 'Eat a real breakfast', description: 'Fuel, not just caffeine' },
+ ],
+ // Spiritual / Purpose
+ 'spirit,purpose,meaning,faith,pray,god,soul,grateful,thank': [
+ { title: 'Spend 10 minutes in stillness', description: 'Prayer, meditation, or silence' },
+ { title: 'Write what you\'re grateful for', description: 'Gratitude changes your lens' },
+ { title: 'Read something that feeds your soul', description: 'A verse, a poem, a passage' },
+ { title: 'Reflect on your purpose', description: 'Why are you here? What matters?' },
+ { title: 'Do one act of kindness', description: 'Give without expecting return' },
+ { title: 'Forgive yourself for something', description: 'Grace is a daily practice' },
+ ],
+};
+
+// Universal habits that apply to anyone
+const UNIVERSAL_HABITS = [
+ { title: 'Journal for 5 minutes', description: 'Put your thoughts on paper' },
+ { title: 'Drink a full glass of water first thing', description: 'Start clean' },
+ { title: 'Take a 10-minute walk', description: 'Movement clears the mind' },
+ { title: 'Do one thing outside your comfort zone', description: 'Growth is on the other side' },
+ { title: 'Spend 5 minutes planning tomorrow', description: 'Never wake up without a plan' },
+ { title: 'Say something kind to yourself', description: 'You talk to yourself more than anyone' },
+ { title: 'Avoid complaining for 1 hour', description: 'Replace complaints with solutions' },
+ { title: 'Tidy one small area', description: 'Order outside creates order inside' },
+ { title: 'Do something you\'ve been avoiding', description: 'The relief is worth the effort' },
+ { title: 'Go to bed 15 minutes earlier', description: 'Small shifts, big impact' },
+];
+
+export function generateHabitsFromIdentity(title) {
+ const text = (title || '').toLowerCase();
+ const matched = [];
+
+ // Collect all matching habits from the pool
+ for (const [keys, habits] of Object.entries(HABIT_POOL)) {
+ const keyList = keys.split(',');
+ if (keyList.some((k) => text.includes(k.trim()))) {
+ habits.forEach((h) => {
+ if (!matched.some((m) => m.title === h.title)) matched.push(h);
+ });
+ }
+ }
+
+ // Shuffle matched and pick 3-5
+ if (matched.length > 0) {
+ const picked = shuffle(matched).slice(0, 3 + Math.floor(Math.random() * 2));
+ return picked;
+ }
+
+ // No keyword match — return random universal habits
+ return shuffle(UNIVERSAL_HABITS).slice(0, 3);
+}
+
+export function getSuggestionsForIdentity(title) {
+ if (!title || title.trim().length < 2) return [];
+ const text = title.toLowerCase();
+ const matched = [];
+
+ for (const [keys, habits] of Object.entries(HABIT_POOL)) {
+ const keyList = keys.split(',');
+ if (keyList.some((k) => text.includes(k.trim()))) {
+ habits.forEach((h) => {
+ if (!matched.includes(h.title)) matched.push(h.title);
+ });
+ }
+ }
+
+ // Shuffle so same input doesn't always show same order
+ const shuffled = shuffle(matched);
+
+ // If few matches, pad with universal habits
+ if (shuffled.length < 5) {
+ const universalTitles = shuffle(UNIVERSAL_HABITS).map((h) => h.title);
+ universalTitles.forEach((t) => {
+ if (!shuffled.includes(t)) shuffled.push(t);
+ });
+ }
+
+ return shuffled.slice(0, 8);
+}
+
+export function formatDate(date) {
+ return new Date(date).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ });
+}
diff --git a/src/utils/safeParseAI.js b/src/utils/safeParseAI.js
new file mode 100644
index 0000000..39f3554
--- /dev/null
+++ b/src/utils/safeParseAI.js
@@ -0,0 +1,64 @@
+const FALLBACK = {
+ identity_title: 'A Better Version of Me',
+ identity_summary: 'You are starting a journey to transform yourself, step by step. Every small action counts.',
+ suggested_habits: [
+ 'Do one small positive action today',
+ 'Avoid one bad habit',
+ 'Reflect for 2 minutes before sleep',
+ ],
+};
+
+/**
+ * Safely parse AI JSON response.
+ * Handles markdown wrapping, broken JSON, and missing fields.
+ * Always returns a valid result — never throws.
+ *
+ * @param {string} raw - Raw text from AI response
+ * @returns {{ identity_title: string, identity_summary: string, suggested_habits: string[] }}
+ */
+export default function safeParseAI(raw) {
+ if (!raw || typeof raw !== 'string') return { ...FALLBACK };
+
+ try {
+ // Strip markdown code fences
+ let cleaned = raw.trim();
+ cleaned = cleaned.replace(/```json\s*/gi, '').replace(/```\s*/gi, '');
+ cleaned = cleaned.trim();
+
+ // Find first { and last } to extract JSON object
+ const start = cleaned.indexOf('{');
+ const end = cleaned.lastIndexOf('}');
+ if (start === -1 || end === -1) return { ...FALLBACK };
+
+ cleaned = cleaned.slice(start, end + 1);
+ const parsed = JSON.parse(cleaned);
+
+ // Validate required fields
+ const title = typeof parsed.identity_title === 'string' && parsed.identity_title.trim()
+ ? parsed.identity_title.trim()
+ : FALLBACK.identity_title;
+
+ const summary = typeof parsed.identity_summary === 'string' && parsed.identity_summary.trim()
+ ? parsed.identity_summary.trim()
+ : FALLBACK.identity_summary;
+
+ let habits = FALLBACK.suggested_habits;
+ if (Array.isArray(parsed.suggested_habits) && parsed.suggested_habits.length > 0) {
+ habits = parsed.suggested_habits
+ .filter((h) => typeof h === 'string' && h.trim())
+ .map((h) => h.trim());
+ }
+
+ if (habits.length === 0) habits = FALLBACK.suggested_habits;
+
+ return {
+ identity_title: title,
+ identity_summary: summary,
+ suggested_habits: habits,
+ };
+ } catch (_) {
+ return { ...FALLBACK };
+ }
+}
+
+export { FALLBACK };
diff --git a/src/utils/safeParseJournal.js b/src/utils/safeParseJournal.js
new file mode 100644
index 0000000..52d8190
--- /dev/null
+++ b/src/utils/safeParseJournal.js
@@ -0,0 +1,30 @@
+const FALLBACK = {
+ title: 'A Day in Your Journey',
+ summary: 'Today you showed up. That alone matters more than you think.',
+ quote: 'Small steps still move you forward.',
+};
+
+export default function safeParseJournal(raw) {
+ if (!raw || typeof raw !== 'string') return { ...FALLBACK };
+
+ try {
+ let cleaned = raw.trim();
+ cleaned = cleaned.replace(/```json\s*/gi, '').replace(/```\s*/gi, '');
+
+ const start = cleaned.indexOf('{');
+ const end = cleaned.lastIndexOf('}');
+ if (start === -1 || end === -1) return { ...FALLBACK };
+
+ const parsed = JSON.parse(cleaned.slice(start, end + 1));
+
+ return {
+ title: (typeof parsed.title === 'string' && parsed.title.trim()) || FALLBACK.title,
+ summary: (typeof parsed.summary === 'string' && parsed.summary.trim()) || FALLBACK.summary,
+ quote: (typeof parsed.quote === 'string' && parsed.quote.trim()) || FALLBACK.quote,
+ };
+ } catch (_) {
+ return { ...FALLBACK };
+ }
+}
+
+export { FALLBACK };
diff --git a/src/utils/safeParseStory.js b/src/utils/safeParseStory.js
new file mode 100644
index 0000000..126dc9c
--- /dev/null
+++ b/src/utils/safeParseStory.js
@@ -0,0 +1,53 @@
+/**
+ * Safely parse AI-generated transformation story.
+ * Always returns a valid result — never throws.
+ */
+export default function safeParseStory(raw, identity, logs, stats) {
+ const fallback = buildMinimalFallback(identity, logs);
+
+ if (!raw || typeof raw !== 'string') return fallback;
+
+ try {
+ let cleaned = raw.trim();
+ cleaned = cleaned.replace(/```json\s*/gi, '').replace(/```\s*/gi, '');
+
+ const start = cleaned.indexOf('{');
+ const end = cleaned.lastIndexOf('}');
+ if (start === -1 || end === -1) return fallback;
+
+ cleaned = cleaned.slice(start, end + 1);
+ const parsed = JSON.parse(cleaned);
+
+ const title = typeof parsed.title === 'string' && parsed.title.trim()
+ ? parsed.title.trim()
+ : fallback.title;
+
+ let paragraphs = fallback.paragraphs;
+ if (Array.isArray(parsed.paragraphs) && parsed.paragraphs.length >= 2) {
+ paragraphs = parsed.paragraphs
+ .filter((p) => typeof p === 'string' && p.trim())
+ .map((p) => p.trim());
+ }
+
+ const closing_line = typeof parsed.closing_line === 'string' && parsed.closing_line.trim()
+ ? parsed.closing_line.trim()
+ : fallback.closing_line;
+
+ return { title, paragraphs, closing_line };
+ } catch (_) {
+ return fallback;
+ }
+}
+
+function buildMinimalFallback(identity, logs) {
+ const yesCount = logs.filter((l) => l.identity_check === 'yes').length;
+ return {
+ title: 'Your Transformation',
+ paragraphs: [
+ `You set out to become "${identity?.title || 'someone new'}". That took courage.`,
+ `Over 40 days, you showed up ${yesCount} times fully aligned with your identity.`,
+ `The journey wasn't perfect, but it was yours. And that's what matters.`,
+ ],
+ closing_line: 'This is not the end. This is who you are now.',
+ };
+}
diff --git a/src/utils/theme.js b/src/utils/theme.js
new file mode 100644
index 0000000..a3fa650
--- /dev/null
+++ b/src/utils/theme.js
@@ -0,0 +1,57 @@
+export const colors = {
+ background: '#0A0E1A',
+ surface: '#131831',
+ surfaceLight: '#1C2345',
+ primary: '#6C63FF',
+ primaryLight: '#8B85FF',
+ accent: '#00E5FF',
+ accentGlow: 'rgba(0, 229, 255, 0.3)',
+ success: '#00E676',
+ warning: '#FFD740',
+ error: '#FF5252',
+ text: '#FFFFFF',
+ textSecondary: '#8A8FB5',
+ textMuted: '#4A5078',
+ border: '#1E2548',
+ glow: 'rgba(108, 99, 255, 0.4)',
+ planetCore: '#6C63FF',
+ planetGlow: 'rgba(108, 99, 255, 0.6)',
+ orbitLine: 'rgba(108, 99, 255, 0.2)',
+ orbitActive: '#00E5FF',
+ star: 'rgba(255, 255, 255, 0.6)',
+};
+
+export const fonts = {
+ sizes: {
+ xs: 12,
+ sm: 14,
+ md: 16,
+ lg: 20,
+ xl: 24,
+ xxl: 32,
+ hero: 40,
+ },
+ weights: {
+ regular: '400',
+ medium: '500',
+ semibold: '600',
+ bold: '700',
+ },
+};
+
+export const spacing = {
+ xs: 4,
+ sm: 8,
+ md: 16,
+ lg: 24,
+ xl: 32,
+ xxl: 48,
+};
+
+export const borderRadius = {
+ sm: 8,
+ md: 12,
+ lg: 16,
+ xl: 24,
+ full: 999,
+};
diff --git a/supabase/functions/generate-habits/index.ts b/supabase/functions/generate-habits/index.ts
new file mode 100644
index 0000000..a659422
--- /dev/null
+++ b/supabase/functions/generate-habits/index.ts
@@ -0,0 +1,196 @@
+// Supabase Edge Function: generate-habits
+// Deploy: supabase functions deploy generate-habits --no-verify-jwt
+// Set secret: supabase secrets set GEMINI_API_KEY=your-key-here
+
+import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
+
+const GEMINI_URL =
+ 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent';
+
+const corsHeaders = {
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
+};
+
+function buildPrompt(story: string): string {
+ return `You are a thoughtful and emotionally intelligent personal growth coach.
+
+A user has written a personal story about their current life and who they want to become.
+
+Your job is NOT just to generate habits.
+Your job is to deeply understand the person and reflect their intention back in a meaningful way.
+
+---
+
+INSTRUCTIONS:
+
+1. Read the user's story carefully.
+2. Understand:
+ - their struggles
+ - their desires
+ - their emotional tone
+
+3. Then generate:
+
+A. Identity Title
+- A short, powerful sentence
+- Feels personal and motivating
+- Example: "I am someone who shows up even when it's hard"
+
+B. Identity Summary
+- 2-3 sentences
+- Reflect their story in a human tone
+- Make it feel like someone truly understands them
+- Avoid generic phrases
+
+C. Suggested Habits (5-8 items)
+
+Rules:
+- Very simple and actionable
+- Can be done daily
+- No complexity
+- Each habit max 1 sentence
+- Feels aligned with their story (not random)
+
+IMPORTANT:
+- Habits should feel like "small proof of identity"
+- Not tasks, but expressions of who they want to become
+
+D. Tone Style:
+- Warm
+- Supportive
+- Slightly motivational
+- Never robotic
+- Never overly formal
+- Avoid buzzwords like "optimize", "maximize"
+
+---
+
+OUTPUT FORMAT (JSON ONLY):
+Return ONLY valid JSON. No explanation, no markdown, no extra text.
+
+{
+ "identity_title": "",
+ "identity_summary": "",
+ "suggested_habits": [
+ "",
+ "",
+ "",
+ "",
+ ""
+ ]
+}
+
+---
+
+EXAMPLE:
+
+User story: "I feel like I procrastinate a lot and I want to be more focused and disciplined."
+
+Bad output: "Improve productivity with structured routines"
+Good output: "I am someone who takes action even when I don't feel ready"
+
+Make the user feel understood, not analyzed.
+
+---
+
+User story:
+"""
+${story}
+"""`;
+}
+
+serve(async (req) => {
+ // Handle CORS preflight
+ if (req.method === 'OPTIONS') {
+ return new Response('ok', { headers: corsHeaders });
+ }
+
+ try {
+ const { story } = await req.json();
+
+ if (!story || typeof story !== 'string' || story.trim().length < 10) {
+ return new Response(
+ JSON.stringify({ error: 'Please write at least a few sentences about yourself.' }),
+ { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+ );
+ }
+
+ const apiKey = Deno.env.get('GEMINI_API_KEY');
+ if (!apiKey) {
+ return new Response(
+ JSON.stringify({ error: 'AI service not configured. Please set GEMINI_API_KEY.' }),
+ { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+ );
+ }
+
+ // Call Gemini API
+ const geminiResponse = await fetch(`${GEMINI_URL}?key=${apiKey}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ contents: [{ parts: [{ text: buildPrompt(story.trim()) }] }],
+ generationConfig: {
+ temperature: 0.7,
+ topP: 0.9,
+ maxOutputTokens: 1024,
+ },
+ }),
+ });
+
+ if (!geminiResponse.ok) {
+ const errText = await geminiResponse.text();
+ console.error('Gemini API error:', errText);
+ return new Response(
+ JSON.stringify({ error: 'AI generation failed. Please try again.' }),
+ { status: 502, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+ );
+ }
+
+ const geminiData = await geminiResponse.json();
+
+ // Extract text from Gemini response
+ const rawText =
+ geminiData?.candidates?.[0]?.content?.parts?.[0]?.text || '';
+
+ // Parse JSON from response (handle potential markdown wrapping)
+ let cleaned = rawText.trim();
+ // Remove markdown code blocks if present
+ cleaned = cleaned.replace(/```json\s*/gi, '').replace(/```\s*/gi, '');
+ cleaned = cleaned.trim();
+
+ let result;
+ try {
+ result = JSON.parse(cleaned);
+ } catch {
+ console.error('Failed to parse Gemini output:', cleaned);
+ return new Response(
+ JSON.stringify({ error: 'AI returned invalid data. Please try again.' }),
+ { status: 502, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+ );
+ }
+
+ // Validate structure
+ if (
+ !result.identity_title ||
+ !result.identity_summary ||
+ !Array.isArray(result.suggested_habits) ||
+ result.suggested_habits.length === 0
+ ) {
+ return new Response(
+ JSON.stringify({ error: 'AI returned incomplete data. Please try again.' }),
+ { status: 502, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+ );
+ }
+
+ return new Response(JSON.stringify(result), {
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ });
+ } catch (err) {
+ console.error('Function error:', err);
+ return new Response(
+ JSON.stringify({ error: 'Something went wrong. Please try again.' }),
+ { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+ );
+ }
+});
diff --git a/supabase/migrations/001_create_tables.sql b/supabase/migrations/001_create_tables.sql
new file mode 100644
index 0000000..32bb88a
--- /dev/null
+++ b/supabase/migrations/001_create_tables.sql
@@ -0,0 +1,157 @@
+-- Nova40 Database Schema
+-- Run this in your Supabase SQL editor
+
+-- Enable UUID extension
+create extension if not exists "uuid-ossp";
+
+-- 1. Identities table
+create table identities (
+ id uuid default uuid_generate_v4() primary key,
+ user_id uuid references auth.users(id) on delete cascade not null,
+ title text not null,
+ description text,
+ start_date date not null,
+ end_date date not null,
+ created_at timestamptz default now()
+);
+
+alter table identities enable row level security;
+
+create policy "Users can view their own identities"
+ on identities for select using (auth.uid() = user_id);
+
+create policy "Users can create their own identities"
+ on identities for insert with check (auth.uid() = user_id);
+
+-- 2. Habits table
+create table habits (
+ id uuid default uuid_generate_v4() primary key,
+ identity_id uuid references identities(id) on delete cascade not null,
+ title text not null,
+ description text
+);
+
+alter table habits enable row level security;
+
+create policy "Users can view habits for their identities"
+ on habits for select using (
+ identity_id in (select id from identities where user_id = auth.uid())
+ );
+
+create policy "Users can create habits for their identities"
+ on habits for insert with check (
+ identity_id in (select id from identities where user_id = auth.uid())
+ );
+
+-- 3. Daily logs table
+create table daily_logs (
+ id uuid default uuid_generate_v4() primary key,
+ identity_id uuid references identities(id) on delete cascade not null,
+ date date not null,
+ day_number int not null,
+ identity_check text check (identity_check in ('yes', 'almost', 'no')),
+ note text,
+ unique (identity_id, date)
+);
+
+alter table daily_logs enable row level security;
+
+create policy "Users can view their daily logs"
+ on daily_logs for select using (
+ identity_id in (select id from identities where user_id = auth.uid())
+ );
+
+create policy "Users can insert daily logs"
+ on daily_logs for insert with check (
+ identity_id in (select id from identities where user_id = auth.uid())
+ );
+
+create policy "Users can update their daily logs"
+ on daily_logs for update using (
+ identity_id in (select id from identities where user_id = auth.uid())
+ );
+
+-- 4. Habit logs table
+create table habit_logs (
+ id uuid default uuid_generate_v4() primary key,
+ habit_id uuid references habits(id) on delete cascade not null,
+ date date not null,
+ completed boolean default false,
+ unique (habit_id, date)
+);
+
+alter table habit_logs enable row level security;
+
+create policy "Users can view their habit logs"
+ on habit_logs for select using (
+ habit_id in (
+ select h.id from habits h
+ join identities i on h.identity_id = i.id
+ where i.user_id = auth.uid()
+ )
+ );
+
+create policy "Users can insert habit logs"
+ on habit_logs for insert with check (
+ habit_id in (
+ select h.id from habits h
+ join identities i on h.identity_id = i.id
+ where i.user_id = auth.uid()
+ )
+ );
+
+create policy "Users can update their habit logs"
+ on habit_logs for update using (
+ habit_id in (
+ select h.id from habits h
+ join identities i on h.identity_id = i.id
+ where i.user_id = auth.uid()
+ )
+ );
+
+-- 5. Game sessions table
+create table game_sessions (
+ id uuid default uuid_generate_v4() primary key,
+ identity_id uuid references identities(id) on delete cascade not null,
+ game_type text not null,
+ score int default 0,
+ created_at timestamptz default now()
+);
+
+alter table game_sessions enable row level security;
+
+create policy "Users can view their game sessions"
+ on game_sessions for select using (
+ identity_id in (select id from identities where user_id = auth.uid())
+ );
+
+create policy "Users can insert game sessions"
+ on game_sessions for insert with check (
+ identity_id in (select id from identities where user_id = auth.uid())
+ );
+
+-- 6. Stats table
+create table stats (
+ id uuid default uuid_generate_v4() primary key,
+ identity_id uuid references identities(id) on delete cascade not null unique,
+ discipline_score int default 0,
+ focus_score int default 0,
+ consistency_score int default 0
+);
+
+alter table stats enable row level security;
+
+create policy "Users can view their stats"
+ on stats for select using (
+ identity_id in (select id from identities where user_id = auth.uid())
+ );
+
+create policy "Users can insert their stats"
+ on stats for insert with check (
+ identity_id in (select id from identities where user_id = auth.uid())
+ );
+
+create policy "Users can update their stats"
+ on stats for update using (
+ identity_id in (select id from identities where user_id = auth.uid())
+ );
diff --git a/supabase/migrations/002_seed_demo_user.sql b/supabase/migrations/002_seed_demo_user.sql
new file mode 100644
index 0000000..313a1cb
--- /dev/null
+++ b/supabase/migrations/002_seed_demo_user.sql
@@ -0,0 +1,75 @@
+-- Nova40 Demo User Seeder
+-- Run this in Supabase SQL Editor
+--
+-- Creates: demo@nova40.app / 123456 (pre-confirmed, ready to login)
+
+-- Step 1: Create the demo user in auth.users
+do $$
+declare
+ demo_uid uuid := gen_random_uuid();
+begin
+ -- Skip if demo user already exists
+ if exists (select 1 from auth.users where email = 'demo@nova40.app') then
+ raise notice 'Demo user already exists, skipping.';
+ return;
+ end if;
+
+ -- Insert into auth.users with minimal required columns
+ insert into auth.users (
+ id,
+ instance_id,
+ aud,
+ role,
+ email,
+ encrypted_password,
+ email_confirmed_at,
+ raw_app_meta_data,
+ raw_user_meta_data,
+ created_at,
+ updated_at,
+ confirmation_token
+ ) values (
+ demo_uid,
+ '00000000-0000-0000-0000-000000000000',
+ 'authenticated',
+ 'authenticated',
+ 'demo@nova40.app',
+ crypt('123456', gen_salt('bf')),
+ now(),
+ '{"provider": "email", "providers": ["email"]}'::jsonb,
+ '{"email": "demo@nova40.app"}'::jsonb,
+ now(),
+ now(),
+ ''
+ );
+
+ -- Insert into auth.identities (required for sign-in to work)
+ insert into auth.identities (
+ id,
+ user_id,
+ provider_id,
+ identity_data,
+ provider,
+ last_sign_in_at,
+ created_at,
+ updated_at
+ ) values (
+ demo_uid,
+ demo_uid,
+ 'demo@nova40.app',
+ jsonb_build_object(
+ 'sub', demo_uid::text,
+ 'email', 'demo@nova40.app',
+ 'email_verified', true
+ ),
+ 'email',
+ now(),
+ now(),
+ now()
+ );
+
+ raise notice 'Demo user created successfully (id: %)', demo_uid;
+end $$;
+
+-- Verify: you should see one row
+select id, email, email_confirmed_at from auth.users where email = 'demo@nova40.app';
diff --git a/supabase/migrations/003_fix_auth_and_seed_demo.sql b/supabase/migrations/003_fix_auth_and_seed_demo.sql
new file mode 100644
index 0000000..2cdddee
--- /dev/null
+++ b/supabase/migrations/003_fix_auth_and_seed_demo.sql
@@ -0,0 +1,107 @@
+-- Nova40: Fix auth schema and create demo user
+-- Run this in Supabase SQL Editor
+--
+-- This script:
+-- 1. Cleans up any broken demo user entries from previous seeder attempts
+-- 2. Verifies auth schema health
+-- 3. Creates a proper demo user
+
+-- ============================================
+-- STEP 1: Clean up any broken demo user data
+-- ============================================
+delete from auth.identities
+where user_id in (select id from auth.users where email = 'demo@nova40.app');
+
+delete from auth.users
+where email = 'demo@nova40.app';
+
+-- ============================================
+-- STEP 2: Verify auth schema is healthy
+-- ============================================
+-- This should return a number (0 is fine)
+select count(*) as total_users from auth.users;
+
+-- ============================================
+-- STEP 3: Create demo user properly
+-- ============================================
+-- We need the pgcrypto extension for crypt()
+create extension if not exists pgcrypto;
+
+do $$
+declare
+ new_uid uuid := gen_random_uuid();
+begin
+ -- Insert user
+ insert into auth.users (
+ id,
+ instance_id,
+ aud,
+ role,
+ email,
+ encrypted_password,
+ email_confirmed_at,
+ raw_app_meta_data,
+ raw_user_meta_data,
+ created_at,
+ updated_at,
+ confirmation_token,
+ recovery_token,
+ email_change_token_new,
+ email_change
+ ) values (
+ new_uid,
+ '00000000-0000-0000-0000-000000000000',
+ 'authenticated',
+ 'authenticated',
+ 'demo@nova40.app',
+ crypt('123456', gen_salt('bf')),
+ now(),
+ '{"provider": "email", "providers": ["email"]}'::jsonb,
+ '{"email": "demo@nova40.app"}'::jsonb,
+ now(),
+ now(),
+ '',
+ '',
+ '',
+ ''
+ );
+
+ -- Insert identity (REQUIRED for Supabase auth login to work)
+ insert into auth.identities (
+ id,
+ user_id,
+ provider_id,
+ identity_data,
+ provider,
+ last_sign_in_at,
+ created_at,
+ updated_at
+ ) values (
+ new_uid,
+ new_uid,
+ 'demo@nova40.app',
+ jsonb_build_object(
+ 'sub', new_uid::text,
+ 'email', 'demo@nova40.app',
+ 'email_verified', true
+ ),
+ 'email',
+ now(),
+ now(),
+ now()
+ );
+
+ raise notice '✅ Demo user created! ID: %', new_uid;
+end $$;
+
+-- ============================================
+-- STEP 4: Verify the user was created
+-- ============================================
+select
+ u.id,
+ u.email,
+ u.email_confirmed_at,
+ i.provider
+from auth.users u
+left join auth.identities i on i.user_id = u.id
+where u.email = 'demo@nova40.app';
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..0e6371f
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "compilerOptions": {},
+ "extends": "expo/tsconfig.base"
+}