Update project files
This commit is contained in:
1
.claude/worktrees/laughing-perlman-a6074b
Submodule
1
.claude/worktrees/laughing-perlman-a6074b
Submodule
Submodule .claude/worktrees/laughing-perlman-a6074b added at 0d71e6b7bc
3
.idea/.gitignore
generated
vendored
Normal file
3
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
1784
.idea/caches/deviceStreaming.xml
generated
Normal file
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
5
.idea/misc.xml
generated
Normal 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
8
.idea/modules.xml
generated
Normal 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
9
.idea/pantry-manager-android-app.iml
generated
Normal 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
6
.idea/studiobot.xml
generated
Normal 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
6
.idea/vcs.xml
generated
Normal 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
86
App.js
@@ -1,20 +1,74 @@
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import 'react-native-gesture-handler'
|
||||
import { NavigationContainer } from '@react-navigation/native'
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'
|
||||
import { createStackNavigator } from '@react-navigation/stack'
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context'
|
||||
import { StatusBar } from 'expo-status-bar'
|
||||
import { Text, View } from 'react-native'
|
||||
|
||||
import { AuthProvider, useAuth } from './src/context/AuthContext.jsx'
|
||||
import HomeScreen from './src/screens/HomeScreen.jsx'
|
||||
import InventoryScreen from './src/screens/InventoryScreen.jsx'
|
||||
import SearchScreen from './src/screens/SearchScreen.jsx'
|
||||
import BarcodeScreen from './src/screens/BarcodeScreen.jsx'
|
||||
import ProfileScreen from './src/screens/ProfileScreen.jsx'
|
||||
import ShoppingListsScreen from './src/screens/ShoppingListsScreen.jsx'
|
||||
import MealPlannersScreen from './src/screens/MealPlannersScreen.jsx'
|
||||
import AdminScreen from './src/screens/AdminScreen.jsx'
|
||||
import UsersScreen from './src/screens/UsersScreen.jsx'
|
||||
import { colors } from './src/theme.js'
|
||||
|
||||
const Tab = createBottomTabNavigator()
|
||||
const Stack = createStackNavigator()
|
||||
|
||||
function TabIcon({ name, focused }) {
|
||||
const icons = {
|
||||
Home: '🏠', Inventory: '📦', Search: '🔍', Barcode: '📷',
|
||||
Shopping: '🛒', Meals: '🍽️', Profile: '👤', Admin: '⚙️', Users: '👥',
|
||||
}
|
||||
return (
|
||||
<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() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text>Open up App.js to start working on your app!</Text>
|
||||
<StatusBar style="auto" />
|
||||
</View>
|
||||
);
|
||||
<SafeAreaProvider>
|
||||
<AuthProvider>
|
||||
<NavigationContainer>
|
||||
<MainTabs />
|
||||
</NavigationContainer>
|
||||
<StatusBar style="auto" />
|
||||
</AuthProvider>
|
||||
</SafeAreaProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#fff',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
21
app.json
21
app.json
@@ -20,8 +20,27 @@
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"edgeToEdgeEnabled": true
|
||||
"edgeToEdgeEnabled": true,
|
||||
"permissions": [
|
||||
"CAMERA",
|
||||
"READ_EXTERNAL_STORAGE",
|
||||
"android.permission.CAMERA",
|
||||
"android.permission.RECORD_AUDIO",
|
||||
"CAMERA",
|
||||
"READ_EXTERNAL_STORAGE",
|
||||
"android.permission.CAMERA",
|
||||
"android.permission.RECORD_AUDIO"
|
||||
],
|
||||
"package": "com.anonymous.pantrymanagerandroidapp"
|
||||
},
|
||||
"plugins": [
|
||||
[
|
||||
"expo-camera",
|
||||
{
|
||||
"cameraPermission": "Allow Pantry Manager to use the camera for barcode scanning."
|
||||
}
|
||||
]
|
||||
],
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
}
|
||||
|
||||
578
package-lock.json
generated
578
package-lock.json
generated
@@ -8,10 +8,21 @@
|
||||
"name": "pantry-manager-android-app",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "^3.0.2",
|
||||
"@react-native-community/datetimepicker": "^9.1.0",
|
||||
"@react-navigation/bottom-tabs": "^7.16.0",
|
||||
"@react-navigation/native": "^7.2.4",
|
||||
"@react-navigation/stack": "^7.9.0",
|
||||
"expo": "~54.0.33",
|
||||
"expo-camera": "^55.0.18",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5"
|
||||
"react-native": "0.81.5",
|
||||
"react-native-gesture-handler": "^2.31.2",
|
||||
"react-native-reanimated": "^4.3.1",
|
||||
"react-native-safe-area-context": "^5.7.0",
|
||||
"react-native-screens": "^4.25.0",
|
||||
"react-native-worklets": "^0.8.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@0no-co/graphql.web": {
|
||||
@@ -1326,6 +1337,21 @@
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-template-literals": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz",
|
||||
"integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": "^7.27.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-typescript": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz",
|
||||
@@ -1515,6 +1541,18 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@egjs/hammerjs": {
|
||||
"version": "2.0.17",
|
||||
"resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz",
|
||||
"integrity": "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hammerjs": "^2.0.36"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/code-signing-certificates": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz",
|
||||
@@ -2700,6 +2738,42 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native-async-storage/async-storage": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-3.0.2.tgz",
|
||||
"integrity": "sha512-XP0zDIl+1XoeuQ7f878qXKdl77zLwzLALPpxvNRc7ZtDh9ew36WSvOdQOhFkexMySapFAWxEbZxS8K8J2DU4eg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"idb": "8.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native-community/datetimepicker": {
|
||||
"version": "9.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-native-community/datetimepicker/-/datetimepicker-9.1.0.tgz",
|
||||
"integrity": "sha512-eadbnk+I2vxvW30iTAsm/qlCnMMAadkifIMYNEB2lzhxN/SvlKc7S2V4k5DyrwjdCbqdcMk3t9K6fnUMcAV34w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"invariant": "^2.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": ">=52.0.0",
|
||||
"react": "*",
|
||||
"react-native": "*",
|
||||
"react-native-windows": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"expo": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native-windows": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native/assets-registry": {
|
||||
"version": "0.81.5",
|
||||
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
|
||||
@@ -2960,6 +3034,141 @@
|
||||
"integrity": "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@react-navigation/bottom-tabs": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.16.0.tgz",
|
||||
"integrity": "sha512-ShHaAR2mQxsSzL2kDRWfquomRsFfG+bm+UBRgu8D9ScbNBnnetzwOBestz/1DugVj89WtNapWJuHbMdgyCZEyQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-navigation/elements": "^2.9.17",
|
||||
"color": "^4.2.3",
|
||||
"sf-symbols-typescript": "^2.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@react-navigation/native": "^7.2.4",
|
||||
"react": ">= 18.2.0",
|
||||
"react-native": "*",
|
||||
"react-native-safe-area-context": ">= 4.0.0",
|
||||
"react-native-screens": ">= 4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-navigation/core": {
|
||||
"version": "7.17.4",
|
||||
"resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-7.17.4.tgz",
|
||||
"integrity": "sha512-Rv9E2oNNQEkPGpmu9q+vJwGJRSQR6LBg5L+Yo1QHjtwGbHUbjkIKOdYymDZoZYgNzX2OD4rAIlfuzbDKa3cCeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-navigation/routers": "^7.5.5",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"nanoid": "^3.3.11",
|
||||
"query-string": "^7.1.3",
|
||||
"react-is": "^19.1.0",
|
||||
"use-latest-callback": "^0.2.4",
|
||||
"use-sync-external-store": "^1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 18.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-navigation/core/node_modules/escape-string-regexp": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-navigation/core/node_modules/react-is": {
|
||||
"version": "19.2.6",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz",
|
||||
"integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@react-navigation/elements": {
|
||||
"version": "2.9.17",
|
||||
"resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.17.tgz",
|
||||
"integrity": "sha512-Prax9RDS6l32npcl4PzvL88VoXe9HdtcIUP2+rim3DLVSZceD6oreA+cmPBUjeLFjsnxKlU3pTRby3RpYJ5/xw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color": "^4.2.3",
|
||||
"use-latest-callback": "^0.2.4",
|
||||
"use-sync-external-store": "^1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@react-native-masked-view/masked-view": ">= 0.2.0",
|
||||
"@react-navigation/native": "^7.2.4",
|
||||
"react": ">= 18.2.0",
|
||||
"react-native": "*",
|
||||
"react-native-safe-area-context": ">= 4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@react-native-masked-view/masked-view": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@react-navigation/native": {
|
||||
"version": "7.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.2.4.tgz",
|
||||
"integrity": "sha512-eWC2D3JjhYLId2fVTZhhCiUpWIaPhO9XyEb7Wq8ElmOHyIODlbOzgZ0rKia02OIsDKr9BzZl2sK1dL70yMxDaw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-navigation/core": "^7.17.4",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"nanoid": "^3.3.11",
|
||||
"use-latest-callback": "^0.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 18.2.0",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-navigation/native/node_modules/escape-string-regexp": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-navigation/routers": {
|
||||
"version": "7.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-7.5.5.tgz",
|
||||
"integrity": "sha512-9/hhMte12Kgu+pMnLfA4EWJ0OQmIEAMVMX06FPH2yGkEQSQ3JhhCN/GkcRikzQhtEi97VYYQA15umptBUShcOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-navigation/stack": {
|
||||
"version": "7.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-navigation/stack/-/stack-7.9.0.tgz",
|
||||
"integrity": "sha512-9ndJmQwPqIH9OJ0xXMUCx8V42IONkHcJWPmg3HJc40c9AJ6ZOPJjvSjXw8nT0en1YEN58z4MvKiNIgLXTQEKnA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-navigation/elements": "^2.9.17",
|
||||
"color": "^4.2.3",
|
||||
"use-latest-callback": "^0.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@react-navigation/native": "^7.2.4",
|
||||
"react": ">= 18.2.0",
|
||||
"react-native": "*",
|
||||
"react-native-gesture-handler": ">= 2.0.0",
|
||||
"react-native-safe-area-context": ">= 4.0.0",
|
||||
"react-native-screens": ">= 4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sinclair/typebox": {
|
||||
"version": "0.27.10",
|
||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz",
|
||||
@@ -3025,6 +3234,12 @@
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/emscripten": {
|
||||
"version": "1.41.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz",
|
||||
"integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/graceful-fs": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",
|
||||
@@ -3034,6 +3249,12 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/hammerjs": {
|
||||
"version": "2.0.46",
|
||||
"resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz",
|
||||
"integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/istanbul-lib-coverage": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
||||
@@ -3067,6 +3288,24 @@
|
||||
"undici-types": "~7.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-test-renderer": {
|
||||
"version": "19.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-19.1.0.tgz",
|
||||
"integrity": "sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/stack-utils": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
|
||||
@@ -3541,6 +3780,15 @@
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/barcode-detector": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/barcode-detector/-/barcode-detector-3.1.3.tgz",
|
||||
"integrity": "sha512-omL3/x26oU9jlR0gUQcGdXIjQtMlrUGKF7xRFO1RwrQkRkRU7WLz0mgQEsdUtYBm2uX3JH+HQLrKlyTS/BxZRw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"zxing-wasm": "3.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
@@ -3901,6 +4149,19 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/color": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
||||
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1",
|
||||
"color-string": "^1.9.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "1.9.3",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
|
||||
@@ -3916,6 +4177,34 @@
|
||||
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/color-string": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
|
||||
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "^1.0.0",
|
||||
"simple-swizzle": "^0.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/color/node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color/node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
|
||||
@@ -4048,6 +4337,12 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -4065,6 +4360,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decode-uri-component": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz",
|
||||
"integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-extend": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
|
||||
@@ -4320,6 +4624,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/expo-camera": {
|
||||
"version": "55.0.18",
|
||||
"resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-55.0.18.tgz",
|
||||
"integrity": "sha512-Us/7JV6O1lHpLBGKJnK2s8gzmPcmMVJSV5586DBeO7x7AXzmvvVGtH+0nJRVIBE3MNzGzGWyfgievjr8QlE7dA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"barcode-detector": "^3.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react": "*",
|
||||
"react-native": "*",
|
||||
"react-native-web": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-native-web": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/expo-modules-autolinking": {
|
||||
"version": "3.0.25",
|
||||
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.25.tgz",
|
||||
@@ -4886,6 +5210,12 @@
|
||||
"integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-json-stable-stringify": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||
@@ -4913,6 +5243,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/filter-obj": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz",
|
||||
"integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz",
|
||||
@@ -5113,6 +5452,21 @@
|
||||
"hermes-estree": "0.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hoist-non-react-statics": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
|
||||
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"react-is": "^16.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hoist-non-react-statics/node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/hosted-git-info": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz",
|
||||
@@ -5173,6 +5527,12 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/idb": {
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz",
|
||||
"integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
@@ -5258,6 +5618,12 @@
|
||||
"loose-envify": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-arrayish": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
|
||||
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-core-module": {
|
||||
"version": "2.16.2",
|
||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz",
|
||||
@@ -7217,6 +7583,24 @@
|
||||
"qrcode-terminal": "bin/qrcode-terminal.js"
|
||||
}
|
||||
},
|
||||
"node_modules/query-string": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz",
|
||||
"integrity": "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"decode-uri-component": "^0.2.2",
|
||||
"filter-obj": "^1.1.0",
|
||||
"split-on-first": "^1.0.0",
|
||||
"strict-uri-encode": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/queue": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
|
||||
@@ -7269,6 +7653,18 @@
|
||||
"ws": "^7"
|
||||
}
|
||||
},
|
||||
"node_modules/react-freeze": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-freeze/-/react-freeze-1.0.4.tgz",
|
||||
"integrity": "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
@@ -7332,6 +7728,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-gesture-handler": {
|
||||
"version": "2.31.2",
|
||||
"resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.31.2.tgz",
|
||||
"integrity": "sha512-rw5q74i2AfS7YGYdbxQDhOU7xqgY6WRM1132/CCm3erqjblhECZDZFHIm0tteHoC9ih24wogVBVVzcTBQtZ+5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@egjs/hammerjs": "^2.0.17",
|
||||
"@types/react-test-renderer": "^19.1.0",
|
||||
"hoist-non-react-statics": "^3.3.0",
|
||||
"invariant": "^2.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-is-edge-to-edge": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz",
|
||||
@@ -7342,6 +7754,70 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-reanimated": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.3.1.tgz",
|
||||
"integrity": "sha512-KhGsS0YkCA+gusgyzlf9hnqzVPIR398KTpqXyqq/+yYJJPAvyEEPKcxlB0xtOOXSMrR2A9uRKVARVQhZwrOh+Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-native-is-edge-to-edge": "^1.3.1",
|
||||
"semver": "^7.7.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "0.81 - 0.85",
|
||||
"react-native-worklets": "0.8.x"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-safe-area-context": {
|
||||
"version": "5.7.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.7.0.tgz",
|
||||
"integrity": "sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-screens": {
|
||||
"version": "4.25.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.25.0.tgz",
|
||||
"integrity": "sha512-CoE6W0perui0W4WK9fZFJfikUql/AYQFSJjnOGoXcPeteFb5Tursfmkot3vPOSu9lKWQMO6tlCIBQTC1CgbVRw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-freeze": "^1.0.0",
|
||||
"warn-once": "^0.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": ">=0.82.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-worklets": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.8.3.tgz",
|
||||
"integrity": "sha512-oCBJROyLU7yG/1R8s0INMflygTH71bx+5XcYkH0CM938TlhSoVbiunE1WVW5FZa51vwYqfLie/IXMX2s1Kh3eg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/plugin-transform-arrow-functions": "^7.27.1",
|
||||
"@babel/plugin-transform-class-properties": "^7.27.1",
|
||||
"@babel/plugin-transform-classes": "^7.28.4",
|
||||
"@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1",
|
||||
"@babel/plugin-transform-optional-chaining": "^7.27.1",
|
||||
"@babel/plugin-transform-shorthand-properties": "^7.27.1",
|
||||
"@babel/plugin-transform-template-literals": "^7.27.1",
|
||||
"@babel/plugin-transform-unicode-regex": "^7.27.1",
|
||||
"@babel/preset-typescript": "^7.27.1",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"semver": "^7.7.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "*",
|
||||
"@react-native/metro-config": "*",
|
||||
"react": "*",
|
||||
"react-native": "0.81 - 0.85"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native/node_modules/@react-native/virtualized-lists": {
|
||||
"version": "0.81.5",
|
||||
"resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.81.5.tgz",
|
||||
@@ -7818,6 +8294,15 @@
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sf-symbols-typescript": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/sf-symbols-typescript/-/sf-symbols-typescript-2.2.0.tgz",
|
||||
"integrity": "sha512-TPbeg0b7ylrswdGCji8FRGFAKuqbpQlLbL8SOle3j1iHSs5Ob5mhvMAxWN2UItOjgALAB5Zp3fmMfj8mbWvXKw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
@@ -7868,6 +8353,15 @@
|
||||
"plist": "^3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-swizzle": {
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
|
||||
"integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-arrayish": "^0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/sisteransi": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
|
||||
@@ -7929,6 +8423,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split-on-first": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz",
|
||||
"integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||
@@ -7992,6 +8495,15 @@
|
||||
"node": ">= 0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/strict-uri-encode": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz",
|
||||
"integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
@@ -8122,6 +8634,18 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/tagged-tag": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
|
||||
"integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "7.5.15",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz",
|
||||
@@ -8467,6 +8991,24 @@
|
||||
"browserslist": ">= 4.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-latest-callback": {
|
||||
"version": "0.2.6",
|
||||
"resolved": "https://registry.npmjs.org/use-latest-callback/-/use-latest-callback-0.2.6.tgz",
|
||||
"integrity": "sha512-FvRG9i1HSo0wagmX63Vrm8SnlUU3LMM3WyZkQ76RnslpBrX694AdG4A0zQBx2B3ZifFA0yv/BaEHGBnEax5rZg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
@@ -8519,6 +9061,12 @@
|
||||
"makeerror": "1.0.12"
|
||||
}
|
||||
},
|
||||
"node_modules/warn-once": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/warn-once/-/warn-once-0.1.1.tgz",
|
||||
"integrity": "sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wcwidth": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
|
||||
@@ -8780,6 +9328,34 @@
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/zxing-wasm": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-3.0.3.tgz",
|
||||
"integrity": "sha512-DdOn/G5F+qvZELWeO5ZFFwcN611TfMybxPV0LUUoutUmiH2t47MZSB7gLV9O9YLhvudBdnzQNAoFOu4Xz8eOrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/emscripten": "^1.41.5",
|
||||
"type-fest": "^5.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/emscripten": ">=1.39.6"
|
||||
}
|
||||
},
|
||||
"node_modules/zxing-wasm/node_modules/type-fest": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz",
|
||||
"integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==",
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"dependencies": {
|
||||
"tagged-tag": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
17
package.json
17
package.json
@@ -4,15 +4,26 @@
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "^3.0.2",
|
||||
"@react-native-community/datetimepicker": "^9.1.0",
|
||||
"@react-navigation/bottom-tabs": "^7.16.0",
|
||||
"@react-navigation/native": "^7.2.4",
|
||||
"@react-navigation/stack": "^7.9.0",
|
||||
"expo": "~54.0.33",
|
||||
"expo-camera": "^55.0.18",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5"
|
||||
"react-native": "0.81.5",
|
||||
"react-native-gesture-handler": "^2.31.2",
|
||||
"react-native-reanimated": "^4.3.1",
|
||||
"react-native-safe-area-context": "^5.7.0",
|
||||
"react-native-screens": "^4.25.0",
|
||||
"react-native-worklets": "^0.8.3"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
|
||||
217
src/api/client.js
Normal file
217
src/api/client.js
Normal 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
249
src/components/ui.jsx
Normal 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' },
|
||||
})
|
||||
95
src/context/AuthContext.jsx
Normal file
95
src/context/AuthContext.jsx
Normal 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
277
src/screens/AdminScreen.jsx
Normal 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 },
|
||||
})
|
||||
354
src/screens/BarcodeScreen.jsx
Normal file
354
src/screens/BarcodeScreen.jsx
Normal 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
150
src/screens/HomeScreen.jsx
Normal 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 },
|
||||
})
|
||||
237
src/screens/InventoryScreen.jsx
Normal file
237
src/screens/InventoryScreen.jsx
Normal 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 },
|
||||
})
|
||||
237
src/screens/MealPlannersScreen.jsx
Normal file
237
src/screens/MealPlannersScreen.jsx
Normal 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 },
|
||||
})
|
||||
110
src/screens/ProfileScreen.jsx
Normal file
110
src/screens/ProfileScreen.jsx
Normal 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' },
|
||||
})
|
||||
153
src/screens/SearchScreen.jsx
Normal file
153
src/screens/SearchScreen.jsx
Normal 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 },
|
||||
})
|
||||
226
src/screens/ShoppingListsScreen.jsx
Normal file
226
src/screens/ShoppingListsScreen.jsx
Normal 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
156
src/screens/UsersScreen.jsx
Normal 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
56
src/theme.js
Normal 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,
|
||||
}
|
||||
52
src/utils/inventoryItemUtils.js
Normal file
52
src/utils/inventoryItemUtils.js
Normal 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
62
src/utils/searchUtils.js
Normal 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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user