matiru

Blockchain-Based Supply Chain Transparency for Agricultural Produce
commit a8a76385a366286da13e8bb51983580be0a3092d
parent cfdfdeb8cbd4fe64ddbe1b7fdd113540a0136ff3
Author: maydayv7 <maydayv7@gmail.com>
Date:   Wed,  8 Oct 2025 00:17:12 +0530

feat: Offline Inventory

Also start work on notifications

Diffstat:
Mbackend/.gitignore | 5++++-
Mbackend/server.js | 14++++++++++++++
Mfrontend/App.js | 6++++--
Mfrontend/components/ScreenHeader.js | 77+++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Mfrontend/screens/Distributor.js | 4++++
Mfrontend/screens/Farmer.js | 5+++++
Mfrontend/screens/Inspector.js | 1+
Mfrontend/screens/Inventory.js | 70+++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Afrontend/screens/Notifications.js | 128+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mfrontend/screens/Retailer.js | 3+++
Mfrontend/screens/Search.js | 1+
Mfrontend/services/api.js | 1+
12 files changed, 263 insertions(+), 52 deletions(-)

diff --git a/backend/.gitignore b/backend/.gitignore @@ -1,4 +1,7 @@ users.json +notifications.json +uploads + +# Fabric connections wallet -uploads diff --git a/backend/server.js b/backend/server.js @@ -177,6 +177,20 @@ app.post( // Functions const router = express.Router(); +router.get( + "/notifications", + asyncHandler(async (req, res) => { + const notificationsPath = path.join(__dirname, "notifications.json"); + if (!fs.existsSync(notificationsPath)) { + return res.json({ notifications: [] }); + } + const notifications = JSON.parse( + fs.readFileSync(notificationsPath, "utf8") + ); + res.json({ notifications }); + }) +); + router.post( "/registerUser", asyncHandler(async (req, res) => { diff --git a/frontend/App.js b/frontend/App.js @@ -5,12 +5,13 @@ import { AuthProvider } from "./AuthContext"; import HomeScreen from "./screens/Home"; import LoginScreen from "./screens/Login"; -import InventoryScreen from "./screens/Inventory"; import FarmerScreen from "./screens/Farmer"; import DistributorScreen from "./screens/Distributor"; import RetailerScreen from "./screens/Retailer"; import InspectorScreen from "./screens/Inspector"; +import InventoryScreen from "./screens/Inventory"; import SearchScreen from "./screens/Search"; +import NotificationsScreen from "./screens/Notifications"; const Stack = createStackNavigator(); @@ -28,12 +29,13 @@ function App() { <Stack.Navigator screenOptions={{ headerShown: false }}> <Stack.Screen name="Home" component={HomeScreen} /> <Stack.Screen name="Login" component={LoginScreen} /> - <Stack.Screen name="Inventory" component={InventoryScreen} /> <Stack.Screen name="Farmer" component={FarmerScreen} /> <Stack.Screen name="Distributor" component={DistributorScreen} /> <Stack.Screen name="Retailer" component={RetailerScreen} /> <Stack.Screen name="Inspector" component={InspectorScreen} /> + <Stack.Screen name="Inventory" component={InventoryScreen} /> <Stack.Screen name="Search" component={SearchScreen} /> + <Stack.Screen name="Notifications" component={NotificationsScreen} /> </Stack.Navigator> </NavigationContainer> ); diff --git a/frontend/components/ScreenHeader.js b/frontend/components/ScreenHeader.js @@ -8,43 +8,68 @@ export default function ScreenHeader({ role, showBack, hideSearchButton, + hideNotificationsButton, }) { return ( <View style={{ flexDirection: "row", - justifyContent: "space-between", alignItems: "center", marginBottom: 16, }} > - {showBack ? ( - <TouchableOpacity onPress={() => navigation.goBack()}> - <MaterialCommunityIcons - name="arrow-left" - size={28} - color={colors.darkGreen} - /> - </TouchableOpacity> - ) : ( - <View style={{ width: 28 }} /> - )} + <View + style={{ + flex: 1, + flexDirection: "row", + justifyContent: "flex-start", + alignItems: "center", + }} + > + {showBack && ( + <TouchableOpacity onPress={() => navigation.goBack()}> + <MaterialCommunityIcons + name="arrow-left" + size={28} + color={colors.darkGreen} + /> + </TouchableOpacity> + )} + {!hideNotificationsButton && ( + <TouchableOpacity + onPress={() => navigation.navigate("Notifications")} + style={{ marginLeft: showBack ? 16 : 0 }} + > + <MaterialCommunityIcons + name="bell-outline" + size={28} + color={colors.darkGreen} + /> + </TouchableOpacity> + )} + </View> - <Text style={styles.title}>{title}</Text> + <Text style={[styles.title, { flex: 2, marginBottom: 0 }]}>{title}</Text> - {!hideSearchButton ? ( - <TouchableOpacity - onPress={() => navigation.navigate("Search", { fromRole: role })} - > - <MaterialCommunityIcons - name="magnify" - size={28} - color={colors.darkGreen} - /> - </TouchableOpacity> - ) : ( - <View style={{ width: 28 }} /> - )} + <View + style={{ + flex: 1, + flexDirection: "row", + justifyContent: "flex-end", + }} + > + {!hideSearchButton && ( + <TouchableOpacity + onPress={() => navigation.navigate("Search", { fromRole: role })} + > + <MaterialCommunityIcons + name="magnify" + size={28} + color={colors.darkGreen} + /> + </TouchableOpacity> + )} + </View> </View> ); } diff --git a/frontend/screens/Distributor.js b/frontend/screens/Distributor.js @@ -48,6 +48,7 @@ export default function DistributorScreen({ navigation, route }) { Alert.alert("Location Updated", "Location updated successfully"); resetCommon(); setLocation(""); + setActive(null); } catch (err) { Alert.alert("Error", err.message); } @@ -77,6 +78,7 @@ export default function DistributorScreen({ navigation, route }) { ); Alert.alert("Transferred", "Ownership transferred successfully"); onComplete(); + setActive(null); } catch (err) { Alert.alert("Error", err.message); } @@ -92,6 +94,7 @@ export default function DistributorScreen({ navigation, route }) { resetCommon(); setReason(""); setNewStatus(""); + setActive(null); } catch (err) { Alert.alert("Error", err.message); } @@ -114,6 +117,7 @@ export default function DistributorScreen({ navigation, route }) { Alert.alert("Updated", "Storage conditions updated successfully"); resetCommon(); setStorageConditions(""); + setActive(null); } catch (err) { Alert.alert("Error", err.message); } diff --git a/frontend/screens/Farmer.js b/frontend/screens/Farmer.js @@ -95,6 +95,7 @@ export default function FarmerScreen({ navigation, route }) { setLastProduceId(produce.id); setQrVisible(true); resetRegisterForm(); + setActive(null); } catch (err) { Alert.alert("Error", err.message); } @@ -124,6 +125,7 @@ export default function FarmerScreen({ navigation, route }) { setPricePerUnit(""); setStorageConditions(""); setCertification(""); + setActive(null); } catch (err) { Alert.alert("Error", err.message); } @@ -144,6 +146,7 @@ export default function FarmerScreen({ navigation, route }) { Alert.alert("Split", "Produce split successfully"); setSplitQty(""); resetCommon(); + setActive(null); } catch (err) { Alert.alert("Error", err.message); } @@ -164,6 +167,7 @@ export default function FarmerScreen({ navigation, route }) { Alert.alert("Moved", "Location updated successfully"); resetCommon(); setLocation(""); + setActive(null); } catch (err) { Alert.alert("Error", err.message); } @@ -193,6 +197,7 @@ export default function FarmerScreen({ navigation, route }) { ); Alert.alert("Transferred", "Ownership transferred successfully"); onComplete(); + setActive(null); } catch (err) { Alert.alert("Error", err.message); } diff --git a/frontend/screens/Inspector.js b/frontend/screens/Inspector.js @@ -63,6 +63,7 @@ export default function InspectorScreen({ navigation, route }) { : "Inspection data submitted successfully." ); resetCommon(); + setActive(null); } catch (err) { Alert.alert("Error", err.message); } diff --git a/frontend/screens/Inventory.js b/frontend/screens/Inventory.js @@ -8,6 +8,7 @@ import { View, } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; +import AsyncStorage from "@react-native-async-storage/async-storage"; import ScreenHeader from "../components/ScreenHeader"; import ProduceCard from "../components/ProduceCard"; @@ -23,36 +24,55 @@ export default function InventoryScreen({ navigation, route }) { const [loading, setLoading] = useState(false); const [produces, setProduces] = useState([]); + const CACHE_KEY = `@inventory_${routeUserId}`; + useEffect(() => { - fetchInventory(); + loadInventory(); }, []); - const fetchInventory = async () => { + const loadInventory = async () => { + setLoading(true); try { - setLoading(true); - setProduces([]); + const cachedData = await AsyncStorage.getItem(CACHE_KEY); + if (cachedData) { + const parsedData = JSON.parse(cachedData); + parsedData.sort( + (a, b) => new Date(b.harvestDate) - new Date(a.harvestDate) + ); + setProduces(parsedData); + } + } catch (e) { + // Failed to load cache, not a critical error + } + try { + let fetchedProduces; if (role.toUpperCase() === "INSPECTOR") { const userKey = `INSPECTOR-${routeUserId}`; const { user: userDetails } = await api.getUserDetails(userKey); - const inspected = userDetails.inspectedProduce || []; - const items = []; - - for (const pid of inspected) { - try { - const { produce } = await api.getProduceById(pid); - if (produce) items.push(produce); - } catch (e) { - // Ignore failure for single produce - } - } - setProduces(items); + const inspectedIds = userDetails.inspectedProduce || []; + const producePromises = inspectedIds.map((pid) => + api.getProduceById(pid).catch(() => null) + ); + const results = await Promise.all(producePromises); + fetchedProduces = results + .filter((res) => res && res.produce) + .map((res) => res.produce); } else { - const { produces } = await api.getProduceByOwner(routeUserId); - setProduces(produces || []); + const { produces: ownerProduces } = + await api.getProduceByOwner(routeUserId); + fetchedProduces = ownerProduces || []; } + + fetchedProduces.sort( + (a, b) => new Date(b.harvestDate) - new Date(a.harvestDate) + ); + + setProduces(fetchedProduces); + await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(fetchedProduces)); } catch (err) { - Alert.alert("Error", err.message); + if (!produces.length) + Alert.alert("Error", `Failed to fetch inventory: ${err.message}`); } finally { setLoading(false); } @@ -65,13 +85,14 @@ export default function InventoryScreen({ navigation, route }) { navigation={navigation} role={role} hideSearchButton={true} + hideNotificationsButton={true} showBack={true} /> <ScrollView contentContainerStyle={{ padding: 12 }}> - {loading ? ( + {loading && produces.length === 0 ? ( <View style={{ alignItems: "center", padding: 20 }}> <ActivityIndicator size="large" /> - <Text>Loading...</Text> + <Text>Loading Inventory...</Text> </View> ) : ( <> @@ -84,9 +105,12 @@ export default function InventoryScreen({ navigation, route }) { )} <TouchableOpacity style={styles.secondaryButton} - onPress={fetchInventory} + onPress={loadInventory} + disabled={loading} > - <Text style={styles.secondaryButtonText}>Refresh</Text> + <Text style={styles.secondaryButtonText}> + {loading ? "Refreshing..." : "Refresh"} + </Text> </TouchableOpacity> </> )} diff --git a/frontend/screens/Notifications.js b/frontend/screens/Notifications.js @@ -0,0 +1,128 @@ +import { useState, useEffect, useContext } from "react"; +import { + View, + Text, + FlatList, + ActivityIndicator, + Alert, + StyleSheet, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { MaterialCommunityIcons } from "@expo/vector-icons"; + +import ScreenHeader from "../components/ScreenHeader"; +import { api } from "../services/api"; +import { AuthContext } from "../AuthContext"; +import styles, { colors } from "../styles"; + +export default function NotificationsScreen({ navigation }) { + const { user } = useContext(AuthContext); + const [notifications, setNotifications] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchNotifications = async () => { + try { + const data = await api.getNotifications(user.token); + setNotifications(data.notifications || []); + } catch (err) { + Alert.alert("Error", "Failed to fetch notifications."); + } finally { + setLoading(false); + } + }; + + fetchNotifications(); + }, [user.token]); + + const renderItem = ({ item }) => ( + <View style={localStyles.card}> + <MaterialCommunityIcons + name={item.read ? "email-open-outline" : "email-outline"} + size={24} + color={colors.darkGreen} + style={localStyles.icon} + /> + <View style={localStyles.content}> + <Text style={localStyles.title}>{item.title}</Text> + <Text style={localStyles.message}>{item.message}</Text> + <Text style={localStyles.date}> + {new Date(item.date).toLocaleString()} + </Text> + </View> + </View> + ); + + return ( + <SafeAreaView style={styles.container}> + <ScreenHeader + title="Notifications" + navigation={navigation} + showBack={true} + hideSearchButton={true} + hideNotificationsButton={true} + /> + {loading ? ( + <ActivityIndicator + size="large" + color={colors.darkGreen} + style={{ marginTop: 50 }} + /> + ) : ( + <FlatList + data={notifications} + renderItem={renderItem} + keyExtractor={(item) => item.id} + ListEmptyComponent={ + <Text style={localStyles.emptyText}>No notifications</Text> + } + contentContainerStyle={{ paddingHorizontal: 16 }} + /> + )} + </SafeAreaView> + ); +} + +const localStyles = StyleSheet.create({ + card: { + backgroundColor: "white", + borderRadius: 12, + padding: 16, + marginBottom: 12, + flexDirection: "row", + alignItems: "flex-start", + shadowColor: "#000", + shadowOpacity: 0.08, + shadowOffset: { width: 0, height: 2 }, + shadowRadius: 5, + elevation: 3, + }, + icon: { + marginRight: 16, + marginTop: 2, + }, + content: { + flex: 1, + }, + title: { + fontSize: 16, + fontWeight: "bold", + color: colors.darkGreen, + }, + message: { + fontSize: 14, + color: colors.gray, + marginTop: 4, + }, + date: { + fontSize: 12, + color: "#999", + marginTop: 8, + }, + emptyText: { + textAlign: "center", + marginTop: 50, + fontSize: 16, + color: colors.gray, + }, +}); diff --git a/frontend/screens/Retailer.js b/frontend/screens/Retailer.js @@ -47,6 +47,7 @@ export default function RetailerScreen({ navigation, route }) { Alert.alert("Location Updated", "Location updated successfully"); resetCommon(); setLocation(""); + setActive(null); } catch (err) { Alert.alert("Error", err.message); } @@ -76,6 +77,7 @@ export default function RetailerScreen({ navigation, route }) { ); Alert.alert("Transferred", "Sale / Transfer successful"); onComplete(); + setActive(null); } catch (err) { Alert.alert("Error", err.message); } @@ -100,6 +102,7 @@ export default function RetailerScreen({ navigation, route }) { resetCommon(); setPricePerUnit(""); setStorageConditions(""); + setActive(null); } catch (err) { Alert.alert("Error", err.message); } diff --git a/frontend/screens/Search.js b/frontend/screens/Search.js @@ -154,6 +154,7 @@ export default function SearchScreen({ navigation }) { title="Global Search" navigation={navigation} hideSearchButton={true} + hideNotificationsButton={true} showBack={true} /> <View style={local.searchContainer}> diff --git a/frontend/services/api.js b/frontend/services/api.js @@ -36,6 +36,7 @@ const uploadImage = (formData, token) => { export const api = { login: (credentials) => request("/auth/login", { body: credentials }), uploadImage, + getNotifications: (token) => request("/notifications", { token }), registerProduce: (data, token) => request("/registerProduce", { body: data, token }), updateLocation: (data, token) =>