diff --git a/.claude/worktrees/laughing-perlman-a6074b b/.claude/worktrees/laughing-perlman-a6074b new file mode 160000 index 0000000..0d71e6b --- /dev/null +++ b/.claude/worktrees/laughing-perlman-a6074b @@ -0,0 +1 @@ +Subproject commit 0d71e6b7bcd7e2ed83436a5a99cf7629014fcc8d diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/caches/deviceStreaming.xml b/.idea/caches/deviceStreaming.xml new file mode 100644 index 0000000..e2f2258 --- /dev/null +++ b/.idea/caches/deviceStreaming.xml @@ -0,0 +1,1784 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..c08a2df --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..7b3ffca --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/pantry-manager-android-app.iml b/.idea/pantry-manager-android-app.iml new file mode 100644 index 0000000..d6ebd48 --- /dev/null +++ b/.idea/pantry-manager-android-app.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/studiobot.xml b/.idea/studiobot.xml new file mode 100644 index 0000000..539e3b8 --- /dev/null +++ b/.idea/studiobot.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/App.js b/App.js index 09f879b..7564dc7 100644 --- a/App.js +++ b/App.js @@ -1,20 +1,74 @@ -import { StatusBar } from 'expo-status-bar'; -import { StyleSheet, Text, View } from 'react-native'; +import 'react-native-gesture-handler' +import { NavigationContainer } from '@react-navigation/native' +import { createBottomTabNavigator } from '@react-navigation/bottom-tabs' +import { createStackNavigator } from '@react-navigation/stack' +import { SafeAreaProvider } from 'react-native-safe-area-context' +import { StatusBar } from 'expo-status-bar' +import { Text, View } from 'react-native' + +import { AuthProvider, useAuth } from './src/context/AuthContext.jsx' +import HomeScreen from './src/screens/HomeScreen.jsx' +import InventoryScreen from './src/screens/InventoryScreen.jsx' +import SearchScreen from './src/screens/SearchScreen.jsx' +import BarcodeScreen from './src/screens/BarcodeScreen.jsx' +import ProfileScreen from './src/screens/ProfileScreen.jsx' +import ShoppingListsScreen from './src/screens/ShoppingListsScreen.jsx' +import MealPlannersScreen from './src/screens/MealPlannersScreen.jsx' +import AdminScreen from './src/screens/AdminScreen.jsx' +import UsersScreen from './src/screens/UsersScreen.jsx' +import { colors } from './src/theme.js' + +const Tab = createBottomTabNavigator() +const Stack = createStackNavigator() + +function TabIcon({ name, focused }) { + const icons = { + Home: '🏠', Inventory: '📦', Search: '🔍', Barcode: '📷', + Shopping: '🛒', Meals: '🍽️', Profile: '👤', Admin: '⚙️', Users: '👥', + } + return ( + {icons[name] || '•'} + ) +} + +function MainTabs() { + const { isSiteAdmin } = useAuth() + + return ( + ({ + tabBarIcon: ({ focused }) => , + tabBarActiveTintColor: colors.primary, + tabBarInactiveTintColor: colors.textMuted, + tabBarStyle: { backgroundColor: colors.surface, borderTopColor: colors.border }, + headerStyle: { backgroundColor: colors.surface }, + headerTintColor: colors.text, + headerTitleStyle: { fontWeight: '700' }, + tabBarLabelStyle: { fontSize: 10 }, + })} + > + + + + + + + + {isSiteAdmin && } + {isSiteAdmin && } + + ) +} 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 b65b607..3f9fda9 100644 --- a/app.json +++ b/app.json @@ -20,8 +20,27 @@ "foregroundImage": "./assets/adaptive-icon.png", "backgroundColor": "#ffffff" }, - "edgeToEdgeEnabled": true + "edgeToEdgeEnabled": true, + "permissions": [ + "CAMERA", + "READ_EXTERNAL_STORAGE", + "android.permission.CAMERA", + "android.permission.RECORD_AUDIO", + "CAMERA", + "READ_EXTERNAL_STORAGE", + "android.permission.CAMERA", + "android.permission.RECORD_AUDIO" + ], + "package": "com.anonymous.pantrymanagerandroidapp" }, + "plugins": [ + [ + "expo-camera", + { + "cameraPermission": "Allow Pantry Manager to use the camera for barcode scanning." + } + ] + ], "web": { "favicon": "./assets/favicon.png" } diff --git a/package-lock.json b/package-lock.json index 121118f..c8a4860 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,10 +8,21 @@ "name": "pantry-manager-android-app", "version": "1.0.0", "dependencies": { + "@react-native-async-storage/async-storage": "^3.0.2", + "@react-native-community/datetimepicker": "^9.1.0", + "@react-navigation/bottom-tabs": "^7.16.0", + "@react-navigation/native": "^7.2.4", + "@react-navigation/stack": "^7.9.0", "expo": "~54.0.33", + "expo-camera": "^55.0.18", "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.31.2", + "react-native-reanimated": "^4.3.1", + "react-native-safe-area-context": "^5.7.0", + "react-native-screens": "^4.25.0", + "react-native-worklets": "^0.8.3" } }, "node_modules/@0no-co/graphql.web": { @@ -1326,6 +1337,21 @@ "@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", + "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 +1541,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 +2738,42 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@react-native-async-storage/async-storage": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-3.0.2.tgz", + "integrity": "sha512-XP0zDIl+1XoeuQ7f878qXKdl77zLwzLALPpxvNRc7ZtDh9ew36WSvOdQOhFkexMySapFAWxEbZxS8K8J2DU4eg==", + "license": "MIT", + "dependencies": { + "idb": "8.0.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/@react-native-community/datetimepicker": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/@react-native-community/datetimepicker/-/datetimepicker-9.1.0.tgz", + "integrity": "sha512-eadbnk+I2vxvW30iTAsm/qlCnMMAadkifIMYNEB2lzhxN/SvlKc7S2V4k5DyrwjdCbqdcMk3t9K6fnUMcAV34w==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "expo": ">=52.0.0", + "react": "*", + "react-native": "*", + "react-native-windows": "*" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "react-native-windows": { + "optional": true + } + } + }, "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", @@ -2960,6 +3034,141 @@ "integrity": "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g==", "license": "MIT" }, + "node_modules/@react-navigation/bottom-tabs": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.16.0.tgz", + "integrity": "sha512-ShHaAR2mQxsSzL2kDRWfquomRsFfG+bm+UBRgu8D9ScbNBnnetzwOBestz/1DugVj89WtNapWJuHbMdgyCZEyQ==", + "license": "MIT", + "dependencies": { + "@react-navigation/elements": "^2.9.17", + "color": "^4.2.3", + "sf-symbols-typescript": "^2.1.0" + }, + "peerDependencies": { + "@react-navigation/native": "^7.2.4", + "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.4", + "resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-7.17.4.tgz", + "integrity": "sha512-Rv9E2oNNQEkPGpmu9q+vJwGJRSQR6LBg5L+Yo1QHjtwGbHUbjkIKOdYymDZoZYgNzX2OD4rAIlfuzbDKa3cCeA==", + "license": "MIT", + "dependencies": { + "@react-navigation/routers": "^7.5.5", + "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.6", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz", + "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==", + "license": "MIT" + }, + "node_modules/@react-navigation/elements": { + "version": "2.9.17", + "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.17.tgz", + "integrity": "sha512-Prax9RDS6l32npcl4PzvL88VoXe9HdtcIUP2+rim3DLVSZceD6oreA+cmPBUjeLFjsnxKlU3pTRby3RpYJ5/xw==", + "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.4", + "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.4", + "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.2.4.tgz", + "integrity": "sha512-eWC2D3JjhYLId2fVTZhhCiUpWIaPhO9XyEb7Wq8ElmOHyIODlbOzgZ0rKia02OIsDKr9BzZl2sK1dL70yMxDaw==", + "license": "MIT", + "dependencies": { + "@react-navigation/core": "^7.17.4", + "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/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.5", + "resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-7.5.5.tgz", + "integrity": "sha512-9/hhMte12Kgu+pMnLfA4EWJ0OQmIEAMVMX06FPH2yGkEQSQ3JhhCN/GkcRikzQhtEi97VYYQA15umptBUShcOQ==", + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11" + } + }, + "node_modules/@react-navigation/stack": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@react-navigation/stack/-/stack-7.9.0.tgz", + "integrity": "sha512-9ndJmQwPqIH9OJ0xXMUCx8V42IONkHcJWPmg3HJc40c9AJ6ZOPJjvSjXw8nT0en1YEN58z4MvKiNIgLXTQEKnA==", + "license": "MIT", + "dependencies": { + "@react-navigation/elements": "^2.9.17", + "color": "^4.2.3", + "use-latest-callback": "^0.2.4" + }, + "peerDependencies": { + "@react-navigation/native": "^7.2.4", + "react": ">= 18.2.0", + "react-native": "*", + "react-native-gesture-handler": ">= 2.0.0", + "react-native-safe-area-context": ">= 4.0.0", + "react-native-screens": ">= 4.0.0" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", @@ -3025,6 +3234,12 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/emscripten": { + "version": "1.41.5", + "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz", + "integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==", + "license": "MIT" + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -3034,6 +3249,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", @@ -3067,6 +3288,24 @@ "undici-types": "~7.21.0" } }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-test-renderer": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-19.1.0.tgz", + "integrity": "sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ==", + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -3541,6 +3780,15 @@ "node": "18 || 20 || >=22" } }, + "node_modules/barcode-detector": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/barcode-detector/-/barcode-detector-3.1.3.tgz", + "integrity": "sha512-omL3/x26oU9jlR0gUQcGdXIjQtMlrUGKF7xRFO1RwrQkRkRU7WLz0mgQEsdUtYBm2uX3JH+HQLrKlyTS/BxZRw==", + "license": "MIT", + "dependencies": { + "zxing-wasm": "3.0.3" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -3901,6 +4149,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", @@ -3916,6 +4177,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", @@ -4048,6 +4337,12 @@ "node": ">= 8" } }, + "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==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -4065,6 +4360,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", @@ -4320,6 +4624,26 @@ } } }, + "node_modules/expo-camera": { + "version": "55.0.18", + "resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-55.0.18.tgz", + "integrity": "sha512-Us/7JV6O1lHpLBGKJnK2s8gzmPcmMVJSV5586DBeO7x7AXzmvvVGtH+0nJRVIBE3MNzGzGWyfgievjr8QlE7dA==", + "license": "MIT", + "dependencies": { + "barcode-detector": "^3.0.0" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*", + "react-native-web": "*" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, "node_modules/expo-modules-autolinking": { "version": "3.0.25", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.25.tgz", @@ -4886,6 +5210,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", @@ -4913,6 +5243,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", @@ -5113,6 +5452,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", @@ -5173,6 +5527,12 @@ "node": ">= 14" } }, + "node_modules/idb": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz", + "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==", + "license": "ISC" + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -5258,6 +5618,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.2", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", @@ -7217,6 +7583,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", @@ -7269,6 +7653,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", @@ -7332,6 +7728,22 @@ } } }, + "node_modules/react-native-gesture-handler": { + "version": "2.31.2", + "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.31.2.tgz", + "integrity": "sha512-rw5q74i2AfS7YGYdbxQDhOU7xqgY6WRM1132/CCm3erqjblhECZDZFHIm0tteHoC9ih24wogVBVVzcTBQtZ+5A==", + "license": "MIT", + "dependencies": { + "@egjs/hammerjs": "^2.0.17", + "@types/react-test-renderer": "^19.1.0", + "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", @@ -7342,6 +7754,70 @@ "react-native": "*" } }, + "node_modules/react-native-reanimated": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.3.1.tgz", + "integrity": "sha512-KhGsS0YkCA+gusgyzlf9hnqzVPIR398KTpqXyqq/+yYJJPAvyEEPKcxlB0xtOOXSMrR2A9uRKVARVQhZwrOh+Q==", + "license": "MIT", + "dependencies": { + "react-native-is-edge-to-edge": "^1.3.1", + "semver": "^7.7.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "0.81 - 0.85", + "react-native-worklets": "0.8.x" + } + }, + "node_modules/react-native-safe-area-context": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.7.0.tgz", + "integrity": "sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-screens": { + "version": "4.25.0", + "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.25.0.tgz", + "integrity": "sha512-CoE6W0perui0W4WK9fZFJfikUql/AYQFSJjnOGoXcPeteFb5Tursfmkot3vPOSu9lKWQMO6tlCIBQTC1CgbVRw==", + "license": "MIT", + "dependencies": { + "react-freeze": "^1.0.0", + "warn-once": "^0.1.0" + }, + "peerDependencies": { + "react": "*", + "react-native": ">=0.82.0" + } + }, + "node_modules/react-native-worklets": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.8.3.tgz", + "integrity": "sha512-oCBJROyLU7yG/1R8s0INMflygTH71bx+5XcYkH0CM938TlhSoVbiunE1WVW5FZa51vwYqfLie/IXMX2s1Kh3eg==", + "license": "MIT", + "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", @@ -7818,6 +8294,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", @@ -7868,6 +8353,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", @@ -7929,6 +8423,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", @@ -7992,6 +8495,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", @@ -8122,6 +8634,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tar": { "version": "7.5.15", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz", @@ -8467,6 +8991,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", @@ -8519,6 +9061,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", @@ -8780,6 +9328,34 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zxing-wasm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-3.0.3.tgz", + "integrity": "sha512-DdOn/G5F+qvZELWeO5ZFFwcN611TfMybxPV0LUUoutUmiH2t47MZSB7gLV9O9YLhvudBdnzQNAoFOu4Xz8eOrQ==", + "license": "MIT", + "dependencies": { + "@types/emscripten": "^1.41.5", + "type-fest": "^5.6.0" + }, + "peerDependencies": { + "@types/emscripten": ">=1.39.6" + } + }, + "node_modules/zxing-wasm/node_modules/type-fest": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", + "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index 8577b83..bc3a518 100644 --- a/package.json +++ b/package.json @@ -4,15 +4,26 @@ "main": "index.js", "scripts": { "start": "expo start", - "android": "expo start --android", - "ios": "expo start --ios", + "android": "expo run:android", + "ios": "expo run:ios", "web": "expo start --web" }, "dependencies": { + "@react-native-async-storage/async-storage": "^3.0.2", + "@react-native-community/datetimepicker": "^9.1.0", + "@react-navigation/bottom-tabs": "^7.16.0", + "@react-navigation/native": "^7.2.4", + "@react-navigation/stack": "^7.9.0", "expo": "~54.0.33", +"expo-camera": "^55.0.18", "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.31.2", + "react-native-reanimated": "^4.3.1", + "react-native-safe-area-context": "^5.7.0", + "react-native-screens": "^4.25.0", + "react-native-worklets": "^0.8.3" }, "private": true } diff --git a/src/api/client.js b/src/api/client.js new file mode 100644 index 0000000..8f6b86d --- /dev/null +++ b/src/api/client.js @@ -0,0 +1,217 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' + +const SESSION_STORAGE_KEY = 'pantry-management-session' +const API_BASE_URL = 'https://api.pantrymanager.kitchen' + +let refreshPromise = null + +export class ApiError extends Error { + constructor(message, status = 0, data = null) { + super(message) + this.name = 'ApiError' + this.status = status + this.data = data + } +} + +function buildUrl(path) { + return `${API_BASE_URL}${path}` +} + +export async function getStoredSession() { + try { + const raw = await AsyncStorage.getItem(SESSION_STORAGE_KEY) + return raw ? JSON.parse(raw) : null + } catch { + return null + } +} + +export async function saveSession(session) { + await AsyncStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session)) + return session +} + +export async function clearSession() { + await AsyncStorage.removeItem(SESSION_STORAGE_KEY) +} + +async function readResponse(response) { + const text = await response.text() + if (!text) return null + try { + return JSON.parse(text) + } catch { + return text + } +} + +function extractErrorMessage(data, fallbackMessage) { + if (!data) return fallbackMessage + if (typeof data === 'string' && data.trim()) return data + if (typeof data.message === 'string' && data.message.trim()) return data.message + if (typeof data.Message === 'string' && data.Message.trim()) return data.Message + if (typeof data.error === 'string' && data.error.trim()) return data.error + if (data.errors && typeof data.errors === 'object') { + const messages = Object.values(data.errors) + .flatMap(value => Array.isArray(value) ? value : [value]) + .filter(Boolean) + if (messages.length > 0) return messages.join(' ') + } + if (typeof data.title === 'string' && data.title.trim()) return data.title + return fallbackMessage +} + +async function performRefresh(session) { + if (!session?.refreshToken) { + await clearSession() + throw new ApiError('Your session expired. Sign in again.', 401) + } + + let response + try { + response = await fetch(buildUrl('/api/auth/refresh-token'), { + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ accessToken: session.accessToken, refreshToken: session.refreshToken }), + }) + } catch (error) { + throw new ApiError('Unable to refresh your session. Make sure the API is reachable.', 0, error) + } + + const data = await readResponse(response) + + if (!response.ok) { + await clearSession() + throw new ApiError(extractErrorMessage(data, 'Your session expired. Sign in again.'), response.status, data) + } + + return saveSession({ + accessToken: data.accessToken, + refreshToken: data.refreshToken, + user: data.user ?? session.user ?? null, + }) +} + +async function refreshSession() { + if (!refreshPromise) { + const session = await getStoredSession() + refreshPromise = performRefresh(session).finally(() => { refreshPromise = null }) + } + return refreshPromise +} + +export async function requestJson(path, options = {}) { + const { method = 'GET', body, headers = {}, skipAuth = false, retryOnAuthFailure = true } = options + + const session = await getStoredSession() + const requestHeaders = { Accept: 'application/json', ...headers } + + if (body !== undefined) requestHeaders['Content-Type'] = 'application/json' + if (!skipAuth && session?.accessToken) requestHeaders.Authorization = `Bearer ${session.accessToken}` + + let response + try { + response = await fetch(buildUrl(path), { + method, + headers: requestHeaders, + body: body === undefined ? undefined : JSON.stringify(body), + }) + } catch (error) { + throw new ApiError('Unable to reach the API. Make sure the backend is running.', 0, error) + } + + const data = await readResponse(response) + + if (response.status === 401 && !skipAuth && retryOnAuthFailure && session?.refreshToken) { + await refreshSession() + return requestJson(path, { ...options, retryOnAuthFailure: false }) + } + + if (!response.ok) { + throw new ApiError( + extractErrorMessage(data, `${method} ${path} failed with status ${response.status}.`), + response.status, + data, + ) + } + + return data +} + +export const authApi = { + async register(payload) { + return requestJson('/api/auth/register', { method: 'POST', body: payload, skipAuth: true }) + }, + + async login(payload) { + const data = await requestJson('/api/auth/login', { method: 'POST', body: payload, skipAuth: true }) + await saveSession({ accessToken: data.accessToken, refreshToken: data.refreshToken, user: data.user ?? null }) + return data + }, + + async logout() { + try { + await requestJson('/api/auth/logout', { method: 'POST' }) + } finally { + await clearSession() + } + }, +} + +export const profileApi = { + getProfile() { return requestJson('/api/profile') }, + updateProfile(payload) { return requestJson('/api/profile', { method: 'PUT', body: payload }) }, +} + +export const locationsApi = { + getLocations() { return requestJson('/api/locations') }, + getLocationHistory(id) { return requestJson(`/api/locations/${id}/history`) }, + createLocation(payload) { return requestJson('/api/locations', { method: 'POST', body: payload }) }, + updateLocation(id, payload) { return requestJson(`/api/locations/${id}`, { method: 'PUT', body: payload }) }, + deleteLocation(id) { return requestJson(`/api/locations/${id}`, { method: 'DELETE' }) }, +} + +export const inventoryApi = { + getInventoryItems() { return requestJson('/api/inventoryitems') }, + getInventoryItem(id) { return requestJson(`/api/inventoryitems/${id}`) }, + createInventoryItem(payload) { return requestJson('/api/inventoryitems', { method: 'POST', body: payload }) }, + updateInventoryItem(id, payload) { return requestJson(`/api/inventoryitems/${id}`, { method: 'PUT', body: payload }) }, + deleteInventoryItem(id) { return requestJson(`/api/inventoryitems/${id}`, { method: 'DELETE' }) }, +} + +export const searchApi = { + searchLocations(query) { return requestJson(`/api/search/locations?q=${encodeURIComponent(query)}`) }, + searchItems(query) { return requestJson(`/api/search/items?q=${encodeURIComponent(query)}`) }, +} + +export const householdsApi = { + getHouseholds() { return requestJson('/api/households') }, + getHouseholdHistory(id) { return requestJson(`/api/households/${id}/history`) }, + createHousehold(payload) { return requestJson('/api/households', { method: 'POST', body: payload }) }, + updateHousehold(id, payload) { return requestJson(`/api/households/${id}`, { method: 'PUT', body: payload }) }, + inviteHouseholdMember(id, payload) { return requestJson(`/api/households/${id}/invite`, { method: 'POST', body: payload }) }, + leaveHousehold(id) { return requestJson(`/api/households/${id}/leave`, { method: 'DELETE' }) }, +} + +export const usersApi = { + getUsers() { return requestJson('/api/users') }, + getUser(id) { return requestJson(`/api/users/${id}`) }, + updateUser(id, payload) { return requestJson(`/api/users/${id}`, { method: 'PUT', body: payload }) }, +} + +export const shoppingListsApi = { + getShoppingLists() { return requestJson('/api/shoppinglists') }, + getShoppingList(id) { return requestJson(`/api/shoppinglists/${id}`) }, + createShoppingList(payload) { return requestJson('/api/shoppinglists', { method: 'POST', body: payload }) }, + updateShoppingList(id, payload) { return requestJson(`/api/shoppinglists/${id}`, { method: 'PUT', body: payload }) }, + deleteShoppingList(id) { return requestJson(`/api/shoppinglists/${id}`, { method: 'DELETE' }) }, +} + +export const mealPlannersApi = { + getMealPlanners() { return requestJson('/api/mealplanners') }, + getMealPlanner(id) { return requestJson(`/api/mealplanners/${id}`) }, + createMealPlanner(payload) { return requestJson('/api/mealplanners', { method: 'POST', body: payload }) }, + updateMealPlanner(id, payload) { return requestJson(`/api/mealplanners/${id}`, { method: 'PUT', body: payload }) }, + deleteMealPlanner(id) { return requestJson(`/api/mealplanners/${id}`, { method: 'DELETE' }) }, +} diff --git a/src/components/ui.jsx b/src/components/ui.jsx new file mode 100644 index 0000000..a2ba2cd --- /dev/null +++ b/src/components/ui.jsx @@ -0,0 +1,249 @@ +import { View, Text, TextInput, TouchableOpacity, ActivityIndicator, StyleSheet, Switch, ScrollView } from 'react-native' +import { colors, spacing, radius, fontSize } from '../theme.js' + +export function StatusBanner({ type = 'info', children }) { + const bgMap = { error: colors.errorBg, success: colors.successBg, info: colors.infoBg } + const borderMap = { error: colors.errorBorder, success: colors.successBorder, info: colors.infoBorder } + const textMap = { error: colors.errorText, success: colors.successText, info: colors.infoText } + return ( + + {children} + + ) +} + +export function Panel({ children, style }) { + return {children} +} + +export function SectionHeading({ title, right }) { + return ( + + {title} + {right && {right}} + + ) +} + +export function FieldGroup({ label, children }) { + return ( + + {label} + {children} + + ) +} + +export function InputField({ label, ...props }) { + return ( + + + + ) +} + +export function Btn({ title, onPress, variant = 'primary', disabled = false, style }) { + const bgMap = { primary: colors.primary, secondary: colors.secondary, danger: colors.danger } + const textColorMap = { primary: colors.primaryText, secondary: colors.secondaryText, danger: colors.dangerText } + const opacity = disabled ? 0.5 : 1 + return ( + + {title} + + ) +} + +export function BtnRow({ children }) { + return {children} +} + +export function Chip({ label, type = 'neutral' }) { + const bgMap = { neutral: colors.chipNeutral, success: colors.chipSuccess, warning: colors.chipWarning } + const textMap = { neutral: colors.chipNeutralText, success: colors.chipSuccessText, warning: colors.chipWarningText } + return ( + + {label} + + ) +} + +export function EmptyState({ children }) { + return ( + + {children} + + ) +} + +export function EntityRow({ selected, children, style }) { + return ( + + {children} + + ) +} + +export function EntityMeta({ children }) { + return {children} +} + +export function EntityActions({ children }) { + return {children} +} + +export function PageTitle({ children }) { + return {children} +} + +export function Divider() { + return +} + +export function FormNote({ children }) { + return {children} +} + +export function Picker({ label, selectedValue, onValueChange, children, enabled = true }) { + return ( + + + + {children} + + + + ) +} + +export function PickerBtn({ label, selected, onPress, disabled }) { + return ( + + {label} + + ) +} + +export function CheckRow({ label, value, onValueChange }) { + return ( + + {label} + + + ) +} + +export function LoadingSpinner() { + return ( + + + + ) +} + +const s = StyleSheet.create({ + banner: { + borderWidth: 1, + borderRadius: radius.sm, + padding: spacing.sm, + marginBottom: spacing.sm, + }, + bannerText: { fontSize: fontSize.sm }, + panel: { + backgroundColor: colors.surface, + borderRadius: radius.md, + padding: spacing.md, + marginBottom: spacing.md, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.06, + shadowRadius: 4, + elevation: 2, + }, + sectionHeading: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: spacing.md, + }, + sectionTitle: { fontSize: fontSize.lg, fontWeight: '700', color: colors.text }, + fieldGroup: { marginBottom: spacing.sm }, + label: { fontSize: fontSize.sm, color: colors.textSoft, marginBottom: spacing.xs, fontWeight: '500' }, + input: { + borderWidth: 1, + borderColor: colors.border, + borderRadius: radius.sm, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.sm, + fontSize: fontSize.md, + color: colors.text, + backgroundColor: colors.surface, + }, + btn: { + borderRadius: radius.sm, + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md, + alignItems: 'center', + justifyContent: 'center', + }, + btnText: { fontSize: fontSize.sm, fontWeight: '600' }, + btnRow: { flexDirection: 'row', gap: spacing.sm, flexWrap: 'wrap', marginTop: spacing.sm }, + chip: { + borderRadius: radius.sm, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + }, + chipText: { fontSize: fontSize.xs, fontWeight: '600' }, + emptyState: { padding: spacing.lg, alignItems: 'center' }, + emptyStateText: { color: colors.textMuted, fontSize: fontSize.sm, textAlign: 'center' }, + entityRow: { + borderWidth: 1, + borderColor: colors.border, + borderRadius: radius.sm, + padding: spacing.sm, + marginBottom: spacing.sm, + backgroundColor: colors.surface, + }, + entityRowSelected: { + borderColor: colors.selectedBorder, + backgroundColor: colors.selectedBg, + }, + entityMeta: { fontSize: fontSize.sm, color: colors.textMuted, marginTop: 2 }, + entityActions: { flexDirection: 'row', gap: spacing.xs, flexWrap: 'wrap', marginTop: spacing.sm }, + pageTitle: { fontSize: fontSize.xxl, fontWeight: '700', color: colors.text, marginBottom: spacing.xs }, + divider: { height: 1, backgroundColor: colors.border, marginBottom: spacing.md }, + formNote: { fontSize: fontSize.xs, color: colors.textMuted, marginTop: spacing.sm, lineHeight: 18 }, + pickerWrap: { + borderWidth: 1, + borderColor: colors.border, + borderRadius: radius.sm, + paddingVertical: spacing.xs, + paddingHorizontal: spacing.xs, + }, + pickerBtn: { + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderRadius: radius.sm, + marginRight: spacing.xs, + backgroundColor: colors.surfaceMuted, + }, + pickerBtnSelected: { backgroundColor: colors.primary }, + pickerBtnText: { fontSize: fontSize.sm, color: colors.textSoft }, + pickerBtnTextSelected: { color: colors.primaryText, fontWeight: '600' }, + checkRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: spacing.xs, + marginBottom: spacing.sm, + }, + spinnerWrap: { padding: spacing.xl, alignItems: 'center' }, +}) diff --git a/src/context/AuthContext.jsx b/src/context/AuthContext.jsx new file mode 100644 index 0000000..3c27cd7 --- /dev/null +++ b/src/context/AuthContext.jsx @@ -0,0 +1,95 @@ +import { createContext, useContext, useEffect, useState, useCallback } from 'react' +import { authApi, getStoredSession, profileApi, saveSession } from '../api/client.js' + +const AuthContext = createContext(null) +const SITE_ADMIN_ROLES = new Set(['Site Admin', 'Admin']) + +export function AuthProvider({ children }) { + const [session, setSession] = useState(null) + const [initializing, setInitializing] = useState(true) + + const setCurrentUserProfile = useCallback((profile) => { + setSession(current => { + if (!current) return current + const updated = { ...current, user: profile } + saveSession(updated) + return updated + }) + }, []) + + const refreshProfile = useCallback(async () => { + const currentSession = await getStoredSession() + if (!currentSession) return null + const profile = await profileApi.getProfile() + setCurrentUserProfile(profile) + return profile + }, [setCurrentUserProfile]) + + useEffect(() => { + let cancelled = false + + async function syncStoredSession() { + const existingSession = await getStoredSession() + if (!existingSession) { + if (!cancelled) setInitializing(false) + return + } + if (!cancelled) setSession(existingSession) + try { + const profile = await profileApi.getProfile() + if (!cancelled) { + const updated = { ...existingSession, user: profile } + await saveSession(updated) + setSession(updated) + } + } catch { + // session may be expired — client will clear it on next 401 + } finally { + if (!cancelled) setInitializing(false) + } + } + + syncStoredSession() + return () => { cancelled = true } + }, []) + + const login = useCallback(async (payload) => { + const data = await authApi.login(payload) + const newSession = { accessToken: data.accessToken, refreshToken: data.refreshToken, user: data.user ?? null } + setSession(newSession) + return data + }, []) + + const logout = useCallback(async () => { + await authApi.logout() + setSession(null) + }, []) + + const register = useCallback(async (payload) => { + return authApi.register(payload) + }, []) + + const user = session?.user ?? null + const userRoles = Array.isArray(user?.roles) ? user.roles : [] + + const value = { + session, + user, + isAuthenticated: Boolean(session?.accessToken), + isSiteAdmin: userRoles.some(role => SITE_ADMIN_ROLES.has(role)), + initializing, + login, + register, + logout, + refreshProfile, + setCurrentUserProfile, + } + + return {children} +} + +export function useAuth() { + const value = useContext(AuthContext) + if (!value) throw new Error('useAuth must be used inside an AuthProvider.') + return value +} diff --git a/src/screens/AdminScreen.jsx b/src/screens/AdminScreen.jsx new file mode 100644 index 0000000..45c1521 --- /dev/null +++ b/src/screens/AdminScreen.jsx @@ -0,0 +1,277 @@ +import { useEffect, useState } from 'react' +import { View, Text, ScrollView, StyleSheet, Alert } from 'react-native' +import { useAuth } from '../context/AuthContext.jsx' +import { householdsApi, usersApi } from '../api/client.js' +import { formatDate } from '../utils/searchUtils.js' +import { StatusBanner, Panel, SectionHeading, InputField, Btn, BtnRow, EmptyState, EntityRow, EntityMeta, EntityActions, FormNote } from '../components/ui.jsx' +import { colors, spacing, fontSize, radius } from '../theme.js' + +const EMPTY_HOUSEHOLD_FORM = { name: '', description: '' } +const EMPTY_USER_FORM = { firstName: '', lastName: '', email: '', password: '', confirmPassword: '' } + +function formatPersonName(p) { + const full = [p.firstName, p.lastName].filter(Boolean).join(' ') + return full || p.email || 'Unnamed person' +} + +export default function AdminScreen() { + const { isAuthenticated, isSiteAdmin, register } = useAuth() + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [status, setStatus] = useState('') + const [households, setHouseholds] = useState([]) + const [selectedHouseholdId, setSelectedHouseholdId] = useState('') + const [editingHouseholdId, setEditingHouseholdId] = useState('') + const [householdForm, setHouseholdForm] = useState(EMPTY_HOUSEHOLD_FORM) + const [inviteEmail, setInviteEmail] = useState('') + const [userForm, setUserForm] = useState(EMPTY_USER_FORM) + const [createdUser, setCreatedUser] = useState(null) + const [householdHistory, setHouseholdHistory] = useState([]) + const [historyLoading, setHistoryLoading] = useState(false) + const [historyError, setHistoryError] = useState('') + + async function loadHouseholds(preferredId = '') { + const r = await householdsApi.getHouseholds() + const next = Array.isArray(r) ? r : [] + setHouseholds(next) + setSelectedHouseholdId(id => { const t = preferredId || id; return next.some(h => h.id === t) ? t : next[0]?.id ?? '' }) + setEditingHouseholdId(id => next.some(h => h.id === id) ? id : '') + } + + useEffect(() => { + let cancelled = false + async function init() { + if (!isAuthenticated) { setHouseholds([]); return } + setLoading(true); setError('') + try { + const r = await householdsApi.getHouseholds() + if (cancelled) return + const next = Array.isArray(r) ? r : [] + setHouseholds(next); setSelectedHouseholdId(next[0]?.id ?? '') + } catch (e) { if (!cancelled) setError(e.message) } + finally { if (!cancelled) setLoading(false) } + } + init() + return () => { cancelled = true } + }, [isAuthenticated]) + + useEffect(() => { + let cancelled = false + async function loadHistory() { + if (!isAuthenticated || !selectedHouseholdId) { setHouseholdHistory([]); return } + setHistoryLoading(true); setHistoryError('') + try { const r = await householdsApi.getHouseholdHistory(selectedHouseholdId); if (!cancelled) setHouseholdHistory(Array.isArray(r) ? r : []) } + catch (e) { if (!cancelled) { setHouseholdHistory([]); setHistoryError(e.message) } } + finally { if (!cancelled) setHistoryLoading(false) } + } + loadHistory() + return () => { cancelled = true } + }, [isAuthenticated, selectedHouseholdId]) + + const selectedHousehold = households.find(h => h.id === selectedHouseholdId) ?? null + const editingHousehold = households.find(h => h.id === editingHouseholdId) ?? null + const canManageSelected = Boolean(selectedHousehold && (isSiteAdmin || selectedHousehold.isCurrentUserHouseholdAdmin)) + const canSubmitForm = editingHouseholdId ? Boolean(editingHousehold && (isSiteAdmin || editingHousehold.isCurrentUserHouseholdAdmin)) : isSiteAdmin + + async function submitHousehold() { + const name = householdForm.name.trim() + if (!name) { setError('Household name is required.'); return } + if (!canSubmitForm) { setError(editingHouseholdId ? 'You can only edit households you administer.' : 'Only site admins can create households.'); return } + setLoading(true); setError(''); setStatus('') + try { + const payload = { name, description: householdForm.description.trim() || null } + const result = editingHouseholdId ? await householdsApi.updateHousehold(editingHouseholdId, payload) : await householdsApi.createHousehold(payload) + await loadHouseholds(result?.id ?? editingHouseholdId) + setEditingHouseholdId(''); setHouseholdForm(EMPTY_HOUSEHOLD_FORM) + setStatus(editingHouseholdId ? 'Household updated.' : 'Household created.') + } catch (e) { setError(e.message) } + finally { setLoading(false) } + } + + async function submitInvite() { + const email = inviteEmail.trim() + if (!selectedHouseholdId) { setError('Select a household first.'); return } + if (!email) { setError('Email is required.'); return } + if (!canManageSelected) { setError('You can only invite users to households you administer.'); return } + setLoading(true); setError(''); setStatus('') + try { await householdsApi.inviteHouseholdMember(selectedHouseholdId, { email }); await loadHouseholds(selectedHouseholdId); setInviteEmail(''); setStatus('Invitation sent.') } + catch (e) { setError(e.message) } + finally { setLoading(false) } + } + + async function submitCreateUser() { + if (!isSiteAdmin) { setError('Only site admins can create users.'); return } + const email = userForm.email.trim() + if (!email) { setError('Email is required.'); return } + if (!userForm.password) { setError('Password is required.'); return } + if (userForm.password !== userForm.confirmPassword) { setError('Passwords do not match.'); return } + setLoading(true); setError(''); setStatus('') + try { + const result = await register({ email, password: userForm.password, confirmPassword: userForm.confirmPassword, firstName: userForm.firstName.trim() || null, lastName: userForm.lastName.trim() || null }) + try { + const users = await usersApi.getUsers() + const match = Array.isArray(users) ? users.find(u => u.email?.toLowerCase() === email.toLowerCase()) : null + setCreatedUser(match ?? { email, firstName: userForm.firstName.trim(), lastName: userForm.lastName.trim(), roles: [] }) + } catch { setCreatedUser({ email, firstName: userForm.firstName.trim(), lastName: userForm.lastName.trim(), roles: [] }) } + setUserForm(EMPTY_USER_FORM); setStatus(result?.message || 'User created.') + } catch (e) { setError(e.message) } + finally { setLoading(false) } + } + + async function handleLeave(id) { + Alert.alert('Leave household?', undefined, [ + { text: 'Cancel' }, + { text: 'Leave', style: 'destructive', onPress: async () => { + setLoading(true); setError(''); setStatus('') + try { await householdsApi.leaveHousehold(id); await loadHouseholds(selectedHouseholdId === id ? '' : selectedHouseholdId); setStatus('Household left.') } + catch (e) { setError(e.message) } + finally { setLoading(false) } + }}, + ]) + } + + if (!isAuthenticated) return ( + Sign in to access admin features. + ) + + const totalMembers = households.reduce((n, h) => n + (h.members?.length ?? 0), 0) + const managedCount = households.filter(h => h.isCurrentUserHouseholdAdmin || isSiteAdmin).length + + return ( + + {error ? {error} : null} + {status ? {status} : null} + {loading ? Processing... : null} + + + + + Households{households.length} + Managed{managedCount} + Members{totalMembers} + + + + + { setEditingHouseholdId(''); setHouseholdForm(EMPTY_HOUSEHOLD_FORM) }} /> : null} /> + {!isSiteAdmin && !editingHouseholdId && Only site admins can create households.} + setHouseholdForm(f => ({ ...f, name: v }))} placeholder="Main Household" /> + setHouseholdForm(f => ({ ...f, description: v }))} placeholder="Shared pantry access" multiline /> + + + { setEditingHouseholdId(''); setHouseholdForm(EMPTY_HOUSEHOLD_FORM) }} /> + + + + + {selectedHousehold?.name || 'None selected'}} /> + {!selectedHousehold ? Select a household to invite a member. : ( + <> + {!canManageSelected && You can only invite users to households you administer.} + + + + setInviteEmail('')} /> + + + )} + + + + + {isSiteAdmin ? 'Creates a new account via the register endpoint.' : 'Only site admins can create users.'} + setUserForm(f => ({ ...f, firstName: v }))} placeholder="Jordan" editable={isSiteAdmin} /> + setUserForm(f => ({ ...f, lastName: v }))} placeholder="Lee" editable={isSiteAdmin} /> + setUserForm(f => ({ ...f, email: v }))} keyboardType="email-address" autoCapitalize="none" placeholder="new@example.com" editable={isSiteAdmin} /> + setUserForm(f => ({ ...f, password: v }))} secureTextEntry placeholder="Password" editable={isSiteAdmin} /> + setUserForm(f => ({ ...f, confirmPassword: v }))} secureTextEntry placeholder="Repeat password" editable={isSiteAdmin} /> + + + setUserForm(EMPTY_USER_FORM)} disabled={!isSiteAdmin} /> + + {createdUser && ( + + {formatPersonName(createdUser)} + {createdUser.email} + {createdUser.id && ID: {createdUser.id}} + + {(Array.isArray(createdUser.roles) && createdUser.roles.length > 0 + ? createdUser.roles + : ['No roles assigned'] + ).map((r, i) => {r})} + + + )} + + + + {selectedHousehold?.name || 'None selected'}} /> + {historyLoading ? Loading history... : null} + {historyError ? {historyError} : null} + {!selectedHousehold ? Select a household to see its history. : + householdHistory.length === 0 ? No history returned. : + householdHistory.map(entry => ( + + {entry.action} + {formatDate(entry.changedAt) || 'Unknown'} by {entry.changedByEmail || 'Unknown'} + {entry.description || 'No description.'} + {entry.affectedUserEmail && Affected: {entry.affectedUserEmail}} + + )) + } + + + + {households.length} total} /> + {households.length === 0 ? No households yet. : households.map(h => { + const canManage = isSiteAdmin || h.isCurrentUserHouseholdAdmin + return ( + + + {h.name} + + {h.isCurrentUserHouseholdAdmin && Household admin} + + + {h.description || 'No description.'} + Created: {formatDate(h.createdAt) || 'Not available'} + {(h.members ?? []).map(member => ( + + {formatPersonName(member)} + {member.email} + + ))} + + setSelectedHouseholdId(h.id)} /> + {canManage && { setSelectedHouseholdId(h.id); setEditingHouseholdId(h.id); setHouseholdForm({ name: h.name ?? '', description: h.description ?? '' }) }} />} + handleLeave(h.id)} /> + + + ) + })} + + + ) +} + +const s = StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.bg }, + content: { padding: spacing.md }, + authMsg: { padding: spacing.lg, textAlign: 'center', color: colors.textMuted, fontSize: fontSize.md }, + statsGrid: { flexDirection: 'row', gap: spacing.sm }, + statCard: { flex: 1, backgroundColor: colors.surfaceMuted, borderRadius: radius.sm, padding: spacing.sm, alignItems: 'center' }, + statLabel: { fontSize: fontSize.xs, color: colors.textMuted }, + statValue: { fontSize: fontSize.xl, fontWeight: '700', color: colors.text }, + strongText: { fontSize: fontSize.md, fontWeight: '600', color: colors.text }, + subtleText: { fontSize: fontSize.sm, color: colors.textMuted }, + householdHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, + badgeRow: { flexDirection: 'row', gap: spacing.xs }, + adminBadge: { backgroundColor: colors.accent + '22', borderRadius: radius.sm, paddingHorizontal: spacing.xs, paddingVertical: 2 }, + badgeText: { fontSize: fontSize.xs, color: colors.accent }, + memberRow: { paddingVertical: spacing.xs, borderTopWidth: 1, borderColor: colors.border, marginTop: spacing.xs }, + memberName: { fontSize: fontSize.sm, fontWeight: '600', color: colors.text }, + createdUserCard: { marginTop: spacing.sm, padding: spacing.sm, borderWidth: 1, borderColor: colors.border, borderRadius: radius.sm }, + roleList: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.xs, marginTop: spacing.xs }, + roleBadge: { backgroundColor: colors.chipNeutral, borderRadius: radius.sm, paddingHorizontal: spacing.xs, paddingVertical: 2 }, + roleBadgeText: { fontSize: fontSize.xs, color: colors.textSoft }, +}) diff --git a/src/screens/BarcodeScreen.jsx b/src/screens/BarcodeScreen.jsx new file mode 100644 index 0000000..86b7b29 --- /dev/null +++ b/src/screens/BarcodeScreen.jsx @@ -0,0 +1,354 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { View, Text, ScrollView, StyleSheet, Alert, TextInput, TouchableOpacity } from 'react-native' +import { CameraView, useCameraPermissions } from 'expo-camera' +import { useAuth } from '../context/AuthContext.jsx' +import { inventoryApi, locationsApi } from '../api/client.js' +import { buildInventoryPayload, createItemForm, findItemsByBarcode, mapItemToForm, normalizeBarcode } from '../utils/inventoryItemUtils.js' +import { formatAmount, formatDate } from '../utils/searchUtils.js' +import { StatusBanner, Panel, SectionHeading, InputField, Btn, BtnRow, EmptyState, EntityRow, EntityMeta, EntityActions, FieldGroup, PickerBtn, FormNote } from '../components/ui.jsx' +import { colors, spacing, fontSize, radius } from '../theme.js' +import DateTimePicker from '@react-native-community/datetimepicker' + +const MATCH_CANDIDATE_LIMIT = 10 + +function getMatchCandidates(items, query, scannedBarcode) { + const normalizedQ = query.trim().toLowerCase() + const normalizedB = normalizeBarcode(scannedBarcode) + return [...items] + .filter(item => normalizeBarcode(item.barcode) !== normalizedB) + .sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')) + .filter(item => { + if (!normalizedQ) return true + const text = [item.name ?? '', item.location?.name ?? '', item.barcode ?? ''].join(' ').toLowerCase() + return text.includes(normalizedQ) + }) + .slice(0, MATCH_CANDIDATE_LIMIT) +} + +export default function BarcodeScreen() { + const { isAuthenticated } = useAuth() + const [permission, requestPermission] = useCameraPermissions() + const [cameraActive, setCameraActive] = useState(false) + const [mode, setMode] = useState('camera') + const [manualInput, setManualInput] = useState('') + const [lastScannedBarcode, setLastScannedBarcode] = useState('') + const [locations, setLocations] = useState([]) + const [items, setItems] = useState([]) + const [inventoryLoading, setInventoryLoading] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState('') + const [status, setStatus] = useState('') + const [editorMode, setEditorMode] = useState('') + const [editingItemId, setEditingItemId] = useState('') + const [itemForm, setItemForm] = useState(createItemForm()) + const [matchQuery, setMatchQuery] = useState('') + const [showExpiryPicker, setShowExpiryPicker] = useState(false) + const [showUseByPicker, setShowUseByPicker] = useState(false) + const lastCameraScanRef = useRef({ barcode: '', at: 0 }) + + const matchingItems = findItemsByBarcode(items, lastScannedBarcode) + const hasScannedBarcode = Boolean(normalizeBarcode(lastScannedBarcode)) + const hasExactMatches = matchingItems.length > 0 + const quickAddSourceItem = matchingItems.find(i => i.id === editingItemId) ?? matchingItems[0] ?? null + const matchCandidates = getMatchCandidates(items, matchQuery, lastScannedBarcode) + + async function refreshInventory() { + const [locs, inv] = await Promise.all([locationsApi.getLocations(), inventoryApi.getInventoryItems()]) + setLocations(locs); setItems(inv) + } + + useEffect(() => { + let cancelled = false + async function init() { + if (!isAuthenticated) { setLocations([]); setItems([]); return } + setInventoryLoading(true); setError('') + try { + const [locs, inv] = await Promise.all([locationsApi.getLocations(), inventoryApi.getInventoryItems()]) + if (!cancelled) { setLocations(locs); setItems(inv) } + } catch (e) { if (!cancelled) setError(e.message) } + finally { if (!cancelled) setInventoryLoading(false) } + } + init() + return () => { cancelled = true } + }, [isAuthenticated]) + + function onBarcodeScanned(barcode) { + const normalized = normalizeBarcode(barcode) + if (!normalized) return + setLastScannedBarcode(normalized) + setManualInput(normalized) + setEditingItemId('') + setEditorMode('') + setItemForm(createItemForm(normalized)) + setMatchQuery('') + setError('') + setStatus('') + } + + function handleCameraScan({ data }) { + const now = Date.now() + const prev = lastCameraScanRef.current + if (prev.barcode === data && now - prev.at < 1500) return + lastCameraScanRef.current = { barcode: data, at: now } + onBarcodeScanned(data) + } + + function clearScan() { + setLastScannedBarcode(''); setManualInput(''); setEditorMode(''); setEditingItemId('') + setItemForm(createItemForm()); setMatchQuery(''); setError(''); setStatus('') + lastCameraScanRef.current = { barcode: '', at: 0 } + } + + async function handleQuickAdd() { + if (!hasScannedBarcode) return + if (!quickAddSourceItem) { + setEditorMode('create') + setStatus('No exact match found. Complete the form to add a new item.') + return + } + setSubmitting(true); setError(''); setStatus('') + try { + await inventoryApi.createInventoryItem(buildInventoryPayload({ ...mapItemToForm(quickAddSourceItem), expiryDate: '', useByDate: '' })) + await refreshInventory() + setStatus(`Quick added ${quickAddSourceItem.name || 'item'}.`) + } catch (e) { setError(e.message) } + finally { setSubmitting(false) } + } + + async function handleQuickRemove() { + if (!hasScannedBarcode || !quickAddSourceItem) return + setSubmitting(true); setError(''); setStatus('') + try { + await inventoryApi.deleteInventoryItem(quickAddSourceItem.id) + await refreshInventory() + setStatus(`Quick removed ${quickAddSourceItem.name || 'item'}.`) + } catch (e) { setError(e.message) } + finally { setSubmitting(false) } + } + + async function handleRefreshInventory() { + setInventoryLoading(true); setError('') + try { await refreshInventory(); setStatus('Inventory refreshed.') } + catch (e) { setError(e.message) } + finally { setInventoryLoading(false) } + } + + async function handleLoadItemForEdit(itemId) { + setSubmitting(true); setError(''); setStatus('') + try { const item = await inventoryApi.getInventoryItem(itemId); setEditingItemId(item.id); setEditorMode('update'); setItemForm(mapItemToForm(item)) } + catch (e) { setError(e.message) } + finally { setSubmitting(false) } + } + + async function handleItemSubmit() { + const name = itemForm.name.trim(); const barcode = itemForm.barcode.trim() + if (!name && !barcode) { setError('Provide an item name or barcode.'); return } + setSubmitting(true); setError(''); setStatus('') + try { + let itemId = editingItemId + if (editorMode === 'update' && editingItemId) { + await inventoryApi.updateInventoryItem(editingItemId, buildInventoryPayload(itemForm, true)); setStatus('Item updated.') + } else { + const created = await inventoryApi.createInventoryItem(buildInventoryPayload(itemForm)); itemId = created.id; setStatus('Item created.') + } + await refreshInventory() + if (itemId) { + const fresh = await inventoryApi.getInventoryItem(itemId) + setEditingItemId(fresh.id); setEditorMode('update'); setItemForm(mapItemToForm(fresh)) + const savedBarcode = normalizeBarcode(fresh.barcode) + if (savedBarcode) { setLastScannedBarcode(savedBarcode); setManualInput(savedBarcode) } + } + } catch (e) { setError(e.message) } + finally { setSubmitting(false) } + } + + async function handleMatchItem(itemId, itemName, currentBarcode) { + const msg = currentBarcode + ? `Replace the barcode on ${itemName} with ${lastScannedBarcode}?` + : `Assign barcode ${lastScannedBarcode} to ${itemName}?` + Alert.alert('Match Barcode', msg, [ + { text: 'Cancel' }, + { text: 'Match', onPress: async () => { + setSubmitting(true); setError(''); setStatus('') + try { + const item = await inventoryApi.getInventoryItem(itemId) + await inventoryApi.updateInventoryItem(itemId, buildInventoryPayload({ ...mapItemToForm(item), barcode: lastScannedBarcode }, true)) + await refreshInventory() + const fresh = await inventoryApi.getInventoryItem(itemId) + setEditingItemId(fresh.id); setEditorMode('update'); setItemForm(mapItemToForm(fresh)) + setStatus(`Barcode ${lastScannedBarcode} matched to ${fresh.name}.`) + } catch (e) { setError(e.message) } + finally { setSubmitting(false) } + }}, + ]) + } + + if (!isAuthenticated) return ( + Sign in to use the barcode scanner. + ) + + return ( + + {error ? {error} : null} + {status ? {status} : null} + {inventoryLoading ? Refreshing inventory... : null} + {submitting ? Saving... : null} + + + + setMode('camera')}> + Camera + + setMode('manual')}> + Manual + + + + {hasScannedBarcode && ( + + {lastScannedBarcode} + + {hasExactMatches ? `${matchingItems.length} match${matchingItems.length !== 1 ? 'es' : ''}` : 'No exact matches'} + + + )} + + {mode === 'camera' ? ( + <> + {!permission?.granted ? ( + + Camera permission required. + + + ) : !cameraActive ? ( + + Camera inactive. Tap Start to scan. + + ) : ( + + + + + + )} + + {!cameraActive + ? setCameraActive(true)} /> + : setCameraActive(false)} /> + } + + + + + + ) : ( + <> + onBarcodeScanned(manualInput)} returnKeyType="go" /> + + onBarcodeScanned(manualInput)} /> + + + setManualInput('')} /> + + + + )} + + {hasScannedBarcode && } + + + + {matchingItems.length} exact : null} /> + {!hasScannedBarcode ? Scan a barcode to search your inventory. : + !hasExactMatches ? No items match barcode {lastScannedBarcode}. : + matchingItems.map(item => ( + + {item.name}Match + {item.location?.name || 'No location'} + Amount: {formatAmount(item.amount, item.amountType)} + Expiry: {formatDate(item.expiryDate) || 'Not set'} + Barcode: {item.barcode || 'Not set'} + handleLoadItemForEdit(item.id)} disabled={submitting} /> + + )) + } + + + + { setEditorMode(''); setEditingItemId(''); setItemForm(createItemForm(lastScannedBarcode)) }} /> : null} /> + {!editorMode ? ( + Use Quick Add to clone a match, or Update Item on a match to edit it here. + ) : ( + <> + setItemForm(f => ({ ...f, name: v }))} placeholder="Whole Milk" /> + setItemForm(f => ({ ...f, barcode: v }))} placeholder="01234567890" keyboardType="numeric" /> + + + setItemForm(f => ({ ...f, locationId: '' }))} /> + {locations.map(loc => setItemForm(f => ({ ...f, locationId: loc.id }))} />)} + + + setItemForm(f => ({ ...f, amount: v }))} placeholder="2" keyboardType="decimal-pad" /> + setItemForm(f => ({ ...f, amountType: v }))} placeholder="litres" /> + + setShowExpiryPicker(true)} /> + {showExpiryPicker && { setShowExpiryPicker(false); if (d) setItemForm(f => ({ ...f, expiryDate: d.toISOString().slice(0, 10) })) }} />} + + + setShowUseByPicker(true)} /> + {showUseByPicker && { setShowUseByPicker(false); if (d) setItemForm(f => ({ ...f, useByDate: d.toISOString().slice(0, 10) })) }} />} + + + + setItemForm(createItemForm(lastScannedBarcode))} disabled={submitting} /> + + + )} + + + {hasScannedBarcode && !hasExactMatches && ( + + Assign {lastScannedBarcode}} /> + + {matchCandidates.length === 0 ? No items match that filter. : + matchCandidates.map(item => ( + + {item.name} + {item.location?.name || 'No location'} | {formatAmount(item.amount, item.amountType)} + Current barcode: {item.barcode || 'Not set'} + handleMatchItem(item.id, item.name, item.barcode)} disabled={submitting} /> + + )) + } + + )} + + ) +} + +const s = StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.bg }, + content: { padding: spacing.md }, + authMsg: { padding: spacing.lg, textAlign: 'center', color: colors.textMuted, fontSize: fontSize.md }, + modeToggle: { flexDirection: 'row', borderRadius: radius.sm, borderWidth: 1, borderColor: colors.border, overflow: 'hidden', marginBottom: spacing.sm }, + modeBtn: { flex: 1, paddingVertical: spacing.sm, alignItems: 'center', backgroundColor: colors.surfaceMuted }, + modeBtnActive: { backgroundColor: colors.primary }, + modeBtnText: { fontSize: fontSize.sm, color: colors.textSoft, fontWeight: '600' }, + modeBtnTextActive: { color: colors.primaryText }, + cameraFrame: { height: 240, borderRadius: radius.sm, overflow: 'hidden', marginBottom: spacing.sm }, + camera: { flex: 1 }, + cameraPlaceholder: { height: 180, borderRadius: radius.sm, backgroundColor: colors.surfaceMuted, justifyContent: 'center', alignItems: 'center', marginBottom: spacing.sm }, + cameraPlaceholderText: { color: colors.textMuted, fontSize: fontSize.sm, textAlign: 'center' }, + scanOverlay: { ...StyleSheet.absoluteFillObject, justifyContent: 'center', alignItems: 'center' }, + scanWindow: { width: 200, height: 120, borderWidth: 2, borderColor: colors.primary, borderRadius: radius.sm }, + scanStatus: { flexDirection: 'row', gap: spacing.xs, marginBottom: spacing.sm, flexWrap: 'wrap' }, + scanChip: { borderRadius: radius.sm, paddingHorizontal: spacing.sm, paddingVertical: spacing.xs }, + scanChipBarcode: { borderRadius: radius.sm, paddingHorizontal: spacing.sm, paddingVertical: spacing.xs, backgroundColor: colors.chipNeutral }, + scanChipSuccess: { backgroundColor: colors.chipSuccess }, + scanChipWarning: { backgroundColor: colors.chipWarning }, + scanChipText: { fontSize: fontSize.xs, fontWeight: '600', color: colors.textSoft }, + rowHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, + strongText: { fontSize: fontSize.md, fontWeight: '600', color: colors.text }, + subtleText: { fontSize: fontSize.sm, color: colors.textMuted }, +}) diff --git a/src/screens/HomeScreen.jsx b/src/screens/HomeScreen.jsx new file mode 100644 index 0000000..c541ea0 --- /dev/null +++ b/src/screens/HomeScreen.jsx @@ -0,0 +1,150 @@ +import { useEffect, useState } from 'react' +import { View, Text, ScrollView, StyleSheet, Alert } from 'react-native' +import { useAuth } from '../context/AuthContext.jsx' +import { inventoryApi, locationsApi } from '../api/client.js' +import { getExpiryStatus, formatAmount, formatDate } from '../utils/searchUtils.js' +import { StatusBanner, Panel, SectionHeading, InputField, Btn, BtnRow, PageTitle, Divider, EmptyState, EntityMeta } from '../components/ui.jsx' +import { colors, spacing, fontSize, radius } from '../theme.js' + +const PIE_COLORS = ['#2563eb', '#0f766e', '#9333ea', '#ea580c', '#dc2626', '#0891b2'] + +function sortByExpiry(items) { + return [...items].sort((a, b) => { + const aDate = a.expiryDate ? new Date(a.expiryDate).getTime() : Number.MAX_SAFE_INTEGER + const bDate = b.expiryDate ? new Date(b.expiryDate).getTime() : Number.MAX_SAFE_INTEGER + return aDate - bDate + }) +} + +export default function HomeScreen() { + const { initializing, isAuthenticated, login } = useAuth() + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [formError, setFormError] = useState('') + const [submitting, setSubmitting] = useState(false) + const [locations, setLocations] = useState([]) + const [items, setItems] = useState([]) + const [loading, setLoading] = useState(false) + const [dashboardError, setDashboardError] = useState('') + + useEffect(() => { + let cancelled = false + async function load() { + if (!isAuthenticated) { setLocations([]); setItems([]); return } + setLoading(true) + setDashboardError('') + try { + const [locs, inv] = await Promise.all([locationsApi.getLocations(), inventoryApi.getInventoryItems()]) + if (!cancelled) { setLocations(locs); setItems(inv) } + } catch (e) { + if (!cancelled) setDashboardError(e.message) + } finally { + if (!cancelled) setLoading(false) + } + } + load() + return () => { cancelled = true } + }, [isAuthenticated]) + + async function handleLogin() { + setFormError('') + if (!email.trim()) { setFormError('Email is required.'); return } + if (!password) { setFormError('Password is required.'); return } + setSubmitting(true) + try { + await login({ email: email.trim(), password }) + setEmail('') + setPassword('') + } catch (e) { + setFormError(e.message) + } finally { + setSubmitting(false) + } + } + + const expiringSoon = items.filter(i => { const s = getExpiryStatus(i.expiryDate); return s.status === 'Soon' || s.status === 'Today' }) + const expiringItems = sortByExpiry(items).filter(i => i.expiryDate).slice(0, 3) + + if (!isAuthenticated) { + return ( + + {initializing && Restoring your session...} + {formError ? {formError} : null} + + Pantry Manager + Sign in to continue + + + + + + + + ) + } + + return ( + + {loading && Loading dashboard...} + {dashboardError ? {dashboardError} : null} + + + + + Locations{locations.length} + Items{items.length} + Expiring Soon{expiringSoon.length} + + + + + + {locations.length === 0 ? No locations yet. : ( + locations.map((loc, i) => { + const count = items.filter(item => item.location?.id === loc.id).length + return ( + + + {loc.name} + {count} item{count !== 1 ? 's' : ''} + + ) + }) + )} + + + + + {expiringItems.length === 0 ? No dated inventory items yet. : ( + expiringItems.map(item => { + const exp = getExpiryStatus(item.expiryDate) + return ( + + {item.name} + {item.location?.name || 'No location'} | {formatAmount(item.amount, item.amountType)} + {formatDate(item.expiryDate)} — {exp.text} + + ) + }) + )} + + + ) +} + +const s = StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.bg }, + content: { padding: spacing.md }, + loginTitle: { fontSize: fontSize.xxl, fontWeight: '700', color: colors.text, textAlign: 'center', marginBottom: spacing.xs }, + loginSubtitle: { fontSize: fontSize.md, color: colors.textMuted, textAlign: 'center', marginBottom: spacing.lg }, + statsGrid: { flexDirection: 'row', gap: spacing.sm }, + statCard: { flex: 1, backgroundColor: colors.surfaceMuted, borderRadius: radius.sm, padding: spacing.sm, alignItems: 'center' }, + statLabel: { fontSize: fontSize.xs, color: colors.textMuted, marginBottom: spacing.xs }, + statValue: { fontSize: fontSize.xl, fontWeight: '700', color: colors.text }, + locationBar: { flexDirection: 'row', alignItems: 'center', paddingVertical: spacing.xs }, + locationDot: { width: 10, height: 10, borderRadius: 5, marginRight: spacing.sm }, + locationBarLabel: { flex: 1, fontSize: fontSize.sm, color: colors.text }, + locationBarCount: { fontSize: fontSize.sm, color: colors.textMuted }, + expiryCard: { borderLeftWidth: 3, paddingLeft: spacing.sm, marginBottom: spacing.sm }, + expiryName: { fontSize: fontSize.md, fontWeight: '600', color: colors.text }, +}) diff --git a/src/screens/InventoryScreen.jsx b/src/screens/InventoryScreen.jsx new file mode 100644 index 0000000..03aee73 --- /dev/null +++ b/src/screens/InventoryScreen.jsx @@ -0,0 +1,237 @@ +import { useEffect, useState } from 'react' +import { View, Text, ScrollView, StyleSheet, Alert, TextInput } from 'react-native' +import DateTimePicker from '@react-native-community/datetimepicker' +import { useAuth } from '../context/AuthContext.jsx' +import { inventoryApi, locationsApi } from '../api/client.js' +import { buildInventoryPayload, createItemForm, mapItemToForm } from '../utils/inventoryItemUtils.js' +import { formatAmount, formatDate, getExpiryStatus } from '../utils/searchUtils.js' +import { StatusBanner, Panel, SectionHeading, FieldGroup, InputField, Btn, BtnRow, EmptyState, EntityRow, EntityMeta, EntityActions, PageTitle, Divider, FormNote, PickerBtn } from '../components/ui.jsx' +import { colors, spacing, fontSize, radius } from '../theme.js' + +const EMPTY_LOCATION_FORM = { name: '', description: '' } + +export default function InventoryScreen() { + const { isAuthenticated } = useAuth() + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [status, setStatus] = useState('') + const [locations, setLocations] = useState([]) + const [items, setItems] = useState([]) + const [selectedLocationId, setSelectedLocationId] = useState('') + const [locationHistory, setLocationHistory] = useState([]) + const [locationHistoryLoading, setLocationHistoryLoading] = useState(false) + const [locationHistoryError, setLocationHistoryError] = useState('') + const [editingLocationId, setEditingLocationId] = useState('') + const [editingItemId, setEditingItemId] = useState('') + const [locationForm, setLocationForm] = useState(EMPTY_LOCATION_FORM) + const [itemForm, setItemForm] = useState(createItemForm()) + const [showExpiryPicker, setShowExpiryPicker] = useState(false) + const [showUseByPicker, setShowUseByPicker] = useState(false) + + async function loadData() { + const [locs, inv] = await Promise.all([locationsApi.getLocations(), inventoryApi.getInventoryItems()]) + setLocations(locs) + setItems(inv) + setSelectedLocationId(id => { + if (locs.some(l => l.id === id)) return id + return locs[0]?.id ?? '' + }) + } + + useEffect(() => { + let cancelled = false + async function init() { + if (!isAuthenticated) { setLocations([]); setItems([]); return } + setLoading(true); setError('') + try { + const [locs, inv] = await Promise.all([locationsApi.getLocations(), inventoryApi.getInventoryItems()]) + if (!cancelled) { setLocations(locs); setItems(inv); setSelectedLocationId(locs[0]?.id ?? '') } + } catch (e) { if (!cancelled) setError(e.message) } + finally { if (!cancelled) setLoading(false) } + } + init() + return () => { cancelled = true } + }, [isAuthenticated]) + + useEffect(() => { + let cancelled = false + async function loadHistory() { + if (!isAuthenticated || !selectedLocationId) { setLocationHistory([]); return } + setLocationHistoryLoading(true); setLocationHistoryError('') + try { + const r = await locationsApi.getLocationHistory(selectedLocationId) + if (!cancelled) setLocationHistory(Array.isArray(r) ? r : []) + } catch (e) { if (!cancelled) { setLocationHistory([]); setLocationHistoryError(e.message) } } + finally { if (!cancelled) setLocationHistoryLoading(false) } + } + loadHistory() + return () => { cancelled = true } + }, [isAuthenticated, selectedLocationId]) + + async function submitLocation() { + const name = locationForm.name.trim() + if (!name) { setError('Location name is required.'); return } + setLoading(true); setError(''); setStatus('') + try { + const payload = { name, description: locationForm.description.trim() || null } + if (editingLocationId) { await locationsApi.updateLocation(editingLocationId, payload); setStatus('Location updated.') } + else { await locationsApi.createLocation(payload); setStatus('Location created.') } + await loadData() + setEditingLocationId(''); setLocationForm(EMPTY_LOCATION_FORM) + } catch (e) { setError(e.message) } + finally { setLoading(false) } + } + + async function deleteLocation(id) { + Alert.alert('Delete location?', 'Items linked to it may block deletion.', [ + { text: 'Cancel' }, + { text: 'Delete', style: 'destructive', onPress: async () => { + setLoading(true); setError(''); setStatus('') + try { await locationsApi.deleteLocation(id); await loadData(); setStatus('Location deleted.') } + catch (e) { setError(e.message) } + finally { setLoading(false) } + }}, + ]) + } + + async function loadItemForEdit(id) { + setLoading(true); setError(''); setStatus('') + try { const item = await inventoryApi.getInventoryItem(id); setEditingItemId(item.id); setItemForm(mapItemToForm(item)) } + catch (e) { setError(e.message) } + finally { setLoading(false) } + } + + async function submitItem() { + const name = itemForm.name.trim(); const barcode = itemForm.barcode.trim() + if (!name && !barcode) { setError('Provide an item name or barcode.'); return } + setLoading(true); setError(''); setStatus('') + try { + let id = editingItemId + if (editingItemId) { await inventoryApi.updateInventoryItem(editingItemId, buildInventoryPayload(itemForm, true)); setStatus('Item updated.') } + else { const created = await inventoryApi.createInventoryItem(buildInventoryPayload(itemForm)); id = created.id; setStatus('Item created.') } + await loadData() + if (id) { const fresh = await inventoryApi.getInventoryItem(id); setEditingItemId(fresh.id); setItemForm(mapItemToForm(fresh)) } + } catch (e) { setError(e.message) } + finally { setLoading(false) } + } + + async function deleteItem(id) { + Alert.alert('Delete item?', 'This cannot be undone.', [ + { text: 'Cancel' }, + { text: 'Delete', style: 'destructive', onPress: async () => { + setLoading(true); setError(''); setStatus('') + try { await inventoryApi.deleteInventoryItem(id); await loadData(); if (editingItemId === id) { setEditingItemId(''); setItemForm(createItemForm()) }; setStatus('Item deleted.') } + catch (e) { setError(e.message) } + finally { setLoading(false) } + }}, + ]) + } + + if (!isAuthenticated) return ( + Sign in on the home screen to manage inventory. + ) + + return ( + + {error ? {error} : null} + {status ? {status} : null} + {loading ? Syncing with API... : null} + + + { setEditingLocationId(''); setLocationForm(EMPTY_LOCATION_FORM) }} /> : null} /> + setLocationForm(f => ({ ...f, name: v }))} placeholder="Pantry" /> + setLocationForm(f => ({ ...f, description: v }))} placeholder="Main kitchen shelf" multiline /> + + + { setEditingLocationId(''); setLocationForm(EMPTY_LOCATION_FORM) }} /> + + {locations.length === 0 ? No locations yet. : locations.map(loc => ( + + {loc.name} + {loc.description || 'No description provided.'} + + { setSelectedLocationId(loc.id); setEditingLocationId(loc.id); setLocationForm({ name: loc.name, description: loc.description ?? '' }) }} /> + setSelectedLocationId(loc.id)} /> + deleteLocation(loc.id)} /> + + + ))} + + + + {locations.find(l => l.id === selectedLocationId)?.name || 'None selected'}} /> + {locationHistoryLoading ? Loading history... : null} + {locationHistoryError ? {locationHistoryError} : null} + {!selectedLocationId ? Select a location to see its history. : + locationHistory.length === 0 ? No history returned. : + locationHistory.map(entry => ( + + {entry.action} + {formatDate(entry.changedAt) || 'Unknown date'} by {entry.changedByEmail || 'Unknown'} + {entry.description || 'No description.'} + + )) + } + + + + { setEditingItemId(''); setItemForm(createItemForm()) }} /> : null} /> + setItemForm(f => ({ ...f, name: v }))} placeholder="Whole Milk" /> + setItemForm(f => ({ ...f, barcode: v }))} placeholder="01234567890" keyboardType="numeric" /> + + + setItemForm(f => ({ ...f, locationId: '' }))} /> + {locations.map(loc => setItemForm(f => ({ ...f, locationId: loc.id }))} />)} + + + setItemForm(f => ({ ...f, amount: v }))} placeholder="2" keyboardType="decimal-pad" /> + setItemForm(f => ({ ...f, amountType: v }))} placeholder="litres" /> + + setShowExpiryPicker(true)} /> + {showExpiryPicker && ( + { setShowExpiryPicker(false); if (d) setItemForm(f => ({ ...f, expiryDate: d.toISOString().slice(0, 10) })) }} /> + )} + {itemForm.expiryDate ? setItemForm(f => ({ ...f, expiryDate: '' }))} style={{ marginTop: 4 }} /> : null} + + + setShowUseByPicker(true)} /> + {showUseByPicker && ( + { setShowUseByPicker(false); if (d) setItemForm(f => ({ ...f, useByDate: d.toISOString().slice(0, 10) })) }} /> + )} + {itemForm.useByDate ? setItemForm(f => ({ ...f, useByDate: '' }))} style={{ marginTop: 4 }} /> : null} + + + + { setEditingItemId(''); setItemForm(createItemForm()) }} /> + + + + + {items.length} total} /> + {items.length === 0 ? No inventory items yet. : items.map(item => { + const exp = getExpiryStatus(item.expiryDate) + return ( + + {item.name} + {item.location?.name || 'No location'} | {formatAmount(item.amount, item.amountType)} + Expiry: {formatDate(item.expiryDate) || 'Not set'} {exp.text ? `| ${exp.text}` : ''} + Barcode: {item.barcode || 'Not set'} + + loadItemForEdit(item.id)} /> + deleteItem(item.id)} /> + + + ) + })} + + + ) +} + +const s = StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.bg }, + content: { padding: spacing.md }, + authMsg: { padding: spacing.lg, textAlign: 'center', color: colors.textMuted, fontSize: fontSize.md }, + strongText: { fontSize: fontSize.md, fontWeight: '600', color: colors.text }, + subtleText: { fontSize: fontSize.sm, color: colors.textMuted }, +}) diff --git a/src/screens/MealPlannersScreen.jsx b/src/screens/MealPlannersScreen.jsx new file mode 100644 index 0000000..2f17bd8 --- /dev/null +++ b/src/screens/MealPlannersScreen.jsx @@ -0,0 +1,237 @@ +import { useEffect, useState } from 'react' +import { View, Text, ScrollView, StyleSheet, Alert } from 'react-native' +import DateTimePicker from '@react-native-community/datetimepicker' +import { useAuth } from '../context/AuthContext.jsx' +import { householdsApi, inventoryApi, mealPlannersApi } from '../api/client.js' +import { formatAmount, formatDate, formatTime, toDateInputValue, toTimeInputValue } from '../utils/searchUtils.js' +import { StatusBanner, Panel, SectionHeading, InputField, Btn, BtnRow, EmptyState, EntityRow, EntityMeta, EntityActions, FieldGroup, PickerBtn, FormNote } from '../components/ui.jsx' +import { colors, spacing, fontSize, radius } from '../theme.js' + +function createItemRow() { return { inventoryItemId: '', amountRequired: '', amountType: '' } } +function createPlannerForm(householdId = '') { return { name: '', householdId, plannedDate: '', plannedTime: '', items: [createItemRow()] } } + +function mapPlannerToForm(p) { + return { + name: p?.name ?? '', + householdId: p?.householdId ?? '', + plannedDate: toDateInputValue(p?.plannedDate), + plannedTime: toTimeInputValue(p?.plannedTime), + items: Array.isArray(p?.items) && p.items.length > 0 + ? p.items.map(i => ({ inventoryItemId: i.inventoryItemId ?? '', amountRequired: i.amountRequired == null ? '' : String(i.amountRequired), amountType: i.amountType ?? '' })) + : [createItemRow()], + } +} + +function normalizeItems(items) { + const normalized = items + .filter(i => i.inventoryItemId || i.amountRequired !== '' || i.amountType.trim()) + .map((item, idx) => { + const amt = Number(item.amountRequired) + if (!item.inventoryItemId) throw new Error(`Choose an inventory item for row ${idx + 1}.`) + if (!Number.isFinite(amt) || amt <= 0) throw new Error(`Amount must be > 0 for row ${idx + 1}.`) + if (!item.amountType.trim()) throw new Error(`Amount type required for row ${idx + 1}.`) + return { inventoryItemId: item.inventoryItemId, amountRequired: amt, amountType: item.amountType.trim() } + }) + const ids = new Set(normalized.map(i => i.inventoryItemId)) + if (ids.size !== normalized.length) throw new Error('Each inventory item can only appear once.') + return normalized +} + +function resolveHousehold(households, id) { + return households.find(h => h.id === id)?.name ?? 'Unknown household' +} + +function toApiTime(t) { + if (!t) return '' + return t.length === 5 ? `${t}:00` : t +} + +export default function MealPlannersScreen() { + const { isAuthenticated } = useAuth() + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [status, setStatus] = useState('') + const [households, setHouseholds] = useState([]) + const [inventoryItems, setInventoryItems] = useState([]) + const [planners, setPlanners] = useState([]) + const [selectedId, setSelectedId] = useState('') + const [editingId, setEditingId] = useState('') + const [form, setForm] = useState(createPlannerForm()) + const [showDatePicker, setShowDatePicker] = useState(false) + const [showTimePicker, setShowTimePicker] = useState(false) + + async function loadData(preferredId = '') { + const [h, inv, mp] = await Promise.all([householdsApi.getHouseholds(), inventoryApi.getInventoryItems(), mealPlannersApi.getMealPlanners()]) + const nextH = Array.isArray(h) ? h : []; const nextInv = Array.isArray(inv) ? inv : []; const nextMp = Array.isArray(mp) ? mp : [] + setHouseholds(nextH); setInventoryItems(nextInv); setPlanners(nextMp) + setSelectedId(id => { const t = preferredId || id; return nextMp.some(p => p.id === t) ? t : nextMp[0]?.id ?? '' }) + setEditingId(id => nextMp.some(p => p.id === id) ? id : '') + } + + useEffect(() => { + let cancelled = false + async function init() { + if (!isAuthenticated) { setHouseholds([]); setInventoryItems([]); setPlanners([]); return } + setLoading(true); setError('') + try { + const [h, inv, mp] = await Promise.all([householdsApi.getHouseholds(), inventoryApi.getInventoryItems(), mealPlannersApi.getMealPlanners()]) + if (cancelled) return + const nextH = Array.isArray(h) ? h : []; const nextInv = Array.isArray(inv) ? inv : []; const nextMp = Array.isArray(mp) ? mp : [] + setHouseholds(nextH); setInventoryItems(nextInv); setPlanners(nextMp); setSelectedId(nextMp[0]?.id ?? '') + setForm(createPlannerForm(nextH[0]?.id ?? '')) + } catch (e) { if (!cancelled) setError(e.message) } + finally { if (!cancelled) setLoading(false) } + } + init() + return () => { cancelled = true } + }, [isAuthenticated]) + + function resetEditor() { setEditingId(''); setForm(createPlannerForm(households[0]?.id ?? '')) } + function updateRow(idx, updates) { setForm(f => ({ ...f, items: f.items.map((item, i) => i === idx ? { ...item, ...updates } : item) })) } + function addRow() { setForm(f => ({ ...f, items: [...f.items, createItemRow()] })) } + function removeRow(idx) { setForm(f => ({ ...f, items: f.items.filter((_, i) => i !== idx) })) } + + async function loadForEdit(id) { + setLoading(true); setError(''); setStatus('') + try { const mp = await mealPlannersApi.getMealPlanner(id); setSelectedId(mp.id); setEditingId(mp.id); setForm(mapPlannerToForm(mp)) } + catch (e) { setError(e.message) } + finally { setLoading(false) } + } + + async function handleSubmit() { + const name = form.name.trim() + if (!name) { setError('Name is required.'); return } + if (!editingId && !form.householdId) { setError('Choose a household.'); return } + if (!form.plannedDate) { setError('Choose a planned date.'); return } + if (!form.plannedTime) { setError('Choose a planned time.'); return } + let normalized + try { normalized = normalizeItems(form.items) } catch (e) { setError(e.message); return } + setLoading(true); setError(''); setStatus('') + try { + const payload = { name, plannedDate: form.plannedDate, plannedTime: toApiTime(form.plannedTime), items: normalized } + const result = editingId + ? await mealPlannersApi.updateMealPlanner(editingId, payload) + : await mealPlannersApi.createMealPlanner({ ...payload, householdId: form.householdId }) + await loadData(result.id); setSelectedId(result.id); setEditingId(result.id); setForm(mapPlannerToForm(result)) + setStatus(editingId ? 'Meal planner updated.' : 'Meal planner created.') + } catch (e) { setError(e.message) } + finally { setLoading(false) } + } + + async function handleDelete(id) { + Alert.alert('Delete meal planner?', undefined, [ + { text: 'Cancel' }, + { text: 'Delete', style: 'destructive', onPress: async () => { + setLoading(true); setError(''); setStatus('') + try { await mealPlannersApi.deleteMealPlanner(id); await loadData(selectedId === id ? '' : selectedId); if (editingId === id) resetEditor(); setStatus('Meal planner deleted.') } + catch (e) { setError(e.message) } + finally { setLoading(false) } + }}, + ]) + } + + const inventoryOptions = [...inventoryItems].sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')) + + if (!isAuthenticated) return ( + Sign in to manage meal planners. + ) + + return ( + + {error ? {error} : null} + {status ? {status} : null} + {loading ? Syncing meal planners... : null} + + + : null} /> + Meal planners are household-scoped. Choose a household, date, time, and add ingredients. + setForm(f => ({ ...f, name: v }))} placeholder="Pasta night" /> + {!editingId && ( + + + {households.map(h => setForm(f => ({ ...f, householdId: h.id, items: [] }))} />)} + + + )} + + setShowDatePicker(true)} /> + {showDatePicker && { setShowDatePicker(false); if (d) setForm(f => ({ ...f, plannedDate: d.toISOString().slice(0, 10) })) }} />} + + + setShowTimePicker(true)} /> + {showTimePicker && { setShowTimePicker(false); if (d) { const hh = String(d.getHours()).padStart(2, '0'); const mm = String(d.getMinutes()).padStart(2, '0'); setForm(f => ({ ...f, plannedTime: `${hh}:${mm}` })) } }} />} + + + } /> + {form.items.length === 0 ? No items yet. : form.items.map((item, idx) => ( + + Item {idx + 1} removeRow(idx)} /> + + + updateRow(idx, { inventoryItemId: '' })} /> + {inventoryOptions.map(inv => updateRow(idx, { inventoryItemId: inv.id })} />)} + + + updateRow(idx, { amountRequired: v })} keyboardType="decimal-pad" placeholder="1" /> + updateRow(idx, { amountType: v })} placeholder="litres" /> + + ))} + + {editingId && Household is locked while editing.} + + + + + + + + {planners.length} total} /> + {planners.length === 0 ? No meal planners yet. : planners.map(planner => ( + + + + {planner.name} + Household: {resolveHousehold(households, planner.householdId)} + Planned: {formatDate(planner.plannedDate) || 'Unknown'} at {formatTime(planner.plannedTime) || 'Unknown'} + Created {formatDate(planner.createdAt) || 'Unknown'} by {planner.createdByEmail || 'Unknown'} + + {planner.items?.length ?? 0} item(s) + + {(planner.items ?? []).map(item => ( + + + {item.inventoryItemName || 'Unnamed item'} + {formatAmount(item.amountRequired, item.amountType)} + + Required + + ))} + + setSelectedId(planner.id)} /> + loadForEdit(planner.id)} /> + handleDelete(planner.id)} /> + + + ))} + + + ) +} + +const s = StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.bg }, + content: { padding: spacing.md }, + authMsg: { padding: spacing.lg, textAlign: 'center', color: colors.textMuted, fontSize: fontSize.md }, + strongText: { fontSize: fontSize.md, fontWeight: '600', color: colors.text }, + subtleText: { fontSize: fontSize.sm, color: colors.textMuted }, + itemRow: { borderWidth: 1, borderColor: colors.border, borderRadius: radius.sm, padding: spacing.sm, marginBottom: spacing.sm }, + itemRowHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: spacing.xs }, + itemRowLabel: { fontWeight: '600', color: colors.text }, + cardHeader: { flexDirection: 'row', alignItems: 'flex-start', marginBottom: spacing.xs }, + chip: { backgroundColor: colors.chipNeutral, borderRadius: radius.sm, paddingHorizontal: spacing.xs, paddingVertical: 2 }, + chipText: { fontSize: fontSize.xs, color: colors.textSoft }, + summaryRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: spacing.xs, borderTopWidth: 1, borderColor: colors.border }, + itemName: { fontSize: fontSize.sm, fontWeight: '600', color: colors.text }, + requiredChip: { backgroundColor: colors.chipNeutral, borderRadius: radius.sm, paddingHorizontal: spacing.xs, paddingVertical: 2 }, + requiredChipText: { fontSize: fontSize.xs, color: colors.textSoft }, +}) diff --git a/src/screens/ProfileScreen.jsx b/src/screens/ProfileScreen.jsx new file mode 100644 index 0000000..0355803 --- /dev/null +++ b/src/screens/ProfileScreen.jsx @@ -0,0 +1,110 @@ +import { useEffect, useState } from 'react' +import { View, Text, ScrollView, StyleSheet } from 'react-native' +import { useAuth } from '../context/AuthContext.jsx' +import { profileApi } from '../api/client.js' +import { StatusBanner, Panel, SectionHeading, InputField, Btn, BtnRow, FormNote, EntityMeta } from '../components/ui.jsx' +import { colors, spacing, fontSize, radius } from '../theme.js' + +function createProfileForm(user) { + return { email: user?.email ?? '', firstName: user?.firstName ?? '', lastName: user?.lastName ?? '', currentPassword: '', newPassword: '' } +} + +export default function ProfileScreen() { + const { isAuthenticated, user, refreshProfile, setCurrentUserProfile, logout } = useAuth() + const [form, setForm] = useState(() => createProfileForm(user)) + const [loading, setLoading] = useState(false) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + const [status, setStatus] = useState('') + + const roles = Array.isArray(user?.roles) ? user.roles : [] + + useEffect(() => { + if (!isAuthenticated) { setForm({ email: '', firstName: '', lastName: '', currentPassword: '', newPassword: '' }); return } + setForm(f => ({ ...f, email: user?.email ?? '', firstName: user?.firstName ?? '', lastName: user?.lastName ?? '' })) + }, [isAuthenticated, user?.email, user?.firstName, user?.lastName]) + + async function handleRefresh() { + setLoading(true); setError(''); setStatus('') + try { const p = await refreshProfile(); if (p) { setForm(createProfileForm(p)); setStatus('Profile refreshed.') } } + catch (e) { setError(e.message) } + finally { setLoading(false) } + } + + async function handleSave() { + const email = form.email.trim() + if (!email) { setError('Email is required.'); return } + if (form.newPassword && !form.currentPassword) { setError('Current password is required to change password.'); return } + setSaving(true); setError(''); setStatus('') + try { + const updated = await profileApi.updateProfile({ email, firstName: form.firstName, lastName: form.lastName, currentPassword: form.currentPassword || undefined, newPassword: form.newPassword || undefined }) + setCurrentUserProfile(updated); setForm(createProfileForm(updated)); setStatus('Profile updated.') + } catch (e) { setError(e.message) } + finally { setSaving(false) } + } + + async function handleLogout() { + try { await logout() } catch { /* ignore */ } + } + + if (!isAuthenticated) return ( + Sign in to view your profile. + ) + + return ( + + {error ? {error} : null} + {status ? {status} : null} + {loading ? Refreshing profile... : null} + {saving ? Saving changes... : null} + + + } /> + {[user?.firstName, user?.lastName].filter(Boolean).join(' ') || user?.email || 'Signed in user'} + Email: {user?.email || 'Not available'} + User ID: {user?.id || 'Not available'} + + Assigned roles + + {roles.length === 0 ? ( + No roles assigned + ) : roles.map(role => ( + {role} + ))} + + + + + + + Leave password fields blank unless changing your password. + setForm(f => ({ ...f, email: v }))} keyboardType="email-address" autoCapitalize="none" placeholder="you@example.com" /> + setForm(f => ({ ...f, firstName: v }))} placeholder="Alex" /> + setForm(f => ({ ...f, lastName: v }))} placeholder="Smith" /> + setForm(f => ({ ...f, currentPassword: v }))} secureTextEntry placeholder="Required to change password" /> + setForm(f => ({ ...f, newPassword: v }))} secureTextEntry placeholder="Leave blank to keep current" /> + + + setForm(createProfileForm(user))} disabled={saving} /> + + + + + + + + + ) +} + +const s = StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.bg }, + content: { padding: spacing.md }, + authMsg: { padding: spacing.lg, textAlign: 'center', color: colors.textMuted, fontSize: fontSize.md }, + displayName: { fontSize: fontSize.lg, fontWeight: '700', color: colors.text, marginBottom: spacing.xs }, + roleSection: { marginTop: spacing.sm }, + rolesLabel: { fontSize: fontSize.sm, color: colors.textMuted, marginBottom: spacing.xs }, + roleList: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.xs }, + roleBadge: { backgroundColor: colors.chipNeutral, borderRadius: radius.sm, paddingHorizontal: spacing.sm, paddingVertical: spacing.xs }, + roleBadgeText: { fontSize: fontSize.xs, color: colors.textSoft, fontWeight: '500' }, +}) diff --git a/src/screens/SearchScreen.jsx b/src/screens/SearchScreen.jsx new file mode 100644 index 0000000..4da834f --- /dev/null +++ b/src/screens/SearchScreen.jsx @@ -0,0 +1,153 @@ +import { useEffect, useState } from 'react' +import { View, Text, ScrollView, StyleSheet, Alert } from 'react-native' +import { useAuth } from '../context/AuthContext.jsx' +import { inventoryApi, locationsApi, searchApi } from '../api/client.js' +import { filterInventoryItems, formatAmount, formatDate, getExpiryStatus } from '../utils/searchUtils.js' +import { StatusBanner, Panel, SectionHeading, InputField, Btn, BtnRow, EmptyState, EntityRow, EntityMeta, FieldGroup, PickerBtn } from '../components/ui.jsx' +import { colors, spacing, fontSize, radius } from '../theme.js' + +const PAGE_SIZE = 15 + +export default function SearchScreen() { + const { isAuthenticated } = useAuth() + const [searchName, setSearchName] = useState('') + const [locationId, setLocationId] = useState('') + const [minAmount, setMinAmount] = useState('0') + const [maxAmount, setMaxAmount] = useState('999') + const [locations, setLocations] = useState([]) + const [matchingLocations, setMatchingLocations] = useState([]) + const [results, setResults] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [page, setPage] = useState(1) + + useEffect(() => { + let cancelled = false + async function init() { + if (!isAuthenticated) { setLocations([]); setResults([]); return } + setLoading(true); setError('') + try { + const [locs, inv] = await Promise.all([locationsApi.getLocations(), inventoryApi.getInventoryItems()]) + if (!cancelled) { setLocations(locs); setResults(inv) } + } catch (e) { if (!cancelled) setError(e.message) } + finally { if (!cancelled) setLoading(false) } + } + init() + return () => { cancelled = true } + }, [isAuthenticated]) + + async function performSearch() { + const parsedMin = minAmount === '' ? 0 : Number(minAmount) + const parsedMax = maxAmount === '' ? Infinity : Number(maxAmount) + if (parsedMin > parsedMax) { Alert.alert('Min amount cannot exceed max amount.'); return } + setLoading(true); setError('') + try { + const q = searchName.trim() + const [inv, locs] = q + ? await Promise.all([searchApi.searchItems(q), searchApi.searchLocations(q)]) + : await Promise.all([inventoryApi.getInventoryItems(), Promise.resolve([])]) + const filtered = filterInventoryItems(inv, { locationId, minAmount: parsedMin, maxAmount: parsedMax }) + setResults(filtered); setMatchingLocations(locs); setPage(1) + } catch (e) { setError(e.message) } + finally { setLoading(false) } + } + + async function resetSearch() { + setSearchName(''); setLocationId(''); setMinAmount('0'); setMaxAmount('999'); setMatchingLocations([]); setPage(1); setError('') + if (!isAuthenticated) { setResults([]); return } + setLoading(true) + try { + const [locs, inv] = await Promise.all([locationsApi.getLocations(), inventoryApi.getInventoryItems()]) + setLocations(locs); setResults(inv) + } catch (e) { setError(e.message) } + finally { setLoading(false) } + } + + const totalPages = Math.max(1, Math.ceil(results.length / PAGE_SIZE)) + const pageItems = results.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE) + + if (!isAuthenticated) return ( + Sign in to search your inventory. + ) + + return ( + + {error ? {error} : null} + {loading ? Searching... : null} + + + + + + + setLocationId('')} /> + {locations.map(loc => setLocationId(loc.id)} />)} + + + + + + + + + + + + + + {searchName.trim() && matchingLocations.length > 0 && ( + + + {matchingLocations.map(loc => ( + + {loc.name} + {loc.description || 'No description.'} + + ))} + + )} + + + + {results.length === 0 ? No items match your search. : ( + <> + {pageItems.map(item => { + const exp = getExpiryStatus(item.expiryDate) + return ( + + + {item.name} + + {exp.status} + + + {item.location?.name || 'No location'} | {formatAmount(item.amount, item.amountType)} + Barcode: {item.barcode || 'Not set'} | Expires: {formatDate(item.expiryDate) || 'Not set'} + + ) + })} + + setPage(p => Math.max(1, p - 1))} disabled={page === 1} /> + Page {page} of {totalPages} + setPage(p => Math.min(totalPages, p + 1))} disabled={page === totalPages} /> + + + )} + + + ) +} + +const s = StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.bg }, + content: { padding: spacing.md }, + authMsg: { padding: spacing.lg, textAlign: 'center', color: colors.textMuted, fontSize: fontSize.md }, + strongText: { fontSize: fontSize.md, fontWeight: '600', color: colors.text }, + amountRow: { flexDirection: 'row', alignItems: 'flex-start' }, + amountSep: { width: spacing.sm }, + itemHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, + expiryBadge: { borderWidth: 1, borderRadius: radius.sm, paddingHorizontal: spacing.xs, paddingVertical: 2 }, + expiryBadgeText: { fontSize: fontSize.xs, fontWeight: '600' }, + pagination: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginTop: spacing.sm }, + pageInfo: { fontSize: fontSize.sm, color: colors.textMuted }, +}) diff --git a/src/screens/ShoppingListsScreen.jsx b/src/screens/ShoppingListsScreen.jsx new file mode 100644 index 0000000..54ec8df --- /dev/null +++ b/src/screens/ShoppingListsScreen.jsx @@ -0,0 +1,226 @@ +import { useEffect, useState } from 'react' +import { View, Text, ScrollView, StyleSheet, Alert, Switch } from 'react-native' +import { useAuth } from '../context/AuthContext.jsx' +import { householdsApi, inventoryApi, shoppingListsApi } from '../api/client.js' +import { formatAmount, formatDate } from '../utils/searchUtils.js' +import { StatusBanner, Panel, SectionHeading, InputField, Btn, BtnRow, EmptyState, EntityRow, EntityMeta, EntityActions, FieldGroup, PickerBtn, FormNote } from '../components/ui.jsx' +import { colors, spacing, fontSize, radius } from '../theme.js' + +function createItemRow() { return { inventoryItemId: '', amountRequired: '', amountType: '', isPurchased: false } } +function createListForm(householdId = '') { return { name: '', householdId, items: [createItemRow()] } } + +function mapListToForm(list) { + return { + name: list?.name ?? '', + householdId: list?.householdId ?? '', + items: Array.isArray(list?.items) && list.items.length > 0 + ? list.items.map(i => ({ inventoryItemId: i.inventoryItemId ?? '', amountRequired: i.amountRequired == null ? '' : String(i.amountRequired), amountType: i.amountType ?? '', isPurchased: Boolean(i.isPurchased) })) + : [createItemRow()], + } +} + +function normalizeItems(items) { + const normalized = items + .filter(i => i.inventoryItemId || i.amountRequired !== '' || i.amountType.trim()) + .map((item, idx) => { + const amt = Number(item.amountRequired) + if (!item.inventoryItemId) throw new Error(`Choose an inventory item for row ${idx + 1}.`) + if (!Number.isFinite(amt) || amt <= 0) throw new Error(`Amount must be > 0 for row ${idx + 1}.`) + if (!item.amountType.trim()) throw new Error(`Amount type required for row ${idx + 1}.`) + return { inventoryItemId: item.inventoryItemId, amountRequired: amt, amountType: item.amountType.trim(), isPurchased: Boolean(item.isPurchased) } + }) + const ids = new Set(normalized.map(i => i.inventoryItemId)) + if (ids.size !== normalized.length) throw new Error('Each inventory item can only appear once.') + return normalized +} + +function resolveHousehold(households, id) { + return households.find(h => h.id === id)?.name ?? 'Unknown household' +} + +export default function ShoppingListsScreen() { + const { isAuthenticated } = useAuth() + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [status, setStatus] = useState('') + const [households, setHouseholds] = useState([]) + const [inventoryItems, setInventoryItems] = useState([]) + const [lists, setLists] = useState([]) + const [selectedId, setSelectedId] = useState('') + const [editingId, setEditingId] = useState('') + const [form, setForm] = useState(createListForm()) + + async function loadData(preferredId = '') { + const [h, inv, sl] = await Promise.all([householdsApi.getHouseholds(), inventoryApi.getInventoryItems(), shoppingListsApi.getShoppingLists()]) + const nextH = Array.isArray(h) ? h : []; const nextInv = Array.isArray(inv) ? inv : []; const nextSl = Array.isArray(sl) ? sl : [] + setHouseholds(nextH); setInventoryItems(nextInv); setLists(nextSl) + setSelectedId(id => { const t = preferredId || id; return nextSl.some(l => l.id === t) ? t : nextSl[0]?.id ?? '' }) + setEditingId(id => nextSl.some(l => l.id === id) ? id : '') + } + + useEffect(() => { + let cancelled = false + async function init() { + if (!isAuthenticated) { setHouseholds([]); setInventoryItems([]); setLists([]); return } + setLoading(true); setError('') + try { + const [h, inv, sl] = await Promise.all([householdsApi.getHouseholds(), inventoryApi.getInventoryItems(), shoppingListsApi.getShoppingLists()]) + if (cancelled) return + const nextH = Array.isArray(h) ? h : []; const nextInv = Array.isArray(inv) ? inv : []; const nextSl = Array.isArray(sl) ? sl : [] + setHouseholds(nextH); setInventoryItems(nextInv); setLists(nextSl); setSelectedId(nextSl[0]?.id ?? '') + setForm(createListForm(nextH[0]?.id ?? '')) + } catch (e) { if (!cancelled) setError(e.message) } + finally { if (!cancelled) setLoading(false) } + } + init() + return () => { cancelled = true } + }, [isAuthenticated]) + + function resetEditor() { setEditingId(''); setForm(createListForm(households[0]?.id ?? '')) } + function updateRow(idx, updates) { setForm(f => ({ ...f, items: f.items.map((item, i) => i === idx ? { ...item, ...updates } : item) })) } + function addRow() { setForm(f => ({ ...f, items: [...f.items, createItemRow()] })) } + function removeRow(idx) { setForm(f => ({ ...f, items: f.items.filter((_, i) => i !== idx) })) } + + async function loadForEdit(id) { + setLoading(true); setError(''); setStatus('') + try { const sl = await shoppingListsApi.getShoppingList(id); setSelectedId(sl.id); setEditingId(sl.id); setForm(mapListToForm(sl)) } + catch (e) { setError(e.message) } + finally { setLoading(false) } + } + + async function handleSubmit() { + const name = form.name.trim() + if (!name) { setError('Name is required.'); return } + if (!editingId && !form.householdId) { setError('Choose a household.'); return } + let normalized + try { normalized = normalizeItems(form.items) } catch (e) { setError(e.message); return } + setLoading(true); setError(''); setStatus('') + try { + const payload = { name, items: normalized } + const result = editingId + ? await shoppingListsApi.updateShoppingList(editingId, payload) + : await shoppingListsApi.createShoppingList({ ...payload, householdId: form.householdId }) + await loadData(result.id); setSelectedId(result.id); setEditingId(result.id); setForm(mapListToForm(result)) + setStatus(editingId ? 'Shopping list updated.' : 'Shopping list created.') + } catch (e) { setError(e.message) } + finally { setLoading(false) } + } + + async function handleDelete(id) { + Alert.alert('Delete shopping list?', undefined, [ + { text: 'Cancel' }, + { text: 'Delete', style: 'destructive', onPress: async () => { + setLoading(true); setError(''); setStatus('') + try { await shoppingListsApi.deleteShoppingList(id); await loadData(selectedId === id ? '' : selectedId); if (editingId === id) resetEditor(); setStatus('Shopping list deleted.') } + catch (e) { setError(e.message) } + finally { setLoading(false) } + }}, + ]) + } + + const inventoryOptions = [...inventoryItems].sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')) + + if (!isAuthenticated) return ( + Sign in to manage shopping lists. + ) + + return ( + + {error ? {error} : null} + {status ? {status} : null} + {loading ? Syncing shopping lists... : null} + + + : null} /> + Shopping lists are household-scoped. Pick the household first. + setForm(f => ({ ...f, name: v }))} placeholder="Weekend shop" /> + {!editingId && ( + + + {households.map(h => setForm(f => ({ ...f, householdId: h.id, items: [] }))} />)} + + + )} + + } /> + {form.items.length === 0 ? No items yet. Tap Add item. : form.items.map((item, idx) => ( + + Item {idx + 1} removeRow(idx)} /> + + + updateRow(idx, { inventoryItemId: '' })} /> + {inventoryOptions.map(inv => updateRow(idx, { inventoryItemId: inv.id })} />)} + + + updateRow(idx, { amountRequired: v })} keyboardType="decimal-pad" placeholder="2" /> + updateRow(idx, { amountType: v })} placeholder="cartons" /> + + Purchased + updateRow(idx, { isPurchased: v })} trackColor={{ true: colors.primary }} /> + + + ))} + + {editingId && Household is locked while editing.} + + + + + + + + {lists.length} total} /> + {lists.length === 0 ? No shopping lists yet. : lists.map(list => ( + + + + {list.name} + Household: {resolveHousehold(households, list.householdId)} + Created {formatDate(list.createdAt) || 'Unknown'} by {list.createdByEmail || 'Unknown'} + + {list.items?.length ?? 0} item(s) + + {(list.items ?? []).map(item => ( + + + {item.inventoryItemName || 'Unnamed item'} + {formatAmount(item.amountRequired, item.amountType)} + + + {item.isPurchased ? 'Purchased' : 'Pending'} + + + ))} + + setSelectedId(list.id)} /> + loadForEdit(list.id)} /> + handleDelete(list.id)} /> + + + ))} + + + ) +} + +const s = StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.bg }, + content: { padding: spacing.md }, + authMsg: { padding: spacing.lg, textAlign: 'center', color: colors.textMuted, fontSize: fontSize.md }, + strongText: { fontSize: fontSize.md, fontWeight: '600', color: colors.text }, + subtleText: { fontSize: fontSize.sm, color: colors.textMuted }, + itemRow: { borderWidth: 1, borderColor: colors.border, borderRadius: radius.sm, padding: spacing.sm, marginBottom: spacing.sm }, + itemRowHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: spacing.xs }, + itemRowLabel: { fontWeight: '600', color: colors.text }, + checkRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: spacing.xs }, + checkLabel: { fontSize: fontSize.sm, color: colors.textSoft }, + cardHeader: { flexDirection: 'row', alignItems: 'flex-start', marginBottom: spacing.xs }, + chip: { backgroundColor: colors.chipNeutral, borderRadius: radius.sm, paddingHorizontal: spacing.xs, paddingVertical: 2 }, + chipText: { fontSize: fontSize.xs, color: colors.textSoft }, + summaryRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: spacing.xs, borderTopWidth: 1, borderColor: colors.border }, + itemName: { fontSize: fontSize.sm, fontWeight: '600', color: colors.text }, + statusChip: { borderRadius: radius.sm, paddingHorizontal: spacing.xs, paddingVertical: 2 }, + statusChipSuccess: { backgroundColor: colors.chipSuccess }, + statusChipNeutral: { backgroundColor: colors.chipNeutral }, + statusChipText: { fontSize: fontSize.xs, fontWeight: '600' }, +}) diff --git a/src/screens/UsersScreen.jsx b/src/screens/UsersScreen.jsx new file mode 100644 index 0000000..bca3d65 --- /dev/null +++ b/src/screens/UsersScreen.jsx @@ -0,0 +1,156 @@ +import { useEffect, useState } from 'react' +import { View, Text, ScrollView, StyleSheet } from 'react-native' +import { useAuth } from '../context/AuthContext.jsx' +import { usersApi } from '../api/client.js' +import { StatusBanner, Panel, SectionHeading, InputField, Btn, BtnRow, EmptyState, EntityRow, EntityMeta, EntityActions, FormNote } from '../components/ui.jsx' +import { colors, spacing, fontSize, radius } from '../theme.js' + +const EMPTY_FORM = { email: '', firstName: '', lastName: '', password: '', rolesInput: '' } + +function formatUserName(u) { + const full = [u.firstName, u.lastName].filter(Boolean).join(' ') + return full || u.email || 'Unnamed user' +} + +function mapToForm(u) { + return { email: u?.email ?? '', firstName: u?.firstName ?? '', lastName: u?.lastName ?? '', password: '', rolesInput: Array.isArray(u?.roles) ? u.roles.join(', ') : '' } +} + +function parseRoles(v) { + return v.split(',').map(r => r.trim()).filter(Boolean) +} + +export default function UsersScreen() { + const { isAuthenticated, isSiteAdmin, refreshProfile, user } = useAuth() + const [users, setUsers] = useState([]) + const [loading, setLoading] = useState(false) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + const [status, setStatus] = useState('') + const [selectedId, setSelectedId] = useState('') + const [editingId, setEditingId] = useState('') + const [form, setForm] = useState(EMPTY_FORM) + + async function loadUsers(preferredId = '') { + const r = await usersApi.getUsers() + const next = Array.isArray(r) ? r : [] + setUsers(next) + setSelectedId(id => { const t = preferredId || id; return next.some(u => u.id === t) ? t : next[0]?.id ?? '' }) + setEditingId(id => next.some(u => u.id === id) ? id : '') + } + + useEffect(() => { + let cancelled = false + async function init() { + if (!isAuthenticated || !isSiteAdmin) { setUsers([]); return } + setLoading(true); setError('') + try { + const r = await usersApi.getUsers() + if (cancelled) return + const next = Array.isArray(r) ? r : [] + setUsers(next); setSelectedId(next[0]?.id ?? '') + } catch (e) { if (!cancelled) setError(e.message) } + finally { if (!cancelled) setLoading(false) } + } + init() + return () => { cancelled = true } + }, [isAuthenticated, isSiteAdmin]) + + async function handleRefresh() { + setLoading(true); setError(''); setStatus('') + try { await loadUsers(selectedId) } + catch (e) { setError(e.message) } + finally { setLoading(false) } + } + + async function handleEditUser(id) { + setLoading(true); setError(''); setStatus('') + try { const u = await usersApi.getUser(id); setSelectedId(u.id); setEditingId(u.id); setForm(mapToForm(u)) } + catch (e) { setError(e.message) } + finally { setLoading(false) } + } + + async function handleSave() { + if (!editingId) { setError('Select a user to edit.'); return } + const email = form.email.trim() + if (!email) { setError('Email is required.'); return } + setSaving(true); setError(''); setStatus('') + try { + const updated = await usersApi.updateUser(editingId, { email, firstName: form.firstName, lastName: form.lastName, password: form.password || undefined, roles: parseRoles(form.rolesInput) }) + await loadUsers(updated.id); setSelectedId(updated.id); setEditingId(updated.id); setForm(mapToForm(updated)) + if (user?.id === updated.id) await refreshProfile() + setStatus('User updated.') + } catch (e) { setError(e.message) } + finally { setSaving(false) } + } + + if (!isAuthenticated) return ( + Sign in to access user management. + ) + if (!isSiteAdmin) return ( + The users page is reserved for site admins. + ) + + return ( + + {error ? {error} : null} + {status ? {status} : null} + {loading ? Loading users... : null} + {saving ? Saving user... : null} + + + { setEditingId(''); setForm(EMPTY_FORM) }} /> : null} /> + Roles are replaced by the comma-separated list you submit. Leave password blank to keep current. + {!editingId ? Choose a user from the directory to load the edit form. : ( + <> + {formatUserName(form)} — editing ID: {editingId} + setForm(f => ({ ...f, email: v }))} keyboardType="email-address" autoCapitalize="none" placeholder="user@example.com" /> + setForm(f => ({ ...f, firstName: v }))} placeholder="Alex" /> + setForm(f => ({ ...f, lastName: v }))} placeholder="Smith" /> + setForm(f => ({ ...f, password: v }))} secureTextEntry placeholder="Leave blank to keep current" /> + setForm(f => ({ ...f, rolesInput: v }))} placeholder="Site Admin, Admin" autoCapitalize="none" /> + + + { setEditingId(''); setForm(EMPTY_FORM) }} disabled={saving} /> + + + )} + + + + } /> + {users.length === 0 ? No users returned. : users.map(u => { + const roles = Array.isArray(u.roles) ? u.roles : [] + return ( + + {formatUserName(u)} + {u.email || 'No email.'} + ID: {u.id || 'Not set'} + + {roles.length === 0 + ? No roles + : roles.map(r => {r}) + } + + + setSelectedId(u.id)} /> + handleEditUser(u.id)} /> + + + ) + })} + + + ) +} + +const s = StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.bg }, + content: { padding: spacing.md }, + authMsg: { padding: spacing.lg, textAlign: 'center', color: colors.textMuted, fontSize: fontSize.md }, + strongText: { fontSize: fontSize.md, fontWeight: '600', color: colors.text }, + editorMeta: { fontSize: fontSize.sm, color: colors.textMuted, marginBottom: spacing.sm }, + roleList: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.xs, marginTop: spacing.xs }, + roleBadge: { backgroundColor: colors.chipNeutral, borderRadius: radius.sm, paddingHorizontal: spacing.xs, paddingVertical: 2 }, + roleBadgeText: { fontSize: fontSize.xs, color: colors.textSoft }, +}) diff --git a/src/theme.js b/src/theme.js new file mode 100644 index 0000000..97e7454 --- /dev/null +++ b/src/theme.js @@ -0,0 +1,56 @@ +export const colors = { + bg: '#f5f5f5', + surface: '#ffffff', + surfaceMuted: '#f0f0f0', + border: '#e0e0e0', + text: '#1a1a1a', + textSoft: '#444444', + textMuted: '#777777', + primary: '#4CAF50', + primaryText: '#ffffff', + danger: '#f44336', + dangerText: '#ffffff', + secondary: '#dddddd', + secondaryText: '#333333', + accent: '#2563eb', + errorBg: '#fdecea', + errorBorder: '#f44336', + errorText: '#c62828', + successBg: '#e8f5e9', + successBorder: '#4CAF50', + successText: '#2e7d32', + infoBg: '#e3f2fd', + infoBorder: '#2196f3', + infoText: '#1565c0', + selectedBorder: '#4CAF50', + selectedBg: '#f0faf0', + chipNeutral: '#e0e0e0', + chipNeutralText: '#333333', + chipSuccess: '#e8f5e9', + chipSuccessText: '#2e7d32', + chipWarning: '#fff3e0', + chipWarningText: '#e65100', +} + +export const spacing = { + xs: 4, + sm: 8, + md: 16, + lg: 24, + xl: 32, +} + +export const radius = { + sm: 6, + md: 10, + lg: 16, +} + +export const fontSize = { + xs: 11, + sm: 13, + md: 15, + lg: 18, + xl: 22, + xxl: 28, +} diff --git a/src/utils/inventoryItemUtils.js b/src/utils/inventoryItemUtils.js new file mode 100644 index 0000000..aa40d69 --- /dev/null +++ b/src/utils/inventoryItemUtils.js @@ -0,0 +1,52 @@ +import { toDateInputValue } from './searchUtils.js' + +export const EMPTY_ITEM_FORM = { + name: '', + barcode: '', + expiryDate: '', + useByDate: '', + amount: '', + amountType: '', + locationId: '', +} + +export function normalizeBarcode(value) { + return String(value ?? '').trim() +} + +export function createItemForm(barcode = '') { + return { ...EMPTY_ITEM_FORM, barcode: normalizeBarcode(barcode) } +} + +export function buildInventoryPayload(form, includeBlankText = false) { + const payload = {} + const name = form.name.trim() + const barcode = normalizeBarcode(form.barcode) + const amountType = form.amountType.trim() + if (name || includeBlankText) payload.name = name + if (barcode || includeBlankText) payload.barcode = barcode + if (amountType || includeBlankText) payload.amountType = amountType + if (form.expiryDate) payload.expiryDate = form.expiryDate + if (form.useByDate) payload.useByDate = form.useByDate + if (form.amount !== '') payload.amount = Number(form.amount) + if (form.locationId) payload.locationId = form.locationId + return payload +} + +export function mapItemToForm(item) { + return { + name: item.name ?? '', + barcode: item.barcode ?? '', + expiryDate: toDateInputValue(item.expiryDate), + useByDate: toDateInputValue(item.useByDate), + amount: item.amount == null ? '' : String(item.amount), + amountType: item.amountType ?? '', + locationId: item.locationId ?? item.location?.id ?? '', + } +} + +export function findItemsByBarcode(items, barcode) { + const normalizedBarcode = normalizeBarcode(barcode) + if (!normalizedBarcode) return [] + return items.filter(item => normalizeBarcode(item.barcode) === normalizedBarcode) +} diff --git a/src/utils/searchUtils.js b/src/utils/searchUtils.js new file mode 100644 index 0000000..c0052fc --- /dev/null +++ b/src/utils/searchUtils.js @@ -0,0 +1,62 @@ +export function formatDate(dateStr) { + if (!dateStr) return '' + const normalizedDate = toDateInputValue(dateStr) + const date = new Date(`${normalizedDate}T00:00:00`) + if (Number.isNaN(date.getTime())) return '' + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) +} + +export function formatTime(timeStr) { + const normalizedTime = toTimeInputValue(timeStr) + if (!normalizedTime) return '' + const [hours, minutes] = normalizedTime.split(':').map(Number) + if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return normalizedTime + const date = new Date() + date.setHours(hours, minutes, 0, 0) + return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }) +} + +export function toDateInputValue(dateStr) { + if (!dateStr) return '' + return String(dateStr).slice(0, 10) +} + +export function toTimeInputValue(timeStr) { + if (!timeStr) return '' + return String(timeStr).slice(0, 5) +} + +export function getExpiryStatus(expiryDate) { + if (!expiryDate) return { status: 'Unknown', color: '#999', text: '' } + const today = new Date() + today.setHours(0, 0, 0, 0) + const normalizedDate = toDateInputValue(expiryDate) + const expiry = new Date(`${normalizedDate}T00:00:00`) + if (Number.isNaN(expiry.getTime())) return { status: 'Unknown', color: '#999', text: '' } + const daysUntilExpiry = Math.floor((expiry - today) / (1000 * 60 * 60 * 24)) + if (daysUntilExpiry < 0) return { status: 'Expired', color: '#f44336', days: daysUntilExpiry, text: `Expired ${Math.abs(daysUntilExpiry)} days ago` } + if (daysUntilExpiry === 0) return { status: 'Today', color: '#ff9800', days: 0, text: 'Expires today' } + if (daysUntilExpiry <= 7) return { status: 'Soon', color: '#ff9800', days: daysUntilExpiry, text: `Expires in ${daysUntilExpiry} days` } + return { status: 'Fresh', color: '#4CAF50', days: daysUntilExpiry, text: `Expires in ${daysUntilExpiry} days` } +} + +export function formatAmount(amount, amountType = '') { + if (amount == null || amount === '') return 'Amount not set' + const unit = amountType?.trim() ?? '' + return unit ? `${amount} ${unit}` : String(amount) +} + +export function filterInventoryItems(items, filters = {}) { + const { locationId = '', minAmount = 0, maxAmount = Infinity, minExpiryDate = '', maxExpiryDate = '' } = filters + return items.filter(item => { + const itemLocationId = item.locationId ?? item.location?.id ?? '' + const locationMatch = !locationId || itemLocationId === locationId + const numericAmount = item.amount == null ? 0 : Number(item.amount) + const amountMatch = numericAmount >= minAmount && numericAmount <= maxAmount + const normalizedExpiryDate = toDateInputValue(item.expiryDate) + let expiryMatch = true + if (minExpiryDate && (!normalizedExpiryDate || normalizedExpiryDate < minExpiryDate)) expiryMatch = false + if (maxExpiryDate && (!normalizedExpiryDate || normalizedExpiryDate > maxExpiryDate)) expiryMatch = false + return locationMatch && amountMatch && expiryMatch + }) +}