commit 436335025e608e1bdc15cbaea9ff4f794fa25e35
parent a8a76385a366286da13e8bb51983580be0a3092d
Author: maydayv7 <maydayv7@gmail.com>
Date: Wed, 8 Oct 2025 01:44:02 +0530
feat: Notifications + Refresh Callback
Also update styling, test inspectProduce
Diffstat:
9 files changed, 441 insertions(+), 384 deletions(-)
diff --git a/backend/server.js b/backend/server.js
@@ -14,6 +14,31 @@ const app = express();
app.use(cors());
app.use(morgan("dev"));
+// Notifications
+const NOTIFICATIONS_PATH = path.join(__dirname, "notifications.json");
+if (!fs.existsSync(NOTIFICATIONS_PATH))
+ fs.writeFileSync(NOTIFICATIONS_PATH, JSON.stringify([], null, 2));
+
+function readNotifications() {
+ return JSON.parse(fs.readFileSync(NOTIFICATIONS_PATH, "utf8"));
+}
+
+function writeNotifications(notifications) {
+ fs.writeFileSync(NOTIFICATIONS_PATH, JSON.stringify(notifications, null, 2));
+}
+
+async function addNotification(notification) {
+ const notifications = readNotifications();
+ const newNotification = {
+ id: `notif-${Date.now()}-${crypto.randomUUID()}`,
+ date: new Date().toISOString(),
+ read: false,
+ ...notification,
+ };
+ notifications.unshift(newNotification); // Add to top
+ writeNotifications(notifications);
+}
+
// Image Storage
app.use("/uploads", express.static("uploads"));
const UPLOAD_DIR = path.join(__dirname, "uploads");
@@ -61,7 +86,7 @@ async function initializeGateways() {
for (const org of Object.keys(orgConfig)) {
const config = orgConfig[org];
if (!config || !config.ccpPath || !config.identity) {
- console.warn(`Skipping gateway for ${org}: configuration missing.`);
+ console.warn(`Skipping gateway for ${org}: configuration missing`);
continue;
}
@@ -77,7 +102,7 @@ async function initializeGateways() {
discovery: { enabled: true, asLocalhost: AS_LOCALHOST === "true" },
});
gateways[org] = gateway;
- console.log(`Gateway for ${org} initialized successfully.`);
+ console.log(`Gateway for ${org} initialized successfully`);
} catch (error) {
console.error(`Failed to initialize gateway for ${org}:`, error);
}
@@ -87,7 +112,7 @@ async function initializeGateways() {
async function getContract(org) {
const gateway = gateways[org];
if (!gateway || !gateway.getNetwork)
- throw new Error(`Gateway for ${org} is not available or not connected.`);
+ throw new Error(`Gateway for ${org} is not available or not connected`);
const network = await gateway.getNetwork(CHANNEL);
return network.getContract(CHAINCODE);
@@ -107,19 +132,18 @@ function authenticateMiddleware(req, res, next) {
}
const header = req.headers["authorization"];
- if (!header)
- return res.status(401).json({ error: "missing authorization header" });
+ if (!header) return res.status(401).json({ error: "Missing auth header" });
const token = header.split(" ")[1];
- if (!token) return res.status(401).json({ error: "missing token" });
+ if (!token) return res.status(401).json({ error: "Missing token" });
jwt.verify(token, JWT_SECRET, (err, payload) => {
- if (err) return res.status(403).json({ error: "invalid token" });
+ if (err) return res.status(403).json({ error: "Invalid token" });
req.user = payload;
if (!req.user.org) {
return res
.status(403)
- .json({ error: "invalid token: missing org identifier" });
+ .json({ error: "Invalid token: missing Org identifier" });
}
next();
});
@@ -135,7 +159,7 @@ app.post(
asyncHandler(async (req, res) => {
const { username, password } = req.body || {};
if (!username || !password)
- return res.status(400).json({ error: "username and password required" });
+ return res.status(400).json({ error: "Username and Password required" });
const usersPath = path.join(__dirname, "users.json");
if (!fs.existsSync(usersPath))
@@ -145,10 +169,10 @@ app.post(
const users = JSON.parse(fs.readFileSync(usersPath, "utf8"));
const user = users.find((u) => u.username === username);
- if (!user) return res.status(401).json({ error: "invalid credentials" });
+ if (!user) return res.status(401).json({ error: "Invalid credentials" });
const ok = await bcrypt.compare(password, user.passwordHash);
- if (!ok) return res.status(401).json({ error: "invalid credentials" });
+ if (!ok) return res.status(401).json({ error: "Invalid credentials" });
const token = jwt.sign(
{ id: user.id, role: user.role, username: user.username, org: user.org },
@@ -165,7 +189,7 @@ app.post(
upload.single("image"),
(req, res) => {
if (!req.file)
- return res.status(400).json({ error: "no image file uploaded" });
+ return res.status(400).json({ error: "No image file uploaded" });
const fileUrl = `${req.protocol}://${req.get("host")}/uploads/${
req.file.filename
@@ -180,14 +204,15 @@ 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 });
+ const allNotifications = readNotifications();
+ const { id: userId, org: userOrg } = req.user;
+ const userNotifications = allNotifications.filter((n) => {
+ if (n.channel === "all") return true;
+ if (n.channel === "org" && n.targetId === userOrg) return true;
+ if (n.channel === "user" && n.targetId === userId) return true;
+ return false;
+ });
+ res.json({ notifications: userNotifications });
})
);
@@ -215,7 +240,14 @@ router.post(
farmerId,
JSON.stringify(details)
);
- return res.json({ success: true, produce: JSON.parse(result.toString()) });
+ const produce = JSON.parse(result.toString());
+ await addNotification({
+ title: "New Produce Registered",
+ message: `Registered a new batch of ${details.cropType} (ID: ${produce.id}).`,
+ channel: "user",
+ targetId: farmerId,
+ });
+ return res.json({ success: true, produce });
})
);
@@ -230,7 +262,14 @@ router.post(
actorId,
newLocation
);
- return res.json({ success: true, produce: JSON.parse(result.toString()) });
+ const produce = JSON.parse(result.toString());
+ await addNotification({
+ title: "Location Updated",
+ message: `Location for ${produce.cropType} (ID: ${produceId}) has been updated to ${newLocation}.`,
+ channel: "user",
+ targetId: actorId,
+ });
+ return res.json({ success: true, produce });
})
);
@@ -245,7 +284,20 @@ router.post(
inspectorId,
JSON.stringify(qualityUpdate)
);
- return res.json({ success: true, produce: JSON.parse(result.toString()) });
+ const produce = JSON.parse(result.toString());
+
+ await addNotification({
+ title: "Produce Inspected",
+ message: `Your produce batch ${produce.cropType} (ID: ${produceId}) was inspected. Status: ${
+ qualityUpdate.failed
+ ? `Failed. Reason: ${qualityUpdate.reason}.`
+ : "Passed"
+ }.`,
+ channel: "user",
+ targetId: produce.currentOwner,
+ });
+
+ return res.json({ success: true, produce });
})
);
@@ -254,6 +306,13 @@ router.post(
asyncHandler(async (req, res) => {
const { produceId, newOwnerId, qty, salePrice } = req.body;
const contract = await getContract(req.user.org);
+ const produceData = await contract.evaluateTransaction(
+ "getProduceById",
+ produceId
+ );
+ const produce = JSON.parse(produceData.toString());
+ const prevOwnerId = produce.currentOwner;
+
const result = await contract.submitTransaction(
"transferOwnership",
produceId,
@@ -261,6 +320,25 @@ router.post(
"" + qty,
"" + salePrice
);
+
+ await addNotification({
+ title: "Produce Received",
+ message: `You have received ${qty} ${
+ produce.qtyUnit || ""
+ } of ${produce.cropType} from ${prevOwnerId}.`,
+ channel: "user",
+ targetId: newOwnerId,
+ });
+
+ await addNotification({
+ title: "Produce Transferred",
+ message: `You transferred ${qty} ${
+ produce.qtyUnit || ""
+ } of ${produce.cropType} to ${newOwnerId}.`,
+ channel: "user",
+ targetId: prevOwnerId,
+ });
+
return res.json({ success: true, result: JSON.parse(result.toString()) });
})
);
@@ -276,7 +354,14 @@ router.post(
actorId,
JSON.stringify(details)
);
- return res.json({ success: true, produce: JSON.parse(result.toString()) });
+ const produce = JSON.parse(result.toString());
+ await addNotification({
+ title: "Details Updated",
+ message: `Details for your produce batch ${produce.cropType} (ID: ${produceId}) have been updated.`,
+ channel: "user",
+ targetId: actorId,
+ });
+ return res.json({ success: true, produce });
})
);
@@ -292,7 +377,14 @@ router.post(
reason || "",
newStatus || "Removed"
);
- return res.json({ success: true, produce: JSON.parse(result.toString()) });
+ const produce = JSON.parse(result.toString());
+ await addNotification({
+ title: "Produce Status Changed",
+ message: `Your produce batch ${produce.cropType} (ID: ${produceId}) was marked as ${newStatus}.`,
+ channel: "user",
+ targetId: actorId,
+ });
+ return res.json({ success: true, produce });
})
);
@@ -307,28 +399,29 @@ router.post(
"" + qty,
ownerId
);
- return res.json({ success: true, split: JSON.parse(result.toString()) });
+ const splitResult = JSON.parse(result.toString());
+ await addNotification({
+ title: "Produce Split",
+ message: `Your produce batch (ID: ${produceId}) was split. A new batch of ${qty} was created.`,
+ channel: "user",
+ targetId: ownerId,
+ });
+ return res.json({ success: true, split: splitResult });
})
);
router.post(
"/recordPayment",
asyncHandler(async (req, res) => {
- const {
- produceId,
- transactionId,
- paymentStatus,
- paymentMethod,
- paymentRef,
- } = req.body;
+ const { produceId, transactionId, paymentStatus } = req.body;
const contract = await getContract(req.user.org);
const result = await contract.submitTransaction(
"recordPayment",
produceId,
transactionId,
paymentStatus,
- paymentMethod,
- paymentRef || ""
+ req.body.paymentMethod,
+ req.body.paymentRef || ""
);
return res.json({ success: true, produce: JSON.parse(result.toString()) });
})
diff --git a/frontend/components/ProduceCard.js b/frontend/components/ProduceCard.js
@@ -1,21 +1,21 @@
-import { View, Text, Image } from "react-native";
+import { View, Text, Image, useWindowDimensions } from "react-native";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { colors } from "../styles";
import DetailRow from "./DetailRow";
export default function ProduceCard({ produce }) {
+ const { width } = useWindowDimensions();
+ const isSmallScreen = width < 380;
const status = (produce.status || "").toLowerCase();
const statusStyle = getStatusStyle(status);
return (
<View style={local.card}>
<View style={local.header}>
- <View style={local.headerLeft}>
- <Text style={local.refNoText}> Ref. No. </Text>
- </View>
- <View style={local.headerRight}>
- <Text style={local.refNoId}>{produce.id}</Text>
- </View>
+ <Text style={local.refNoText}>Ref. ID </Text>
+ <Text style={local.refNoId} numberOfLines={2} ellipsizeMode="middle">
+ {produce.id}
+ </Text>
</View>
<View style={local.splitRow}>
@@ -24,20 +24,37 @@ export default function ProduceCard({ produce }) {
<Text style={local.title}>{produce.cropType || "N/A"}</Text>
</View>
<View style={{ flex: 1, marginLeft: 8 }}>
- <Text style={local.detailLabel}>Quality</Text>
- <Text style={local.title}>{produce.quality || "N/A"}</Text>
+ <Text style={local.detailLabel}>Owner</Text>
+ <Text style={local.title}>{produce.currentOwner}</Text>
</View>
</View>
- <View style={local.mainContentRow}>
+ <View
+ style={[
+ local.mainContentRow,
+ isSmallScreen && {
+ flexDirection: "column",
+ alignItems: "flex-start",
+ },
+ ]}
+ >
{produce.imageUrl ? (
<Image
source={{ uri: produce.imageUrl }}
- style={local.produceImage}
+ style={[
+ local.produceImage,
+ isSmallScreen && { width: "100%", height: 150, marginBottom: 12 },
+ ]}
resizeMode="cover"
/>
) : (
- <View style={[local.produceImage, local.imagePlaceholder]}>
+ <View
+ style={[
+ local.produceImage,
+ local.imagePlaceholder,
+ isSmallScreen && { width: "100%", height: 150, marginBottom: 12 },
+ ]}
+ >
<MaterialCommunityIcons
name="image-off-outline"
size={40}
@@ -46,9 +63,11 @@ export default function ProduceCard({ produce }) {
</View>
)}
- <View style={local.detailsContainer}>
+ <View
+ style={[local.detailsContainer, isSmallScreen && { marginLeft: 0 }]}
+ >
<DetailRow
- icon="weight-kilogram"
+ icon="weight"
label="Quantity"
value={`${produce.qty} ${produce.qtyUnit}`}
/>
@@ -58,9 +77,14 @@ export default function ProduceCard({ produce }) {
value={`₹${produce.pricePerUnit} / ${produce.qtyUnit}`}
/>
<DetailRow
- icon="account-outline"
- label="Current Owner"
- value={produce.currentOwner}
+ icon="shield-star"
+ label="Quality"
+ value={produce.quality || "N/A"}
+ />
+ <DetailRow
+ icon="thermometer"
+ label="Storage Conditions"
+ value={produce.storageConditions?.join(", ") || "N/A"}
/>
</View>
</View>
@@ -68,7 +92,7 @@ export default function ProduceCard({ produce }) {
<View style={local.splitRow}>
<View style={{ flex: 1, marginRight: 8 }}>
<DetailRow
- icon="calendar-arrow-left"
+ icon="calendar-check"
label="Harvest Date"
value={
produce.harvestDate
@@ -79,7 +103,7 @@ export default function ProduceCard({ produce }) {
</View>
<View style={{ flex: 1, marginLeft: 8 }}>
<DetailRow
- icon="calendar-arrow-right"
+ icon="calendar-alert"
label="Expiry Date"
value={
produce.expiryDate
@@ -100,11 +124,13 @@ export default function ProduceCard({ produce }) {
size={16}
color={colors.darkGreen}
/>
- <Text style={local.certText}>{cert}</Text>
+ <Text style={local.certText}>{cert} </Text>
</View>
))
) : (
- <Text style={{ color: colors.gray }}> No certification </Text>
+ <Text style={{ color: colors.gray, fontSize: 13, flexShrink: 1 }}>
+ No certification
+ </Text>
)}
</View>
@@ -170,25 +196,24 @@ const local = {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-start",
+ marginBottom: 8,
},
- headerLeft: { flexShrink: 0 },
- headerRight: { flex: 1, marginLeft: 8 },
- refNoText: { color: colors.gray, fontSize: 12 },
+ refNoText: { color: colors.gray, fontSize: 12, marginRight: 8 },
refNoId: {
fontSize: 12,
fontWeight: "700",
textAlign: "right",
- flexWrap: "wrap",
+ flexShrink: 1,
},
splitRow: { flexDirection: "row", marginTop: 8 },
detailLabel: { fontSize: 12, color: colors.gray },
title: { fontSize: 16, fontWeight: "700" },
- mainContentRow: { flexDirection: "row", marginTop: 12 },
+ mainContentRow: { flexDirection: "row", marginTop: 12, alignItems: "center" },
produceImage: {
- width: 120,
- height: 120,
+ width: 150,
+ height: 150,
borderRadius: 8,
- marginRight: 16,
+ marginRight: 30,
},
imagePlaceholder: {
backgroundColor: "#f4f7f4",
@@ -197,16 +222,20 @@ const local = {
},
detailsContainer: { flex: 1 },
badgesRow: {
- position: "relative",
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "flex-start",
marginTop: 12,
flexWrap: "wrap",
- paddingRight: 100,
+ gap: 8,
},
badgesLeft: {
flexDirection: "row",
flexWrap: "wrap",
alignItems: "center",
gap: 8,
+ flex: 1,
+ minWidth: "60%",
},
certPill: {
flexDirection: "row",
@@ -215,25 +244,16 @@ const local = {
borderRadius: 16,
paddingVertical: 4,
paddingHorizontal: 10,
- marginRight: 8,
- marginBottom: 6,
- maxWidth: "100%",
},
certText: {
marginLeft: 6,
color: colors.gray,
- flexShrink: 1,
- flexWrap: "wrap",
fontSize: 13,
},
statusContainer: {
- position: "absolute",
- top: 0,
- right: 0,
paddingVertical: 4,
paddingHorizontal: 10,
borderRadius: 6,
- flexShrink: 0,
alignSelf: "flex-start",
},
statusText: {
diff --git a/frontend/screens/Distributor.js b/frontend/screens/Distributor.js
@@ -182,7 +182,7 @@ export default function DistributorScreen({ navigation, route }) {
/>
<TextInput
style={styles.input}
- placeholder="New Status (e.g., Removed, Missing)"
+ placeholder="New Status (e.g. Removed, Missing)"
value={newStatus}
onChangeText={setNewStatus}
/>
diff --git a/frontend/screens/Home.js b/frontend/screens/Home.js
@@ -73,7 +73,7 @@ export default function HomeScreen({ navigation }) {
}
>
<MaterialCommunityIcons
- name="shield-check"
+ name="shield-search"
size={24}
color="white"
/>
diff --git a/frontend/screens/Inspector.js b/frontend/screens/Inspector.js
@@ -11,7 +11,6 @@ import {
import { SafeAreaView } from "react-native-safe-area-context";
import ScreenHeader from "../components/ScreenHeader";
-import ActionButton from "../components/ActionButton";
import Scanner from "../components/Scanner";
import DatePicker from "../components/DatePicker";
@@ -24,7 +23,6 @@ export default function InspectorScreen({ navigation, route }) {
const userId = route.params?.userId || user?.id;
const token = user?.token;
- const [active, setActive] = useState(null);
const [produceId, setProduceId] = useState("");
const [quality, setQuality] = useState("");
const [expiryDate, setExpiryDate] = useState("");
@@ -59,11 +57,10 @@ export default function InspectorScreen({ navigation, route }) {
Alert.alert(
markFailed ? "Marked as Failed" : "Inspection Recorded",
markFailed
- ? "Produce marked as failed."
- : "Inspection data submitted successfully."
+ ? "Produce marked as failed"
+ : "Inspection data submitted successfully"
);
resetCommon();
- setActive(null);
} catch (err) {
Alert.alert("Error", err.message);
}
@@ -77,67 +74,57 @@ export default function InspectorScreen({ navigation, route }) {
role="Inspector"
/>
<ScrollView>
- <View style={styles.actionGrid}>
- <ActionButton
- icon="shield-check"
- text="Inspect Produce"
- onPress={() => setActive("inspect")}
+ <View>
+ <Scanner value={produceId} onChange={setProduceId} />
+ <TextInput
+ style={styles.input}
+ placeholder="Quality (e.g. A+, Good)"
+ value={quality}
+ onChangeText={setQuality}
/>
- </View>
+ <DatePicker
+ label="Expiry Date"
+ value={expiryDate}
+ onChange={setExpiryDate}
+ />
+ <View
+ style={{
+ flexDirection: "row",
+ alignItems: "center",
+ marginTop: 10,
+ }}
+ >
+ <Text style={{ flex: 1, fontWeight: "600", color: colors.gray }}>
+ Mark as Failed
+ </Text>
+ <Switch
+ value={markFailed}
+ onValueChange={setMarkFailed}
+ thumbColor={markFailed ? colors.danger : colors.gray}
+ />
+ </View>
- {active === "inspect" && (
- <View>
- <Scanner value={produceId} onChange={setProduceId} />
+ {markFailed && (
<TextInput
style={styles.input}
- placeholder="Quality (e.g. A+, Good)"
- value={quality}
- onChangeText={setQuality}
+ placeholder="Failure Reason"
+ value={reason}
+ onChangeText={setReason}
/>
- <DatePicker
- label="Expiry Date"
- value={expiryDate}
- onChange={setExpiryDate}
- />
- <View
- style={{
- flexDirection: "row",
- alignItems: "center",
- marginTop: 10,
- }}
- >
- <Text style={{ flex: 1, fontWeight: "600", color: colors.gray }}>
- Mark as Failed
- </Text>
- <Switch
- value={markFailed}
- onValueChange={setMarkFailed}
- thumbColor={markFailed ? colors.danger : colors.gray}
- />
- </View>
-
- {markFailed && (
- <TextInput
- style={styles.input}
- placeholder="Failure Reason"
- value={reason}
- onChangeText={setReason}
- />
- )}
+ )}
- <TouchableOpacity
- style={[
- styles.primaryButton,
- markFailed && { backgroundColor: colors.danger },
- ]}
- onPress={inspectProduce}
- >
- <Text style={styles.buttonText}>
- {markFailed ? "Mark as Failed" : "Submit Inspection"}
- </Text>
- </TouchableOpacity>
- </View>
- )}
+ <TouchableOpacity
+ style={[
+ styles.primaryButton,
+ markFailed && { backgroundColor: colors.danger },
+ ]}
+ onPress={inspectProduce}
+ >
+ <Text style={styles.buttonText}>
+ {markFailed ? "Mark as Failed" : "Submit Inspection"}
+ </Text>
+ </TouchableOpacity>
+ </View>
</ScrollView>
<View style={{ padding: 12 }}>
@@ -147,6 +134,7 @@ export default function InspectorScreen({ navigation, route }) {
navigation.navigate("Inventory", {
userId: userId,
role: "Inspector",
+ title: "Inspected Produce",
})
}
>
diff --git a/frontend/screens/Inventory.js b/frontend/screens/Inventory.js
@@ -1,10 +1,10 @@
-import { useContext, useEffect, useState } from "react";
+import { useContext, useEffect, useState, useCallback } from "react";
import {
ActivityIndicator,
Alert,
+ RefreshControl,
ScrollView,
Text,
- TouchableOpacity,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
@@ -17,78 +17,101 @@ import { AuthContext } from "../AuthContext";
import { api } from "../services/api";
import styles, { colors } from "../styles";
+const sortByLastAction = (a, b) => {
+ const getLatestTimestamp = (produce) => {
+ if (produce.actionHistory && produce.actionHistory.length > 0)
+ return produce.actionHistory[produce.actionHistory.length - 1].timestamp;
+ return produce.harvestDate;
+ };
+
+ const timeA = getLatestTimestamp(a);
+ const timeB = getLatestTimestamp(b);
+
+ return new Date(timeB) - new Date(timeA);
+};
+
export default function InventoryScreen({ navigation, route }) {
const { user } = useContext(AuthContext);
const routeUserId = route.params?.userId || user?.id;
const role = route.params?.role || user?.role;
const [loading, setLoading] = useState(false);
const [produces, setProduces] = useState([]);
+ const [refreshing, setRefreshing] = useState(false);
const CACHE_KEY = `@inventory_${routeUserId}`;
- useEffect(() => {
- loadInventory();
- }, []);
+ const loadInventory = useCallback(
+ async (isRefreshing = false) => {
+ if (!isRefreshing) setLoading(true);
- const loadInventory = async () => {
- setLoading(true);
- try {
- 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);
+ try {
+ const cachedData = await AsyncStorage.getItem(CACHE_KEY);
+ if (cachedData && !isRefreshing) {
+ const parsedData = JSON.parse(cachedData);
+ parsedData.sort(sortByLastAction);
+ setProduces(parsedData);
+ }
+ } catch (e) {
+ console.error("Failed to load cache:", e);
}
- } 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 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: ownerProduces } =
- await api.getProduceByOwner(routeUserId);
- fetchedProduces = ownerProduces || [];
+ try {
+ let fetchedProduces;
+ if (role.toUpperCase() === "INSPECTOR") {
+ const userKey = `INSPECTOR-${routeUserId}`;
+ const { user: userDetails } = await api.getUserDetails(userKey);
+ 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: ownerProduces } =
+ await api.getProduceByOwner(routeUserId);
+ fetchedProduces = ownerProduces || [];
+ }
+
+ fetchedProduces.sort(sortByLastAction);
+ setProduces(fetchedProduces);
+ await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(fetchedProduces));
+ } catch (err) {
+ if (!produces.length) Alert.alert("Error", "Failed to fetch inventory");
+ } finally {
+ if (!isRefreshing) setLoading(false);
}
+ },
+ [role, routeUserId, produces.length]
+ );
- fetchedProduces.sort(
- (a, b) => new Date(b.harvestDate) - new Date(a.harvestDate)
- );
+ useEffect(() => {
+ loadInventory();
+ }, [loadInventory]);
- setProduces(fetchedProduces);
- await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(fetchedProduces));
- } catch (err) {
- if (!produces.length)
- Alert.alert("Error", `Failed to fetch inventory: ${err.message}`);
- } finally {
- setLoading(false);
- }
- };
+ const onRefresh = useCallback(async () => {
+ setRefreshing(true);
+ await loadInventory(true);
+ setRefreshing(false);
+ }, [loadInventory]);
return (
<SafeAreaView style={styles.container}>
<ScreenHeader
- title="My Inventory"
+ title={route.params?.title || "My Inventory"}
navigation={navigation}
role={role}
hideSearchButton={true}
hideNotificationsButton={true}
showBack={true}
/>
- <ScrollView contentContainerStyle={{ padding: 12 }}>
+ <ScrollView
+ contentContainerStyle={{ padding: 12 }}
+ refreshControl={
+ <RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
+ }
+ >
{loading && produces.length === 0 ? (
<View style={{ alignItems: "center", padding: 20 }}>
<ActivityIndicator size="large" />
@@ -103,15 +126,6 @@ export default function InventoryScreen({ navigation, route }) {
) : (
produces.map((p) => <ProduceCard key={p.id} produce={p} />)
)}
- <TouchableOpacity
- style={styles.secondaryButton}
- onPress={loadInventory}
- disabled={loading}
- >
- <Text style={styles.secondaryButtonText}>
- {loading ? "Refreshing..." : "Refresh"}
- </Text>
- </TouchableOpacity>
</>
)}
</ScrollView>
diff --git a/frontend/screens/Notifications.js b/frontend/screens/Notifications.js
@@ -1,11 +1,12 @@
-import { useState, useEffect, useContext } from "react";
+import { useState, useEffect, useContext, useCallback } from "react";
import {
- View,
- Text,
- FlatList,
ActivityIndicator,
Alert,
+ FlatList,
+ RefreshControl,
StyleSheet,
+ Text,
+ View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { MaterialCommunityIcons } from "@expo/vector-icons";
@@ -19,26 +20,47 @@ export default function NotificationsScreen({ navigation }) {
const { user } = useContext(AuthContext);
const [notifications, setNotifications] = useState([]);
const [loading, setLoading] = useState(true);
+ const [refreshing, setRefreshing] = useState(false);
+
+ const fetchNotifications = useCallback(async () => {
+ if (!user?.token) return;
+ try {
+ const data = await api.getNotifications(user.token);
+ const sorted = (data.notifications || []).sort(
+ (a, b) => new Date(b.date) - new Date(a.date)
+ );
+ setNotifications(sorted);
+ } catch (err) {
+ Alert.alert("Error", "Failed to fetch notifications.");
+ }
+ }, [user?.token]);
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);
- }
- };
+ setLoading(true);
+ fetchNotifications().finally(() => setLoading(false));
+ }, [fetchNotifications]);
- fetchNotifications();
- }, [user.token]);
+ const onRefresh = useCallback(async () => {
+ setRefreshing(true);
+ await fetchNotifications();
+ setRefreshing(false);
+ }, [fetchNotifications]);
+
+ const getIconForTitle = (title) => {
+ if (title.toLowerCase().includes("registered")) return "sprout";
+ if (title.toLowerCase().includes("location")) return "map-marker-check";
+ if (title.toLowerCase().includes("inspected")) return "shield-search";
+ if (title.toLowerCase().includes("received")) return "package-down";
+ if (title.toLowerCase().includes("transferred")) return "package-up";
+ if (title.toLowerCase().includes("updated")) return "pencil";
+ if (title.toLowerCase().includes("split")) return "call-split";
+ return "bell";
+ };
const renderItem = ({ item }) => (
<View style={localStyles.card}>
<MaterialCommunityIcons
- name={item.read ? "email-open-outline" : "email-outline"}
+ name={getIconForTitle(item.title)}
size={24}
color={colors.darkGreen}
style={localStyles.icon}
@@ -74,9 +96,12 @@ export default function NotificationsScreen({ navigation }) {
renderItem={renderItem}
keyExtractor={(item) => item.id}
ListEmptyComponent={
- <Text style={localStyles.emptyText}>No notifications</Text>
+ <Text style={localStyles.emptyText}>No notifications found</Text>
}
contentContainerStyle={{ paddingHorizontal: 16 }}
+ refreshControl={
+ <RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
+ }
/>
)}
</SafeAreaView>
@@ -113,11 +138,13 @@ const localStyles = StyleSheet.create({
fontSize: 14,
color: colors.gray,
marginTop: 4,
+ lineHeight: 20,
},
date: {
fontSize: 12,
color: "#999",
marginTop: 8,
+ textAlign: "right",
},
emptyText: {
textAlign: "center",
diff --git a/frontend/screens/Retailer.js b/frontend/screens/Retailer.js
@@ -75,7 +75,7 @@ export default function RetailerScreen({ navigation, route }) {
},
token
);
- Alert.alert("Transferred", "Sale / Transfer successful");
+ Alert.alert("Transferred", "Transfer successful");
onComplete();
setActive(null);
} catch (err) {
diff --git a/frontend/screens/Search.js b/frontend/screens/Search.js
@@ -61,46 +61,51 @@ export default function SearchScreen({ navigation }) {
const renderTimeline = (produce) => (
<View>
<Text style={local.subtitle}>Journey Timeline</Text>
- {produce.actionHistory?.map((a, idx) => (
- <View key={idx} style={local.timelineItem}>
- <MaterialCommunityIcons
- name={
- a.action === "REGISTER"
- ? "sprout"
- : a.action === "MOVE"
- ? "truck-fast"
+ {produce.actionHistory
+ ?.slice()
+ .sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
+ .map((a, idx) => (
+ <View key={idx} style={local.timelineItem}>
+ <MaterialCommunityIcons
+ name={
+ a.action === "REGISTER"
+ ? "sprout"
+ : a.action === "MOVE"
+ ? "truck-fast"
+ : a.action === "SALE"
+ ? "swap-horizontal-bold"
+ : a.action === "INSPECT"
+ ? "check-decagram"
+ : a.action === "SPLIT"
+ ? "call-split"
+ : a.action === "UPDATED"
+ ? "pencil"
+ : "close-circle"
+ }
+ size={24}
+ color={
+ a.action === "REMOVED"
+ ? colors.danger
: a.action === "SALE"
- ? "swap-horizontal-bold"
- : a.action === "INSPECT"
- ? "check-decagram"
- : "close-circle"
- }
- size={24}
- color={
- a.action === "REMOVED"
- ? colors.danger
- : a.action === "SALE"
- ? colors.midGreen
- : colors.darkGreen
- }
- />
- <View style={{ marginLeft: 12, flex: 1 }}>
- <Text style={{ fontWeight: "600", textTransform: "capitalize" }}>
- {a.action.toLowerCase()}
- </Text>
- <Text style={local.small}>
- Location: {a.currentLocation || "N/A"}
- </Text>
- <Text style={local.small}>
- {a.action === "INSPECT" ? "Inspected by" : "Actor"}:{" "}
- {a.currentOwner}
- </Text>
- <Text style={local.small}>
- {new Date(a.timestamp).toLocaleString()}
- </Text>
+ ? colors.midGreen
+ : colors.darkGreen
+ }
+ style={{ marginRight: 12 }}
+ />
+ <View style={{ flex: 1 }}>
+ <Text style={{ fontWeight: "600", textTransform: "capitalize" }}>
+ {a.action.toLowerCase()}
+ </Text>
+ <Text style={local.small}>By: {a.currentOwner}</Text>
+ <Text style={local.small}>
+ On: {new Date(a.timestamp).toLocaleString()}
+ </Text>
+ <Text style={local.small}>
+ Location: {a.currentLocation || "N/A"}
+ </Text>
+ </View>
</View>
- </View>
- ))}
+ ))}
</View>
);
@@ -129,7 +134,7 @@ export default function SearchScreen({ navigation }) {
{user.walletId && (
<DetailRow icon="wallet" label="Wallet ID" value={user.walletId} />
)}
- {user.certification && (
+ {user.certification?.length > 0 && (
<DetailRow
icon="certificate"
label="Certifications"
@@ -146,7 +151,6 @@ export default function SearchScreen({ navigation }) {
contentContainerStyle={{
flexGrow: 1,
paddingBottom: insets.bottom,
- paddingHorizontal: 4,
}}
keyboardShouldPersistTaps="handled"
>
@@ -158,18 +162,11 @@ export default function SearchScreen({ navigation }) {
showBack={true}
/>
<View style={local.searchContainer}>
- <Text
- style={{
- marginBottom: 12,
- color: colors.darkGreen,
- textAlign: "center",
- }}
- >
- Search for information about any produce or user in the supply chain
+ <Text style={local.searchDescription}>
+ Search for information about any produce or user in the supply
+ chain.
</Text>
- <View
- style={{ flexDirection: "row", justifyContent: "space-around" }}
- >
+ <View style={local.tabContainer}>
{["Produce", "User"].map((t) => (
<TouchableOpacity
key={t}
@@ -208,9 +205,7 @@ export default function SearchScreen({ navigation }) {
<Scanner value={produceId} onChange={setProduceId} />
) : (
<View>
- <Text style={[local.detailLabel, { marginTop: 20 }]}>
- 1. Select Role
- </Text>
+ <Text style={local.detailLabel}>1. Select Role</Text>
<RNPickerSelect
onValueChange={(value) => setUserRole(value)}
items={[
@@ -224,21 +219,19 @@ export default function SearchScreen({ navigation }) {
useNativeAndroidPickerStyle={false}
placeholder={{}}
/>
- <Text
- style={[local.detailLabel, { marginTop: 20, marginBottom: 6 }]}
- >
+ <Text style={[local.detailLabel, { marginTop: 12 }]}>
2. Enter User ID
</Text>
<TextInput
style={styles.input}
- placeholder="e.g. farmer1, distributor_xyz"
+ placeholder="e.g. farmer1, dist1"
value={userId}
onChangeText={setUserId}
/>
</View>
)}
<TouchableOpacity
- style={[styles.primaryButton, { marginTop: 20 }]}
+ style={[styles.primaryButton, { marginTop: 16 }]}
onPress={fetchResult}
disabled={loading}
>
@@ -257,22 +250,22 @@ export default function SearchScreen({ navigation }) {
/>
)}
{!loading && result && (
- <>
+ <View style={{ marginTop: 16 }}>
{tab === "Produce" && result.produce ? (
- <View style={{ marginTop: 16 }}>
+ <>
<ProduceCard produce={result.produce} />
{renderTimeline(result.produce)}
- </View>
+ </>
) : tab === "User" && result.user ? (
- <View style={{ marginTop: 16 }}>{renderUser(result.user)}</View>
+ renderUser(result.user)
) : (
<View style={local.card}>
<Text style={{ textAlign: "center", fontWeight: "500" }}>
- No results found for provided ID
+ No results found for the provided ID.
</Text>
</View>
)}
- </>
+ </View>
)}
</ScrollView>
</SafeAreaView>
@@ -284,13 +277,24 @@ const local = StyleSheet.create({
padding: 16,
backgroundColor: "white",
borderRadius: 16,
- marginBottom: 10,
+ margin: 12,
elevation: 4,
shadowColor: "#000",
shadowOpacity: 0.1,
shadowOffset: { width: 0, height: 2 },
shadowRadius: 8,
},
+ searchDescription: {
+ marginBottom: 16,
+ color: colors.gray,
+ textAlign: "center",
+ fontSize: 15,
+ },
+ tabContainer: {
+ flexDirection: "row",
+ justifyContent: "space-around",
+ marginBottom: 10,
+ },
tabButton: {
flex: 1,
flexDirection: "row",
@@ -300,14 +304,12 @@ const local = StyleSheet.create({
borderRadius: 12,
marginHorizontal: 5,
},
- tabText: {
- marginLeft: 6,
- fontWeight: "600",
- },
+ tabText: { marginLeft: 6, fontWeight: "600" },
card: {
backgroundColor: "white",
borderRadius: 16,
padding: 16,
+ marginHorizontal: 12,
marginVertical: 10,
shadowColor: "#000",
shadowOpacity: 0.1,
@@ -315,116 +317,27 @@ const local = StyleSheet.create({
shadowRadius: 10,
elevation: 5,
},
- header: {
- borderBottomWidth: 1,
- borderBottomColor: colors.lightGreen,
- paddingBottom: 8,
- marginBottom: 12,
- },
- refNoText: {
- color: "#999",
- fontSize: 12,
- },
- refNoId: {
- color: colors.darkGreen,
- fontSize: 14,
- fontWeight: "500",
- },
- splitRow: {
- flexDirection: "row",
- justifyContent: "space-between",
- marginBottom: 16,
- },
- mainContentRow: {
- flexDirection: "row",
- marginBottom: 16,
- alignItems: "center",
- },
- produceImage: {
- width: 120,
- height: 120,
- borderRadius: 12,
- },
- imagePlaceholder: {
- backgroundColor: colors.cream,
- alignItems: "center",
- justifyContent: "center",
- borderWidth: 1,
- borderColor: colors.lightGreen,
- },
- detailsContainer: {
- flex: 1,
- marginLeft: 16,
- minHeight: 120,
- justifyContent: "space-around",
- },
- detailItem: {
- flexDirection: "row",
- alignItems: "center",
- marginVertical: 8,
- },
- detailLabel: {
- color: "#666",
- fontSize: 12,
- marginBottom: 2,
- },
- detailValue: {
- color: "#000",
- fontSize: 15,
- fontWeight: "600",
- },
title: {
fontWeight: "700",
fontSize: 18,
color: colors.darkGreen,
},
- badgesRow: {
- flexDirection: "row",
- alignItems: "center",
- justifyContent: "space-between",
- marginVertical: 8,
- paddingVertical: 12,
- borderTopWidth: 1,
- borderTopColor: colors.lightGreen,
- },
- certContainer: {
- flexDirection: "row",
- alignItems: "center",
- backgroundColor: colors.lightGreen,
- paddingVertical: 8,
- paddingHorizontal: 12,
- borderRadius: 20,
- },
- certText: {
- color: colors.darkGreen,
- fontWeight: "600",
- marginLeft: 6,
- },
- statusContainer: {
- backgroundColor: colors.accent,
- paddingVertical: 8,
- paddingHorizontal: 16,
- borderRadius: 8,
- },
- statusText: {
- color: colors.darkGreen,
- fontWeight: "bold",
- fontSize: 14,
- },
subtitle: {
fontWeight: "600",
- marginTop: 12,
+ marginTop: 16,
marginBottom: 8,
fontSize: 16,
color: colors.darkGreen,
+ paddingHorizontal: 12,
},
timelineItem: {
flexDirection: "row",
- alignItems: "center",
- marginVertical: 8,
- paddingLeft: 4,
+ paddingVertical: 12,
+ marginHorizontal: 12,
+ borderBottomWidth: 1,
+ borderBottomColor: colors.lightGreen,
},
- small: { fontSize: 12, color: "#555" },
+ small: { fontSize: 13, color: "#555", marginTop: 2 },
userHeader: {
flexDirection: "row",
alignItems: "center",
@@ -450,10 +363,12 @@ const local = StyleSheet.create({
alignSelf: "flex-start",
marginTop: 4,
},
- roleText: {
- color: "white",
- fontSize: 12,
- fontWeight: "bold",
+ roleText: { color: "white", fontSize: 12, fontWeight: "bold" },
+ detailLabel: {
+ color: colors.gray,
+ fontSize: 14,
+ fontWeight: "600",
+ marginBottom: 4,
},
});