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
+ })
+}