Update project files

This commit is contained in:
2026-07-17 09:48:49 +01:00
parent 0d71e6b7bc
commit cfe7fffce3
27 changed files with 5134 additions and 21 deletions

Submodule .claude/worktrees/laughing-perlman-a6074b added at 0d71e6b7bc

3
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

1784
.idea/caches/deviceStreaming.xml generated Normal file

File diff suppressed because it is too large Load Diff

5
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,5 @@
<project version="4">
<component name="ProjectRootManager" version="2">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/pantry-manager-android-app.iml" filepath="$PROJECT_DIR$/.idea/pantry-manager-android-app.iml" />
</modules>
</component>
</project>

9
.idea/pantry-manager-android-app.iml generated Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

6
.idea/studiobot.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="StudioBotProjectSettings">
<option name="shareContext" value="OptedIn" />
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

86
App.js
View File

@@ -1,20 +1,74 @@
import { StatusBar } from 'expo-status-bar'; import 'react-native-gesture-handler'
import { StyleSheet, Text, View } from 'react-native'; 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 (
<Text style={{ fontSize: 18, opacity: focused ? 1 : 0.5 }}>{icons[name] || '•'}</Text>
)
}
function MainTabs() {
const { isSiteAdmin } = useAuth()
return (
<Tab.Navigator
screenOptions={({ route }) => ({
tabBarIcon: ({ focused }) => <TabIcon name={route.name} focused={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 },
})}
>
<Tab.Screen name="Home" component={HomeScreen} options={{ title: 'Home' }} />
<Tab.Screen name="Inventory" component={InventoryScreen} options={{ title: 'Inventory' }} />
<Tab.Screen name="Search" component={SearchScreen} options={{ title: 'Search' }} />
<Tab.Screen name="Barcode" component={BarcodeScreen} options={{ title: 'Barcode' }} />
<Tab.Screen name="Shopping" component={ShoppingListsScreen} options={{ title: 'Shopping' }} />
<Tab.Screen name="Meals" component={MealPlannersScreen} options={{ title: 'Meals' }} />
<Tab.Screen name="Profile" component={ProfileScreen} options={{ title: 'Profile' }} />
{isSiteAdmin && <Tab.Screen name="Admin" component={AdminScreen} options={{ title: 'Admin' }} />}
{isSiteAdmin && <Tab.Screen name="Users" component={UsersScreen} options={{ title: 'Users' }} />}
</Tab.Navigator>
)
}
export default function App() { export default function App() {
return ( return (
<View style={styles.container}> <SafeAreaProvider>
<Text>Open up App.js to start working on your app!</Text> <AuthProvider>
<StatusBar style="auto" /> <NavigationContainer>
</View> <MainTabs />
); </NavigationContainer>
<StatusBar style="auto" />
</AuthProvider>
</SafeAreaProvider>
)
} }
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
alignItems: 'center',
justifyContent: 'center',
},
});

View File

@@ -20,8 +20,27 @@
"foregroundImage": "./assets/adaptive-icon.png", "foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff" "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": { "web": {
"favicon": "./assets/favicon.png" "favicon": "./assets/favicon.png"
} }

578
package-lock.json generated
View File

@@ -8,10 +8,21 @@
"name": "pantry-manager-android-app", "name": "pantry-manager-android-app",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "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": "~54.0.33",
"expo-camera": "^55.0.18",
"expo-status-bar": "~3.0.9", "expo-status-bar": "~3.0.9",
"react": "19.1.0", "react": "19.1.0",
"react-native": "0.81.5" "react-native": "0.81.5",
"react-native-gesture-handler": "^2.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": { "node_modules/@0no-co/graphql.web": {
@@ -1326,6 +1337,21 @@
"@babel/core": "^7.0.0-0" "@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": { "node_modules/@babel/plugin-transform-typescript": {
"version": "7.28.6", "version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz", "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": ">=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": { "node_modules/@expo/code-signing-certificates": {
"version": "0.0.6", "version": "0.0.6",
"resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz", "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" "@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": { "node_modules/@react-native/assets-registry": {
"version": "0.81.5", "version": "0.81.5",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz", "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==", "integrity": "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g==",
"license": "MIT" "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": { "node_modules/@sinclair/typebox": {
"version": "0.27.10", "version": "0.27.10",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz",
@@ -3025,6 +3234,12 @@
"@babel/types": "^7.28.2" "@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": { "node_modules/@types/graceful-fs": {
"version": "4.1.9", "version": "4.1.9",
"resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",
@@ -3034,6 +3249,12 @@
"@types/node": "*" "@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": { "node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6", "version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", "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" "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": { "node_modules/@types/stack-utils": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
@@ -3541,6 +3780,15 @@
"node": "18 || 20 || >=22" "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": { "node_modules/base64-js": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -3901,6 +4149,19 @@
"node": ">=0.8" "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": { "node_modules/color-convert": {
"version": "1.9.3", "version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
@@ -3916,6 +4177,34 @@
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
"license": "MIT" "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": { "node_modules/commander": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
@@ -4048,6 +4337,12 @@
"node": ">= 8" "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": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "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": { "node_modules/deep-extend": {
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", "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": { "node_modules/expo-modules-autolinking": {
"version": "3.0.25", "version": "3.0.25",
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.25.tgz", "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==", "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==",
"license": "Apache-2.0" "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": { "node_modules/fast-json-stable-stringify": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -4913,6 +5243,15 @@
"node": ">=8" "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": { "node_modules/finalhandler": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz",
@@ -5113,6 +5452,21 @@
"hermes-estree": "0.32.0" "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": { "node_modules/hosted-git-info": {
"version": "7.0.2", "version": "7.0.2",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz",
@@ -5173,6 +5527,12 @@
"node": ">= 14" "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": { "node_modules/ieee754": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -5258,6 +5618,12 @@
"loose-envify": "^1.0.0" "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": { "node_modules/is-core-module": {
"version": "2.16.2", "version": "2.16.2",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", "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" "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": { "node_modules/queue": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
@@ -7269,6 +7653,18 @@
"ws": "^7" "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": { "node_modules/react-is": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "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": { "node_modules/react-native-is-edge-to-edge": {
"version": "1.3.1", "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", "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": "*" "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": { "node_modules/react-native/node_modules/@react-native/virtualized-lists": {
"version": "0.81.5", "version": "0.81.5",
"resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.81.5.tgz", "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.81.5.tgz",
@@ -7818,6 +8294,15 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC" "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": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -7868,6 +8353,15 @@
"plist": "^3.0.5" "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": { "node_modules/sisteransi": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
@@ -7929,6 +8423,15 @@
"node": ">=0.10.0" "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": { "node_modules/sprintf-js": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
@@ -7992,6 +8495,15 @@
"node": ">= 0.10.0" "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": { "node_modules/string-width": {
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@@ -8122,6 +8634,18 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/tar": {
"version": "7.5.15", "version": "7.5.15",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz",
@@ -8467,6 +8991,24 @@
"browserslist": ">= 4.21.0" "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": { "node_modules/utils-merge": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@@ -8519,6 +9061,12 @@
"makeerror": "1.0.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": { "node_modules/wcwidth": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
@@ -8780,6 +9328,34 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "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"
}
} }
} }
} }

View File

@@ -4,15 +4,26 @@
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"start": "expo start", "start": "expo start",
"android": "expo start --android", "android": "expo run:android",
"ios": "expo start --ios", "ios": "expo run:ios",
"web": "expo start --web" "web": "expo start --web"
}, },
"dependencies": { "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": "~54.0.33",
"expo-camera": "^55.0.18",
"expo-status-bar": "~3.0.9", "expo-status-bar": "~3.0.9",
"react": "19.1.0", "react": "19.1.0",
"react-native": "0.81.5" "react-native": "0.81.5",
"react-native-gesture-handler": "^2.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 "private": true
} }

217
src/api/client.js Normal file
View File

@@ -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' }) },
}

249
src/components/ui.jsx Normal file
View File

@@ -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 (
<View style={[s.banner, { backgroundColor: bgMap[type], borderColor: borderMap[type] }]}>
<Text style={[s.bannerText, { color: textMap[type] }]}>{children}</Text>
</View>
)
}
export function Panel({ children, style }) {
return <View style={[s.panel, style]}>{children}</View>
}
export function SectionHeading({ title, right }) {
return (
<View style={s.sectionHeading}>
<Text style={s.sectionTitle}>{title}</Text>
{right && <View>{right}</View>}
</View>
)
}
export function FieldGroup({ label, children }) {
return (
<View style={s.fieldGroup}>
<Text style={s.label}>{label}</Text>
{children}
</View>
)
}
export function InputField({ label, ...props }) {
return (
<FieldGroup label={label}>
<TextInput style={s.input} placeholderTextColor={colors.textMuted} {...props} />
</FieldGroup>
)
}
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 (
<TouchableOpacity
style={[s.btn, { backgroundColor: bgMap[variant], opacity }, style]}
onPress={onPress}
disabled={disabled}
activeOpacity={0.75}
>
<Text style={[s.btnText, { color: textColorMap[variant] }]}>{title}</Text>
</TouchableOpacity>
)
}
export function BtnRow({ children }) {
return <View style={s.btnRow}>{children}</View>
}
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 (
<View style={[s.chip, { backgroundColor: bgMap[type] }]}>
<Text style={[s.chipText, { color: textMap[type] }]}>{label}</Text>
</View>
)
}
export function EmptyState({ children }) {
return (
<View style={s.emptyState}>
<Text style={s.emptyStateText}>{children}</Text>
</View>
)
}
export function EntityRow({ selected, children, style }) {
return (
<View style={[s.entityRow, selected && s.entityRowSelected, style]}>
{children}
</View>
)
}
export function EntityMeta({ children }) {
return <Text style={s.entityMeta}>{children}</Text>
}
export function EntityActions({ children }) {
return <View style={s.entityActions}>{children}</View>
}
export function PageTitle({ children }) {
return <Text style={s.pageTitle}>{children}</Text>
}
export function Divider() {
return <View style={s.divider} />
}
export function FormNote({ children }) {
return <Text style={s.formNote}>{children}</Text>
}
export function Picker({ label, selectedValue, onValueChange, children, enabled = true }) {
return (
<FieldGroup label={label}>
<View style={[s.pickerWrap, !enabled && { opacity: 0.5 }]}>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
{children}
</ScrollView>
</View>
</FieldGroup>
)
}
export function PickerBtn({ label, selected, onPress, disabled }) {
return (
<TouchableOpacity
style={[s.pickerBtn, selected && s.pickerBtnSelected]}
onPress={onPress}
disabled={disabled}
>
<Text style={[s.pickerBtnText, selected && s.pickerBtnTextSelected]}>{label}</Text>
</TouchableOpacity>
)
}
export function CheckRow({ label, value, onValueChange }) {
return (
<View style={s.checkRow}>
<Text style={s.label}>{label}</Text>
<Switch value={value} onValueChange={onValueChange} trackColor={{ true: colors.primary }} />
</View>
)
}
export function LoadingSpinner() {
return (
<View style={s.spinnerWrap}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
)
}
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' },
})

View File

@@ -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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
export function useAuth() {
const value = useContext(AuthContext)
if (!value) throw new Error('useAuth must be used inside an AuthProvider.')
return value
}

277
src/screens/AdminScreen.jsx Normal file
View File

@@ -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 (
<View style={s.container}><Text style={s.authMsg}>Sign in to access admin features.</Text></View>
)
const totalMembers = households.reduce((n, h) => n + (h.members?.length ?? 0), 0)
const managedCount = households.filter(h => h.isCurrentUserHouseholdAdmin || isSiteAdmin).length
return (
<ScrollView style={s.container} contentContainerStyle={s.content}>
{error ? <StatusBanner type="error">{error}</StatusBanner> : null}
{status ? <StatusBanner type="success">{status}</StatusBanner> : null}
{loading ? <StatusBanner type="info">Processing...</StatusBanner> : null}
<Panel>
<SectionHeading title="Household Summary" />
<View style={s.statsGrid}>
<View style={s.statCard}><Text style={s.statLabel}>Households</Text><Text style={s.statValue}>{households.length}</Text></View>
<View style={s.statCard}><Text style={s.statLabel}>Managed</Text><Text style={s.statValue}>{managedCount}</Text></View>
<View style={s.statCard}><Text style={s.statLabel}>Members</Text><Text style={s.statValue}>{totalMembers}</Text></View>
</View>
</Panel>
<Panel>
<SectionHeading title={editingHouseholdId ? 'Edit Household' : 'Create Household'} right={editingHouseholdId ? <Btn title="New" variant="secondary" onPress={() => { setEditingHouseholdId(''); setHouseholdForm(EMPTY_HOUSEHOLD_FORM) }} /> : null} />
{!isSiteAdmin && !editingHouseholdId && <FormNote>Only site admins can create households.</FormNote>}
<InputField label="Name" value={householdForm.name} onChangeText={v => setHouseholdForm(f => ({ ...f, name: v }))} placeholder="Main Household" />
<InputField label="Description" value={householdForm.description} onChangeText={v => setHouseholdForm(f => ({ ...f, description: v }))} placeholder="Shared pantry access" multiline />
<BtnRow>
<Btn title={editingHouseholdId ? 'Update household' : 'Create household'} onPress={submitHousehold} disabled={!canSubmitForm || loading} />
<Btn title="Clear" variant="secondary" onPress={() => { setEditingHouseholdId(''); setHouseholdForm(EMPTY_HOUSEHOLD_FORM) }} />
</BtnRow>
</Panel>
<Panel>
<SectionHeading title="Invite Member" right={<Text style={s.subtleText}>{selectedHousehold?.name || 'None selected'}</Text>} />
{!selectedHousehold ? <EmptyState>Select a household to invite a member.</EmptyState> : (
<>
{!canManageSelected && <FormNote>You can only invite users to households you administer.</FormNote>}
<InputField label="Email" value={inviteEmail} onChangeText={setInviteEmail} keyboardType="email-address" autoCapitalize="none" placeholder="member@example.com" editable={canManageSelected} />
<BtnRow>
<Btn title="Send invite" onPress={submitInvite} disabled={!canManageSelected || loading} />
<Btn title="Clear" variant="secondary" onPress={() => setInviteEmail('')} />
</BtnRow>
</>
)}
</Panel>
<Panel>
<SectionHeading title="Create User" />
<FormNote>{isSiteAdmin ? 'Creates a new account via the register endpoint.' : 'Only site admins can create users.'}</FormNote>
<InputField label="First Name" value={userForm.firstName} onChangeText={v => setUserForm(f => ({ ...f, firstName: v }))} placeholder="Jordan" editable={isSiteAdmin} />
<InputField label="Last Name" value={userForm.lastName} onChangeText={v => setUserForm(f => ({ ...f, lastName: v }))} placeholder="Lee" editable={isSiteAdmin} />
<InputField label="Email" value={userForm.email} onChangeText={v => setUserForm(f => ({ ...f, email: v }))} keyboardType="email-address" autoCapitalize="none" placeholder="new@example.com" editable={isSiteAdmin} />
<InputField label="Password" value={userForm.password} onChangeText={v => setUserForm(f => ({ ...f, password: v }))} secureTextEntry placeholder="Password" editable={isSiteAdmin} />
<InputField label="Confirm Password" value={userForm.confirmPassword} onChangeText={v => setUserForm(f => ({ ...f, confirmPassword: v }))} secureTextEntry placeholder="Repeat password" editable={isSiteAdmin} />
<BtnRow>
<Btn title="Create user" onPress={submitCreateUser} disabled={!isSiteAdmin || loading} />
<Btn title="Clear" variant="secondary" onPress={() => setUserForm(EMPTY_USER_FORM)} disabled={!isSiteAdmin} />
</BtnRow>
{createdUser && (
<View style={s.createdUserCard}>
<Text style={s.strongText}>{formatPersonName(createdUser)}</Text>
<EntityMeta>{createdUser.email}</EntityMeta>
{createdUser.id && <EntityMeta>ID: {createdUser.id}</EntityMeta>}
<View style={s.roleList}>
{(Array.isArray(createdUser.roles) && createdUser.roles.length > 0
? createdUser.roles
: ['No roles assigned']
).map((r, i) => <View key={i} style={s.roleBadge}><Text style={s.roleBadgeText}>{r}</Text></View>)}
</View>
</View>
)}
</Panel>
<Panel>
<SectionHeading title="Household History" right={<Text style={s.subtleText}>{selectedHousehold?.name || 'None selected'}</Text>} />
{historyLoading ? <StatusBanner type="info">Loading history...</StatusBanner> : null}
{historyError ? <StatusBanner type="error">{historyError}</StatusBanner> : null}
{!selectedHousehold ? <EmptyState>Select a household to see its history.</EmptyState> :
householdHistory.length === 0 ? <EmptyState>No history returned.</EmptyState> :
householdHistory.map(entry => (
<EntityRow key={entry.id}>
<Text style={s.strongText}>{entry.action}</Text>
<EntityMeta>{formatDate(entry.changedAt) || 'Unknown'} by {entry.changedByEmail || 'Unknown'}</EntityMeta>
<EntityMeta>{entry.description || 'No description.'}</EntityMeta>
{entry.affectedUserEmail && <EntityMeta>Affected: {entry.affectedUserEmail}</EntityMeta>}
</EntityRow>
))
}
</Panel>
<Panel>
<SectionHeading title="Households" right={<Text style={s.subtleText}>{households.length} total</Text>} />
{households.length === 0 ? <EmptyState>No households yet.</EmptyState> : households.map(h => {
const canManage = isSiteAdmin || h.isCurrentUserHouseholdAdmin
return (
<EntityRow key={h.id} selected={selectedHouseholdId === h.id}>
<View style={s.householdHeader}>
<Text style={s.strongText}>{h.name}</Text>
<View style={s.badgeRow}>
{h.isCurrentUserHouseholdAdmin && <View style={s.adminBadge}><Text style={s.badgeText}>Household admin</Text></View>}
</View>
</View>
<EntityMeta>{h.description || 'No description.'}</EntityMeta>
<EntityMeta>Created: {formatDate(h.createdAt) || 'Not available'}</EntityMeta>
{(h.members ?? []).map(member => (
<View key={member.userId} style={s.memberRow}>
<Text style={s.memberName}>{formatPersonName(member)}</Text>
<EntityMeta>{member.email}</EntityMeta>
</View>
))}
<EntityActions>
<Btn title="Select" variant="secondary" onPress={() => setSelectedHouseholdId(h.id)} />
{canManage && <Btn title="Edit" variant="secondary" onPress={() => { setSelectedHouseholdId(h.id); setEditingHouseholdId(h.id); setHouseholdForm({ name: h.name ?? '', description: h.description ?? '' }) }} />}
<Btn title="Leave" variant="danger" onPress={() => handleLeave(h.id)} />
</EntityActions>
</EntityRow>
)
})}
</Panel>
</ScrollView>
)
}
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 },
})

View File

@@ -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 (
<View style={s.container}><Text style={s.authMsg}>Sign in to use the barcode scanner.</Text></View>
)
return (
<ScrollView style={s.container} contentContainerStyle={s.content}>
{error ? <StatusBanner type="error">{error}</StatusBanner> : null}
{status ? <StatusBanner type="success">{status}</StatusBanner> : null}
{inventoryLoading ? <StatusBanner type="info">Refreshing inventory...</StatusBanner> : null}
{submitting ? <StatusBanner type="info">Saving...</StatusBanner> : null}
<Panel>
<View style={s.modeToggle}>
<TouchableOpacity style={[s.modeBtn, mode === 'camera' && s.modeBtnActive]} onPress={() => setMode('camera')}>
<Text style={[s.modeBtnText, mode === 'camera' && s.modeBtnTextActive]}>Camera</Text>
</TouchableOpacity>
<TouchableOpacity style={[s.modeBtn, mode === 'manual' && s.modeBtnActive]} onPress={() => setMode('manual')}>
<Text style={[s.modeBtnText, mode === 'manual' && s.modeBtnTextActive]}>Manual</Text>
</TouchableOpacity>
</View>
{hasScannedBarcode && (
<View style={s.scanStatus}>
<View style={s.scanChipBarcode}><Text style={s.scanChipText}>{lastScannedBarcode}</Text></View>
<View style={[s.scanChip, hasExactMatches ? s.scanChipSuccess : s.scanChipWarning]}>
<Text style={s.scanChipText}>{hasExactMatches ? `${matchingItems.length} match${matchingItems.length !== 1 ? 'es' : ''}` : 'No exact matches'}</Text>
</View>
</View>
)}
{mode === 'camera' ? (
<>
{!permission?.granted ? (
<View style={s.cameraPlaceholder}>
<Text style={s.cameraPlaceholderText}>Camera permission required.</Text>
<Btn title="Grant Permission" onPress={requestPermission} style={{ marginTop: spacing.sm }} />
</View>
) : !cameraActive ? (
<View style={s.cameraPlaceholder}>
<Text style={s.cameraPlaceholderText}>Camera inactive. Tap Start to scan.</Text>
</View>
) : (
<View style={s.cameraFrame}>
<CameraView style={s.camera} facing="back" onBarcodeScanned={handleCameraScan} barcodeScannerSettings={{ barcodeTypes: ['ean13', 'ean8', 'upc_a', 'upc_e', 'code128', 'code39'] }}>
<View style={s.scanOverlay}><View style={s.scanWindow} /></View>
</CameraView>
</View>
)}
<BtnRow>
{!cameraActive
? <Btn title="Start Camera" onPress={() => setCameraActive(true)} />
: <Btn title="Stop Camera" variant="danger" onPress={() => setCameraActive(false)} />
}
<Btn title="Quick Add" onPress={handleQuickAdd} disabled={!hasScannedBarcode || submitting || inventoryLoading} />
<Btn title="Quick Remove" variant="danger" onPress={handleQuickRemove} disabled={!hasScannedBarcode || !hasExactMatches || submitting || inventoryLoading} />
<Btn title="Refresh" variant="secondary" onPress={handleRefreshInventory} disabled={inventoryLoading || submitting} />
</BtnRow>
</>
) : (
<>
<InputField label="Barcode" value={manualInput} onChangeText={setManualInput} placeholder="Scan, type, or paste" keyboardType="numeric" onSubmitEditing={() => onBarcodeScanned(manualInput)} returnKeyType="go" />
<BtnRow>
<Btn title="Check barcode" onPress={() => onBarcodeScanned(manualInput)} />
<Btn title="Quick Add" onPress={handleQuickAdd} disabled={!hasScannedBarcode || submitting || inventoryLoading} />
<Btn title="Quick Remove" variant="danger" onPress={handleQuickRemove} disabled={!hasScannedBarcode || !hasExactMatches || submitting || inventoryLoading} />
<Btn title="Clear" variant="secondary" onPress={() => setManualInput('')} />
<Btn title="Refresh" variant="secondary" onPress={handleRefreshInventory} disabled={inventoryLoading || submitting} />
</BtnRow>
</>
)}
{hasScannedBarcode && <Btn title="Clear scan" variant="secondary" onPress={clearScan} style={{ marginTop: spacing.sm }} />}
</Panel>
<Panel>
<SectionHeading title="Inventory Matches" right={hasScannedBarcode ? <Text style={s.subtleText}>{matchingItems.length} exact</Text> : null} />
{!hasScannedBarcode ? <EmptyState>Scan a barcode to search your inventory.</EmptyState> :
!hasExactMatches ? <EmptyState>No items match barcode {lastScannedBarcode}.</EmptyState> :
matchingItems.map(item => (
<EntityRow key={item.id} selected={editingItemId === item.id}>
<View style={s.rowHeader}><Text style={s.strongText}>{item.name}</Text><View style={[s.scanChip, s.scanChipSuccess]}><Text style={s.scanChipText}>Match</Text></View></View>
<EntityMeta>{item.location?.name || 'No location'}</EntityMeta>
<EntityMeta>Amount: {formatAmount(item.amount, item.amountType)}</EntityMeta>
<EntityMeta>Expiry: {formatDate(item.expiryDate) || 'Not set'}</EntityMeta>
<EntityMeta>Barcode: {item.barcode || 'Not set'}</EntityMeta>
<EntityActions><Btn title="Update item" variant="secondary" onPress={() => handleLoadItemForEdit(item.id)} disabled={submitting} /></EntityActions>
</EntityRow>
))
}
</Panel>
<Panel>
<SectionHeading title={editorMode === 'update' ? 'Update Item' : 'Add Item'}
right={editorMode ? <Btn title="Close" variant="secondary" onPress={() => { setEditorMode(''); setEditingItemId(''); setItemForm(createItemForm(lastScannedBarcode)) }} /> : null} />
{!editorMode ? (
<EmptyState>Use Quick Add to clone a match, or Update Item on a match to edit it here.</EmptyState>
) : (
<>
<InputField label="Name" value={itemForm.name} onChangeText={v => setItemForm(f => ({ ...f, name: v }))} placeholder="Whole Milk" />
<InputField label="Barcode" value={itemForm.barcode} onChangeText={v => setItemForm(f => ({ ...f, barcode: v }))} placeholder="01234567890" keyboardType="numeric" />
<FieldGroup label="Location">
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={{ paddingVertical: 4 }}>
<PickerBtn label="No location" selected={!itemForm.locationId} onPress={() => setItemForm(f => ({ ...f, locationId: '' }))} />
{locations.map(loc => <PickerBtn key={loc.id} label={loc.name} selected={itemForm.locationId === loc.id} onPress={() => setItemForm(f => ({ ...f, locationId: loc.id }))} />)}
</ScrollView>
</FieldGroup>
<InputField label="Amount" value={itemForm.amount} onChangeText={v => setItemForm(f => ({ ...f, amount: v }))} placeholder="2" keyboardType="decimal-pad" />
<InputField label="Amount Type" value={itemForm.amountType} onChangeText={v => setItemForm(f => ({ ...f, amountType: v }))} placeholder="litres" />
<FieldGroup label="Expiry Date">
<Btn title={itemForm.expiryDate || 'Select date'} variant="secondary" onPress={() => setShowExpiryPicker(true)} />
{showExpiryPicker && <DateTimePicker value={itemForm.expiryDate ? new Date(itemForm.expiryDate) : new Date()} mode="date" onChange={(_, d) => { setShowExpiryPicker(false); if (d) setItemForm(f => ({ ...f, expiryDate: d.toISOString().slice(0, 10) })) }} />}
</FieldGroup>
<FieldGroup label="Use By Date">
<Btn title={itemForm.useByDate || 'Select date'} variant="secondary" onPress={() => setShowUseByPicker(true)} />
{showUseByPicker && <DateTimePicker value={itemForm.useByDate ? new Date(itemForm.useByDate) : new Date()} mode="date" onChange={(_, d) => { setShowUseByPicker(false); if (d) setItemForm(f => ({ ...f, useByDate: d.toISOString().slice(0, 10) })) }} />}
</FieldGroup>
<BtnRow>
<Btn title={editorMode === 'update' ? 'Save changes' : 'Create item'} onPress={handleItemSubmit} disabled={submitting} />
<Btn title="Reset form" variant="secondary" onPress={() => setItemForm(createItemForm(lastScannedBarcode))} disabled={submitting} />
</BtnRow>
</>
)}
</Panel>
{hasScannedBarcode && !hasExactMatches && (
<Panel>
<SectionHeading title="Match to Existing Item" right={<Text style={s.subtleText}>Assign {lastScannedBarcode}</Text>} />
<InputField label="Search items" value={matchQuery} onChangeText={setMatchQuery} placeholder="Name, location, barcode..." />
{matchCandidates.length === 0 ? <EmptyState>No items match that filter.</EmptyState> :
matchCandidates.map(item => (
<EntityRow key={item.id}>
<Text style={s.strongText}>{item.name}</Text>
<EntityMeta>{item.location?.name || 'No location'} | {formatAmount(item.amount, item.amountType)}</EntityMeta>
<EntityMeta>Current barcode: {item.barcode || 'Not set'}</EntityMeta>
<EntityActions><Btn title="Match barcode" variant="secondary" onPress={() => handleMatchItem(item.id, item.name, item.barcode)} disabled={submitting} /></EntityActions>
</EntityRow>
))
}
</Panel>
)}
</ScrollView>
)
}
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 },
})

150
src/screens/HomeScreen.jsx Normal file
View File

@@ -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 (
<ScrollView style={s.container} contentContainerStyle={s.content}>
{initializing && <StatusBanner type="info">Restoring your session...</StatusBanner>}
{formError ? <StatusBanner type="error">{formError}</StatusBanner> : null}
<Panel>
<Text style={s.loginTitle}>Pantry Manager</Text>
<Text style={s.loginSubtitle}>Sign in to continue</Text>
<InputField label="Email" value={email} onChangeText={setEmail} keyboardType="email-address" autoCapitalize="none" placeholder="you@example.com" />
<InputField label="Password" value={password} onChangeText={setPassword} secureTextEntry placeholder="Your password" />
<BtnRow>
<Btn title={submitting ? 'Signing in...' : 'Login'} onPress={handleLogin} disabled={submitting} />
</BtnRow>
</Panel>
</ScrollView>
)
}
return (
<ScrollView style={s.container} contentContainerStyle={s.content}>
{loading && <StatusBanner type="info">Loading dashboard...</StatusBanner>}
{dashboardError ? <StatusBanner type="error">{dashboardError}</StatusBanner> : null}
<Panel>
<SectionHeading title="Summary" />
<View style={s.statsGrid}>
<View style={s.statCard}><Text style={s.statLabel}>Locations</Text><Text style={s.statValue}>{locations.length}</Text></View>
<View style={s.statCard}><Text style={s.statLabel}>Items</Text><Text style={s.statValue}>{items.length}</Text></View>
<View style={s.statCard}><Text style={s.statLabel}>Expiring Soon</Text><Text style={s.statValue}>{expiringSoon.length}</Text></View>
</View>
</Panel>
<Panel>
<SectionHeading title="Inventory by Location" />
{locations.length === 0 ? <EmptyState>No locations yet.</EmptyState> : (
locations.map((loc, i) => {
const count = items.filter(item => item.location?.id === loc.id).length
return (
<View key={loc.id} style={s.locationBar}>
<View style={[s.locationDot, { backgroundColor: PIE_COLORS[i % PIE_COLORS.length] }]} />
<Text style={s.locationBarLabel}>{loc.name}</Text>
<Text style={s.locationBarCount}>{count} item{count !== 1 ? 's' : ''}</Text>
</View>
)
})
)}
</Panel>
<Panel>
<SectionHeading title="Nearest Expiry Dates" />
{expiringItems.length === 0 ? <EmptyState>No dated inventory items yet.</EmptyState> : (
expiringItems.map(item => {
const exp = getExpiryStatus(item.expiryDate)
return (
<View key={item.id} style={[s.expiryCard, { borderLeftColor: exp.color }]}>
<Text style={s.expiryName}>{item.name}</Text>
<EntityMeta>{item.location?.name || 'No location'} | {formatAmount(item.amount, item.amountType)}</EntityMeta>
<EntityMeta style={{ color: exp.color }}>{formatDate(item.expiryDate)} {exp.text}</EntityMeta>
</View>
)
})
)}
</Panel>
</ScrollView>
)
}
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 },
})

View File

@@ -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 (
<View style={s.container}><Text style={s.authMsg}>Sign in on the home screen to manage inventory.</Text></View>
)
return (
<ScrollView style={s.container} contentContainerStyle={s.content}>
{error ? <StatusBanner type="error">{error}</StatusBanner> : null}
{status ? <StatusBanner type="success">{status}</StatusBanner> : null}
{loading ? <StatusBanner type="info">Syncing with API...</StatusBanner> : null}
<Panel>
<SectionHeading title="Locations" right={editingLocationId ? <Btn title="New" variant="secondary" onPress={() => { setEditingLocationId(''); setLocationForm(EMPTY_LOCATION_FORM) }} /> : null} />
<InputField label="Name" value={locationForm.name} onChangeText={v => setLocationForm(f => ({ ...f, name: v }))} placeholder="Pantry" />
<InputField label="Description" value={locationForm.description} onChangeText={v => setLocationForm(f => ({ ...f, description: v }))} placeholder="Main kitchen shelf" multiline />
<BtnRow>
<Btn title={editingLocationId ? 'Update location' : 'Create location'} onPress={submitLocation} disabled={loading} />
<Btn title="Clear" variant="secondary" onPress={() => { setEditingLocationId(''); setLocationForm(EMPTY_LOCATION_FORM) }} />
</BtnRow>
{locations.length === 0 ? <EmptyState>No locations yet.</EmptyState> : locations.map(loc => (
<EntityRow key={loc.id} selected={selectedLocationId === loc.id || editingLocationId === loc.id}>
<Text style={s.strongText}>{loc.name}</Text>
<EntityMeta>{loc.description || 'No description provided.'}</EntityMeta>
<EntityActions>
<Btn title="Edit" variant="secondary" onPress={() => { setSelectedLocationId(loc.id); setEditingLocationId(loc.id); setLocationForm({ name: loc.name, description: loc.description ?? '' }) }} />
<Btn title="History" variant="secondary" onPress={() => setSelectedLocationId(loc.id)} />
<Btn title="Delete" variant="danger" onPress={() => deleteLocation(loc.id)} />
</EntityActions>
</EntityRow>
))}
</Panel>
<Panel>
<SectionHeading title="Location History" right={<Text style={s.subtleText}>{locations.find(l => l.id === selectedLocationId)?.name || 'None selected'}</Text>} />
{locationHistoryLoading ? <StatusBanner type="info">Loading history...</StatusBanner> : null}
{locationHistoryError ? <StatusBanner type="error">{locationHistoryError}</StatusBanner> : null}
{!selectedLocationId ? <EmptyState>Select a location to see its history.</EmptyState> :
locationHistory.length === 0 ? <EmptyState>No history returned.</EmptyState> :
locationHistory.map(entry => (
<EntityRow key={entry.id}>
<Text style={s.strongText}>{entry.action}</Text>
<EntityMeta>{formatDate(entry.changedAt) || 'Unknown date'} by {entry.changedByEmail || 'Unknown'}</EntityMeta>
<EntityMeta>{entry.description || 'No description.'}</EntityMeta>
</EntityRow>
))
}
</Panel>
<Panel>
<SectionHeading title={editingItemId ? 'Edit Item' : 'Add Item'} right={editingItemId ? <Btn title="New item" variant="secondary" onPress={() => { setEditingItemId(''); setItemForm(createItemForm()) }} /> : null} />
<InputField label="Name" value={itemForm.name} onChangeText={v => setItemForm(f => ({ ...f, name: v }))} placeholder="Whole Milk" />
<InputField label="Barcode" value={itemForm.barcode} onChangeText={v => setItemForm(f => ({ ...f, barcode: v }))} placeholder="01234567890" keyboardType="numeric" />
<FieldGroup label="Location">
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={{ paddingVertical: 4 }}>
<PickerBtn label="No location" selected={!itemForm.locationId} onPress={() => setItemForm(f => ({ ...f, locationId: '' }))} />
{locations.map(loc => <PickerBtn key={loc.id} label={loc.name} selected={itemForm.locationId === loc.id} onPress={() => setItemForm(f => ({ ...f, locationId: loc.id }))} />)}
</ScrollView>
</FieldGroup>
<InputField label="Amount" value={itemForm.amount} onChangeText={v => setItemForm(f => ({ ...f, amount: v }))} placeholder="2" keyboardType="decimal-pad" />
<InputField label="Amount Type" value={itemForm.amountType} onChangeText={v => setItemForm(f => ({ ...f, amountType: v }))} placeholder="litres" />
<FieldGroup label="Expiry Date">
<Btn title={itemForm.expiryDate || 'Select date'} variant="secondary" onPress={() => setShowExpiryPicker(true)} />
{showExpiryPicker && (
<DateTimePicker value={itemForm.expiryDate ? new Date(itemForm.expiryDate) : new Date()} mode="date" onChange={(_, d) => { setShowExpiryPicker(false); if (d) setItemForm(f => ({ ...f, expiryDate: d.toISOString().slice(0, 10) })) }} />
)}
{itemForm.expiryDate ? <Btn title="Clear" variant="secondary" onPress={() => setItemForm(f => ({ ...f, expiryDate: '' }))} style={{ marginTop: 4 }} /> : null}
</FieldGroup>
<FieldGroup label="Use By Date">
<Btn title={itemForm.useByDate || 'Select date'} variant="secondary" onPress={() => setShowUseByPicker(true)} />
{showUseByPicker && (
<DateTimePicker value={itemForm.useByDate ? new Date(itemForm.useByDate) : new Date()} mode="date" onChange={(_, d) => { setShowUseByPicker(false); if (d) setItemForm(f => ({ ...f, useByDate: d.toISOString().slice(0, 10) })) }} />
)}
{itemForm.useByDate ? <Btn title="Clear" variant="secondary" onPress={() => setItemForm(f => ({ ...f, useByDate: '' }))} style={{ marginTop: 4 }} /> : null}
</FieldGroup>
<BtnRow>
<Btn title={editingItemId ? 'Update item' : 'Create item'} onPress={submitItem} disabled={loading} />
<Btn title="Clear" variant="secondary" onPress={() => { setEditingItemId(''); setItemForm(createItemForm()) }} />
</BtnRow>
</Panel>
<Panel>
<SectionHeading title="Inventory Items" right={<Text style={s.subtleText}>{items.length} total</Text>} />
{items.length === 0 ? <EmptyState>No inventory items yet.</EmptyState> : items.map(item => {
const exp = getExpiryStatus(item.expiryDate)
return (
<EntityRow key={item.id} selected={editingItemId === item.id}>
<Text style={s.strongText}>{item.name}</Text>
<EntityMeta>{item.location?.name || 'No location'} | {formatAmount(item.amount, item.amountType)}</EntityMeta>
<EntityMeta>Expiry: {formatDate(item.expiryDate) || 'Not set'} {exp.text ? `| ${exp.text}` : ''}</EntityMeta>
<EntityMeta>Barcode: {item.barcode || 'Not set'}</EntityMeta>
<EntityActions>
<Btn title="Edit" variant="secondary" onPress={() => loadItemForEdit(item.id)} />
<Btn title="Delete" variant="danger" onPress={() => deleteItem(item.id)} />
</EntityActions>
</EntityRow>
)
})}
</Panel>
</ScrollView>
)
}
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 },
})

View File

@@ -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 (
<View style={s.container}><Text style={s.authMsg}>Sign in to manage meal planners.</Text></View>
)
return (
<ScrollView style={s.container} contentContainerStyle={s.content}>
{error ? <StatusBanner type="error">{error}</StatusBanner> : null}
{status ? <StatusBanner type="success">{status}</StatusBanner> : null}
{loading ? <StatusBanner type="info">Syncing meal planners...</StatusBanner> : null}
<Panel>
<SectionHeading title={editingId ? 'Edit Meal Planner' : 'Create Meal Planner'} right={editingId ? <Btn title="New" variant="secondary" onPress={resetEditor} /> : null} />
<FormNote>Meal planners are household-scoped. Choose a household, date, time, and add ingredients.</FormNote>
<InputField label="Name" value={form.name} onChangeText={v => setForm(f => ({ ...f, name: v }))} placeholder="Pasta night" />
{!editingId && (
<FieldGroup label="Household">
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={{ paddingVertical: 4 }}>
{households.map(h => <PickerBtn key={h.id} label={h.name} selected={form.householdId === h.id} onPress={() => setForm(f => ({ ...f, householdId: h.id, items: [] }))} />)}
</ScrollView>
</FieldGroup>
)}
<FieldGroup label="Planned Date">
<Btn title={form.plannedDate || 'Select date'} variant="secondary" onPress={() => setShowDatePicker(true)} />
{showDatePicker && <DateTimePicker value={form.plannedDate ? new Date(form.plannedDate) : new Date()} mode="date" onChange={(_, d) => { setShowDatePicker(false); if (d) setForm(f => ({ ...f, plannedDate: d.toISOString().slice(0, 10) })) }} />}
</FieldGroup>
<FieldGroup label="Planned Time">
<Btn title={form.plannedTime || 'Select time'} variant="secondary" onPress={() => setShowTimePicker(true)} />
{showTimePicker && <DateTimePicker value={new Date()} mode="time" onChange={(_, d) => { 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}` })) } }} />}
</FieldGroup>
<SectionHeading title="Meal Items" right={<Btn title="Add item" variant="secondary" onPress={addRow} />} />
{form.items.length === 0 ? <EmptyState>No items yet.</EmptyState> : form.items.map((item, idx) => (
<View key={idx} style={s.itemRow}>
<View style={s.itemRowHeader}><Text style={s.itemRowLabel}>Item {idx + 1}</Text><Btn title="Remove" variant="secondary" onPress={() => removeRow(idx)} /></View>
<FieldGroup label="Inventory Item">
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={{ paddingVertical: 4 }}>
<PickerBtn label="Select item" selected={!item.inventoryItemId} onPress={() => updateRow(idx, { inventoryItemId: '' })} />
{inventoryOptions.map(inv => <PickerBtn key={inv.id} label={`${inv.name} (${inv.location?.name || 'No loc'})`} selected={item.inventoryItemId === inv.id} onPress={() => updateRow(idx, { inventoryItemId: inv.id })} />)}
</ScrollView>
</FieldGroup>
<InputField label="Amount Required" value={item.amountRequired} onChangeText={v => updateRow(idx, { amountRequired: v })} keyboardType="decimal-pad" placeholder="1" />
<InputField label="Amount Type" value={item.amountType} onChangeText={v => updateRow(idx, { amountType: v })} placeholder="litres" />
</View>
))}
{editingId && <FormNote>Household is locked while editing.</FormNote>}
<BtnRow>
<Btn title={editingId ? 'Update meal planner' : 'Create meal planner'} onPress={handleSubmit} disabled={loading} />
<Btn title="Clear form" variant="secondary" onPress={resetEditor} />
</BtnRow>
</Panel>
<Panel>
<SectionHeading title="Meal Planners" right={<Text style={s.subtleText}>{planners.length} total</Text>} />
{planners.length === 0 ? <EmptyState>No meal planners yet.</EmptyState> : planners.map(planner => (
<EntityRow key={planner.id} selected={selectedId === planner.id}>
<View style={s.cardHeader}>
<View style={{ flex: 1 }}>
<Text style={s.strongText}>{planner.name}</Text>
<EntityMeta>Household: {resolveHousehold(households, planner.householdId)}</EntityMeta>
<EntityMeta>Planned: {formatDate(planner.plannedDate) || 'Unknown'} at {formatTime(planner.plannedTime) || 'Unknown'}</EntityMeta>
<EntityMeta>Created {formatDate(planner.createdAt) || 'Unknown'} by {planner.createdByEmail || 'Unknown'}</EntityMeta>
</View>
<View style={s.chip}><Text style={s.chipText}>{planner.items?.length ?? 0} item(s)</Text></View>
</View>
{(planner.items ?? []).map(item => (
<View key={item.inventoryItemId} style={s.summaryRow}>
<View style={{ flex: 1 }}>
<Text style={s.itemName}>{item.inventoryItemName || 'Unnamed item'}</Text>
<EntityMeta>{formatAmount(item.amountRequired, item.amountType)}</EntityMeta>
</View>
<View style={s.requiredChip}><Text style={s.requiredChipText}>Required</Text></View>
</View>
))}
<EntityActions>
<Btn title="Select" variant="secondary" onPress={() => setSelectedId(planner.id)} />
<Btn title="Edit" variant="secondary" onPress={() => loadForEdit(planner.id)} />
<Btn title="Delete" variant="danger" onPress={() => handleDelete(planner.id)} />
</EntityActions>
</EntityRow>
))}
</Panel>
</ScrollView>
)
}
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 },
})

View File

@@ -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 (
<View style={s.container}><Text style={s.authMsg}>Sign in to view your profile.</Text></View>
)
return (
<ScrollView style={s.container} contentContainerStyle={s.content}>
{error ? <StatusBanner type="error">{error}</StatusBanner> : null}
{status ? <StatusBanner type="success">{status}</StatusBanner> : null}
{loading ? <StatusBanner type="info">Refreshing profile...</StatusBanner> : null}
{saving ? <StatusBanner type="info">Saving changes...</StatusBanner> : null}
<Panel>
<SectionHeading title="Account Summary" right={<Btn title="Refresh" variant="secondary" onPress={handleRefresh} />} />
<Text style={s.displayName}>{[user?.firstName, user?.lastName].filter(Boolean).join(' ') || user?.email || 'Signed in user'}</Text>
<EntityMeta>Email: {user?.email || 'Not available'}</EntityMeta>
<EntityMeta>User ID: {user?.id || 'Not available'}</EntityMeta>
<View style={s.roleSection}>
<Text style={s.rolesLabel}>Assigned roles</Text>
<View style={s.roleList}>
{roles.length === 0 ? (
<View style={s.roleBadge}><Text style={s.roleBadgeText}>No roles assigned</Text></View>
) : roles.map(role => (
<View key={role} style={s.roleBadge}><Text style={s.roleBadgeText}>{role}</Text></View>
))}
</View>
</View>
</Panel>
<Panel>
<SectionHeading title="Update Profile" />
<FormNote>Leave password fields blank unless changing your password.</FormNote>
<InputField label="Email" value={form.email} onChangeText={v => setForm(f => ({ ...f, email: v }))} keyboardType="email-address" autoCapitalize="none" placeholder="you@example.com" />
<InputField label="First Name" value={form.firstName} onChangeText={v => setForm(f => ({ ...f, firstName: v }))} placeholder="Alex" />
<InputField label="Last Name" value={form.lastName} onChangeText={v => setForm(f => ({ ...f, lastName: v }))} placeholder="Smith" />
<InputField label="Current Password" value={form.currentPassword} onChangeText={v => setForm(f => ({ ...f, currentPassword: v }))} secureTextEntry placeholder="Required to change password" />
<InputField label="New Password" value={form.newPassword} onChangeText={v => setForm(f => ({ ...f, newPassword: v }))} secureTextEntry placeholder="Leave blank to keep current" />
<BtnRow>
<Btn title="Save profile" onPress={handleSave} disabled={saving} />
<Btn title="Reset" variant="secondary" onPress={() => setForm(createProfileForm(user))} disabled={saving} />
</BtnRow>
</Panel>
<Panel>
<SectionHeading title="Session" />
<Btn title="Sign Out" variant="danger" onPress={handleLogout} />
</Panel>
</ScrollView>
)
}
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' },
})

View File

@@ -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 (
<View style={s.container}><Text style={s.authMsg}>Sign in to search your inventory.</Text></View>
)
return (
<ScrollView style={s.container} contentContainerStyle={s.content}>
{error ? <StatusBanner type="error">{error}</StatusBanner> : null}
{loading ? <StatusBanner type="info">Searching...</StatusBanner> : null}
<Panel>
<SectionHeading title="Search Inventory" />
<InputField label="Name / Barcode / Location" value={searchName} onChangeText={setSearchName} placeholder="milk, fridge, 0123..." onSubmitEditing={performSearch} returnKeyType="search" />
<FieldGroup label="Location filter">
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={{ paddingVertical: 4 }}>
<PickerBtn label="All" selected={!locationId} onPress={() => setLocationId('')} />
{locations.map(loc => <PickerBtn key={loc.id} label={loc.name} selected={locationId === loc.id} onPress={() => setLocationId(loc.id)} />)}
</ScrollView>
</FieldGroup>
<View style={s.amountRow}>
<View style={{ flex: 1 }}><InputField label="Min amount" value={minAmount} onChangeText={setMinAmount} keyboardType="decimal-pad" /></View>
<View style={s.amountSep} />
<View style={{ flex: 1 }}><InputField label="Max amount" value={maxAmount} onChangeText={setMaxAmount} keyboardType="decimal-pad" /></View>
</View>
<BtnRow>
<Btn title="Search" onPress={performSearch} disabled={loading} />
<Btn title="Reset" variant="secondary" onPress={resetSearch} />
</BtnRow>
</Panel>
{searchName.trim() && matchingLocations.length > 0 && (
<Panel>
<SectionHeading title="Matching Locations" />
{matchingLocations.map(loc => (
<EntityRow key={loc.id}>
<Text style={s.strongText}>{loc.name}</Text>
<EntityMeta>{loc.description || 'No description.'}</EntityMeta>
</EntityRow>
))}
</Panel>
)}
<Panel>
<SectionHeading title={`Results (${results.length})`} />
{results.length === 0 ? <EmptyState>No items match your search.</EmptyState> : (
<>
{pageItems.map(item => {
const exp = getExpiryStatus(item.expiryDate)
return (
<EntityRow key={item.id}>
<View style={s.itemHeader}>
<Text style={s.strongText}>{item.name}</Text>
<View style={[s.expiryBadge, { backgroundColor: exp.color + '22', borderColor: exp.color }]}>
<Text style={[s.expiryBadgeText, { color: exp.color }]}>{exp.status}</Text>
</View>
</View>
<EntityMeta>{item.location?.name || 'No location'} | {formatAmount(item.amount, item.amountType)}</EntityMeta>
<EntityMeta>Barcode: {item.barcode || 'Not set'} | Expires: {formatDate(item.expiryDate) || 'Not set'}</EntityMeta>
</EntityRow>
)
})}
<View style={s.pagination}>
<Btn title="Prev" variant="secondary" onPress={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1} />
<Text style={s.pageInfo}>Page {page} of {totalPages}</Text>
<Btn title="Next" variant="secondary" onPress={() => setPage(p => Math.min(totalPages, p + 1))} disabled={page === totalPages} />
</View>
</>
)}
</Panel>
</ScrollView>
)
}
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 },
})

View File

@@ -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 (
<View style={s.container}><Text style={s.authMsg}>Sign in to manage shopping lists.</Text></View>
)
return (
<ScrollView style={s.container} contentContainerStyle={s.content}>
{error ? <StatusBanner type="error">{error}</StatusBanner> : null}
{status ? <StatusBanner type="success">{status}</StatusBanner> : null}
{loading ? <StatusBanner type="info">Syncing shopping lists...</StatusBanner> : null}
<Panel>
<SectionHeading title={editingId ? 'Edit Shopping List' : 'Create Shopping List'} right={editingId ? <Btn title="New" variant="secondary" onPress={resetEditor} /> : null} />
<FormNote>Shopping lists are household-scoped. Pick the household first.</FormNote>
<InputField label="Name" value={form.name} onChangeText={v => setForm(f => ({ ...f, name: v }))} placeholder="Weekend shop" />
{!editingId && (
<FieldGroup label="Household">
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={{ paddingVertical: 4 }}>
{households.map(h => <PickerBtn key={h.id} label={h.name} selected={form.householdId === h.id} onPress={() => setForm(f => ({ ...f, householdId: h.id, items: [] }))} />)}
</ScrollView>
</FieldGroup>
)}
<SectionHeading title="List Items" right={<Btn title="Add item" variant="secondary" onPress={addRow} />} />
{form.items.length === 0 ? <EmptyState>No items yet. Tap Add item.</EmptyState> : form.items.map((item, idx) => (
<View key={idx} style={s.itemRow}>
<View style={s.itemRowHeader}><Text style={s.itemRowLabel}>Item {idx + 1}</Text><Btn title="Remove" variant="secondary" onPress={() => removeRow(idx)} /></View>
<FieldGroup label="Inventory Item">
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={{ paddingVertical: 4 }}>
<PickerBtn label="Select item" selected={!item.inventoryItemId} onPress={() => updateRow(idx, { inventoryItemId: '' })} />
{inventoryOptions.map(inv => <PickerBtn key={inv.id} label={`${inv.name} (${inv.location?.name || 'No loc'})`} selected={item.inventoryItemId === inv.id} onPress={() => updateRow(idx, { inventoryItemId: inv.id })} />)}
</ScrollView>
</FieldGroup>
<InputField label="Amount Required" value={item.amountRequired} onChangeText={v => updateRow(idx, { amountRequired: v })} keyboardType="decimal-pad" placeholder="2" />
<InputField label="Amount Type" value={item.amountType} onChangeText={v => updateRow(idx, { amountType: v })} placeholder="cartons" />
<View style={s.checkRow}>
<Text style={s.checkLabel}>Purchased</Text>
<Switch value={item.isPurchased} onValueChange={v => updateRow(idx, { isPurchased: v })} trackColor={{ true: colors.primary }} />
</View>
</View>
))}
{editingId && <FormNote>Household is locked while editing.</FormNote>}
<BtnRow>
<Btn title={editingId ? 'Update shopping list' : 'Create shopping list'} onPress={handleSubmit} disabled={loading} />
<Btn title="Clear form" variant="secondary" onPress={resetEditor} />
</BtnRow>
</Panel>
<Panel>
<SectionHeading title="Shopping Lists" right={<Text style={s.subtleText}>{lists.length} total</Text>} />
{lists.length === 0 ? <EmptyState>No shopping lists yet.</EmptyState> : lists.map(list => (
<EntityRow key={list.id} selected={selectedId === list.id}>
<View style={s.cardHeader}>
<View style={{ flex: 1 }}>
<Text style={s.strongText}>{list.name}</Text>
<EntityMeta>Household: {resolveHousehold(households, list.householdId)}</EntityMeta>
<EntityMeta>Created {formatDate(list.createdAt) || 'Unknown'} by {list.createdByEmail || 'Unknown'}</EntityMeta>
</View>
<View style={s.chip}><Text style={s.chipText}>{list.items?.length ?? 0} item(s)</Text></View>
</View>
{(list.items ?? []).map(item => (
<View key={item.inventoryItemId} style={s.summaryRow}>
<View style={{ flex: 1 }}>
<Text style={s.itemName}>{item.inventoryItemName || 'Unnamed item'}</Text>
<EntityMeta>{formatAmount(item.amountRequired, item.amountType)}</EntityMeta>
</View>
<View style={[s.statusChip, item.isPurchased ? s.statusChipSuccess : s.statusChipNeutral]}>
<Text style={s.statusChipText}>{item.isPurchased ? 'Purchased' : 'Pending'}</Text>
</View>
</View>
))}
<EntityActions>
<Btn title="Select" variant="secondary" onPress={() => setSelectedId(list.id)} />
<Btn title="Edit" variant="secondary" onPress={() => loadForEdit(list.id)} />
<Btn title="Delete" variant="danger" onPress={() => handleDelete(list.id)} />
</EntityActions>
</EntityRow>
))}
</Panel>
</ScrollView>
)
}
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' },
})

156
src/screens/UsersScreen.jsx Normal file
View File

@@ -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 (
<View style={s.container}><Text style={s.authMsg}>Sign in to access user management.</Text></View>
)
if (!isSiteAdmin) return (
<View style={s.container}><Text style={s.authMsg}>The users page is reserved for site admins.</Text></View>
)
return (
<ScrollView style={s.container} contentContainerStyle={s.content}>
{error ? <StatusBanner type="error">{error}</StatusBanner> : null}
{status ? <StatusBanner type="success">{status}</StatusBanner> : null}
{loading ? <StatusBanner type="info">Loading users...</StatusBanner> : null}
{saving ? <StatusBanner type="info">Saving user...</StatusBanner> : null}
<Panel>
<SectionHeading title={editingId ? 'Edit User' : 'User Editor'} right={editingId ? <Btn title="Clear" variant="secondary" onPress={() => { setEditingId(''); setForm(EMPTY_FORM) }} /> : null} />
<FormNote>Roles are replaced by the comma-separated list you submit. Leave password blank to keep current.</FormNote>
{!editingId ? <EmptyState>Choose a user from the directory to load the edit form.</EmptyState> : (
<>
<Text style={s.editorMeta}>{formatUserName(form)} editing ID: {editingId}</Text>
<InputField label="Email" value={form.email} onChangeText={v => setForm(f => ({ ...f, email: v }))} keyboardType="email-address" autoCapitalize="none" placeholder="user@example.com" />
<InputField label="First Name" value={form.firstName} onChangeText={v => setForm(f => ({ ...f, firstName: v }))} placeholder="Alex" />
<InputField label="Last Name" value={form.lastName} onChangeText={v => setForm(f => ({ ...f, lastName: v }))} placeholder="Smith" />
<InputField label="Reset Password" value={form.password} onChangeText={v => setForm(f => ({ ...f, password: v }))} secureTextEntry placeholder="Leave blank to keep current" />
<InputField label="Roles (comma-separated)" value={form.rolesInput} onChangeText={v => setForm(f => ({ ...f, rolesInput: v }))} placeholder="Site Admin, Admin" autoCapitalize="none" />
<BtnRow>
<Btn title="Save user" onPress={handleSave} disabled={saving} />
<Btn title="Reset" variant="secondary" onPress={() => { setEditingId(''); setForm(EMPTY_FORM) }} disabled={saving} />
</BtnRow>
</>
)}
</Panel>
<Panel>
<SectionHeading title="User Directory" right={<Btn title="Refresh" variant="secondary" onPress={handleRefresh} />} />
{users.length === 0 ? <EmptyState>No users returned.</EmptyState> : users.map(u => {
const roles = Array.isArray(u.roles) ? u.roles : []
return (
<EntityRow key={u.id ?? u.email} selected={selectedId === u.id}>
<Text style={s.strongText}>{formatUserName(u)}</Text>
<EntityMeta>{u.email || 'No email.'}</EntityMeta>
<EntityMeta>ID: {u.id || 'Not set'}</EntityMeta>
<View style={s.roleList}>
{roles.length === 0
? <View style={s.roleBadge}><Text style={s.roleBadgeText}>No roles</Text></View>
: roles.map(r => <View key={r} style={s.roleBadge}><Text style={s.roleBadgeText}>{r}</Text></View>)
}
</View>
<EntityActions>
<Btn title="Select" variant="secondary" onPress={() => setSelectedId(u.id)} />
<Btn title="Edit" variant="secondary" onPress={() => handleEditUser(u.id)} />
</EntityActions>
</EntityRow>
)
})}
</Panel>
</ScrollView>
)
}
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 },
})

56
src/theme.js Normal file
View File

@@ -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,
}

View File

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

62
src/utils/searchUtils.js Normal file
View File

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