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:
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) =>