matiru

Blockchain-Based Supply Chain Transparency for Agricultural Produce
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:
Mbackend/server.js | 163++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mfrontend/components/ProduceCard.js | 100+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Mfrontend/screens/Distributor.js | 2+-
Mfrontend/screens/Home.js | 2+-
Mfrontend/screens/Inspector.js | 108+++++++++++++++++++++++++++++++++++--------------------------------------------
Mfrontend/screens/Inventory.js | 130++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Mfrontend/screens/Notifications.js | 63+++++++++++++++++++++++++++++++++++++++++++++------------------
Mfrontend/screens/Retailer.js | 2+-
Mfrontend/screens/Search.js | 255+++++++++++++++++++++++++++-----------------------------------------------------
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, }, });