matiru

Blockchain-Based Supply Chain Transparency for Agricultural Produce
commit 15f5de3b58e534a9b125436f0ce189ffc22feac0
parent 4e39abcb6107a4a52816f199fa21ade3684143ec
Author: maydayv7 <maydayv7@gmail.com>
Date:   Sat,  4 Oct 2025 15:25:07 +0530

feat: Image upload (Local)

Diffstat:
MREADME.md | 3++-
Mbackend/.gitignore | 1+
Mbackend/addToWallet.js | 53++++++++++++++++-------------------------------------
Mbackend/package-lock.json | 175++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mbackend/package.json | 3++-
Mbackend/server.js | 98++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mchaincode/index.js | 2++
Afrontend/components/ImageUploader.js | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mfrontend/components/LocationPicker.js | 6+++++-
Mfrontend/package-lock.json | 22++++++++++++++++++++++
Mfrontend/package.json | 1+
Mfrontend/screens/Farmer.js | 30+++++++++++++++++++++++++++---
Mfrontend/screens/Search.js | 162++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Mfrontend/styles.js | 1+
14 files changed, 538 insertions(+), 145 deletions(-)

diff --git a/README.md b/README.md @@ -42,7 +42,6 @@ After initial setup, you can use the following commands: ``` ./network.sh up # Start network ./network.sh down # Reset everything -> Need to setup again - ``` 4. Deploy Chaincode: @@ -81,6 +80,8 @@ IDENTITY=USER_NAME AS_LOCALHOST=true ``` +Make sure the port is not blocked by a firewall + 7. Start `backend`: ``` diff --git a/backend/.gitignore b/backend/.gitignore @@ -1,3 +1,4 @@ users.json wallet connection-* +uploads diff --git a/backend/addToWallet.js b/backend/addToWallet.js @@ -8,24 +8,31 @@ async function main() { const args = process.argv.slice(2); if (args.length < 4) { console.log( - "Usage: node addToWallet.js <org> <userFolder> <walletDir> <identityLabel>" + "Usage: node addToWallet.js org<num> <userFolder> <walletDir> <identityLabel>" ); process.exit(1); } const [org, userFolder, walletDir, identityLabel] = args; - // Typical path for the identity in test-network: - // fabric-samples/test-network/organizations/peerOrganizations/org1.example.com/users/User1@org1.example.com/msp + // Determine MSP ID + const orgMatch = org.match(/^org(\d+)$/i); + if (!orgMatch) { + console.error( + `Invalid org argument: ${org}. Must be like "org1", "org2", etc.` + ); + process.exit(1); + } + const orgNum = orgMatch[1]; + const mspId = `Org${orgNum}MSP`; + const userMspPath = path.resolve(userFolder); if (!fs.existsSync(userMspPath)) { console.error("User MSP folder not found:", userMspPath); process.exit(1); } + // Read certificate file const certPath = path.join(userMspPath, "signcerts"); - const keyPath = path.join(userMspPath, "keystore"); - - // Read certificate file (first file in signcerts) const certFiles = fs.readdirSync(certPath); if (certFiles.length === 0) { console.error("No cert files found in", certPath); @@ -33,7 +40,8 @@ async function main() { } const cert = fs.readFileSync(path.join(certPath, certFiles[0])).toString(); - // Read private key file (first file in keystore) + // Read private key file + const keyPath = path.join(userMspPath, "keystore"); const keyFiles = fs.readdirSync(keyPath); if (keyFiles.length === 0) { console.error("No key files found in", keyPath); @@ -41,38 +49,10 @@ async function main() { } const key = fs.readFileSync(path.join(keyPath, keyFiles[0])).toString(); - // Read MSP config (to extract MSP ID) - // Try parent folder path like .../User1@org1.example.com/msp/config.yaml or check for Org MSP folder - const parent = path.dirname(userMspPath); - // For org1 user path example, MSP ID usually: Org1MSP - // Try to get it from the folder structure: organizations/peerOrganizations/org1.example.com/msp - let orgMspFolder = null; - let node = parent; - while (node !== path.parse(node).root) { - if (fs.existsSync(path.join(node, "msp"))) { - orgMspFolder = path.join(node, "msp"); - break; - } - node = path.dirname(node); - } - - // Fallback: if not found, try to derive from org argument (org1 -> Org1MSP) - let mspId = null; - if (orgMspFolder) { - // derive MSP ID from folder name e.g., org1.example.com -> Org1MSP - const orgFolder = path.basename(path.dirname(orgMspFolder)); // org1.example.com - // crude mapping: take first token and uppercase first letter + "MSP" - const prefix = orgFolder.split(".")[0]; // org1 - mspId = prefix.charAt(0).toUpperCase() + prefix.slice(1) + "MSP"; // Org1MSP - } else { - mspId = org.charAt(0).toUpperCase() + org.slice(1) + "MSP"; - } - - // Create wallet directory + // Create wallet const walletPath = path.resolve(walletDir); const wallet = await Wallets.newFileSystemWallet(walletPath); - // Create identity object const identity = { credentials: { certificate: cert, @@ -86,7 +66,6 @@ async function main() { console.log( `Successfully added identity ${identityLabel} to wallet at ${walletPath}` ); - console.log(`mspId used: ${mspId}`); process.exit(0); } catch (err) { console.error("Error importing identity to wallet:", err); diff --git a/backend/package-lock.json b/backend/package-lock.json @@ -16,7 +16,8 @@ "fabric-network": "^2.2.20", "fs-extra": "^11.3.2", "jsonwebtoken": "^9.0.2", - "morgan": "^1.10.1" + "morgan": "^1.10.1", + "multer": "^2.0.2" } }, "node_modules/@grpc/grpc-js": { @@ -160,6 +161,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -248,6 +255,23 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -347,6 +371,21 @@ "node": ">= 0.8" } }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/content-disposition": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", @@ -1201,6 +1240,27 @@ "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==", "license": "MIT" }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/morgan": { "version": "1.10.1", "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", @@ -1250,6 +1310,67 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/multer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", + "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "mkdirp": "^0.5.6", + "object-assign": "^4.1.1", + "type-is": "^1.6.18", + "xtend": "^4.0.2" + }, + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/nan": { "version": "2.23.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz", @@ -1531,6 +1652,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -1742,6 +1877,23 @@ "node": ">= 0.8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -1791,6 +1943,12 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "7.13.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.13.0.tgz", @@ -1815,6 +1973,12 @@ "node": ">= 0.8" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -1873,6 +2037,15 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/backend/package.json b/backend/package.json @@ -15,6 +15,7 @@ "fabric-network": "^2.2.20", "fs-extra": "^11.3.2", "jsonwebtoken": "^9.0.2", - "morgan": "^1.10.1" + "morgan": "^1.10.1", + "multer": "^2.0.2" } } diff --git a/backend/server.js b/backend/server.js @@ -8,13 +8,28 @@ const fs = require("fs"); const path = require("path"); const jwt = require("jsonwebtoken"); const bcrypt = require("bcryptjs"); +const multer = require("multer"); const { Gateway, Wallets } = require("fabric-network"); const app = express(); app.use(cors()); -app.use(bodyParser.json()); app.use(morgan("dev")); +// Image Storage +app.use("/uploads", express.static("uploads")); +const UPLOAD_DIR = path.join(__dirname, "uploads"); +if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR); +const storage = multer.diskStorage({ + destination: function (req, file, cb) { + cb(null, UPLOAD_DIR); + }, + filename: function (req, file, cb) { + const uniqueName = Date.now() + "-" + file.originalname; + cb(null, uniqueName); + }, +}); +const upload = multer({ storage }); + const CCP_PATH = process.env.CCP_PATH; const WALLET_PATH = process.env.WALLET_PATH; const CHANNEL = process.env.CHANNEL; @@ -42,11 +57,33 @@ async function getContract() { return { contract, gateway }; } -/** - * Auth Route (prototype) - * Uses backend/users.json for demo authentication - */ -app.post("/api/auth/login", async (req, res) => { +// Middleware +function authenticateMiddleware(req, res, next) { + if (req.path === "/auth/login") return next(); + + // Public Queries + if ( + req.method === "GET" && + (req.path.startsWith("/getProduce") || req.path.startsWith("/getUser")) + ) { + return next(); + } + + const header = req.headers["authorization"]; + if (!header) + return res.status(401).json({ error: "missing authorization header" }); + const token = header.split(" ")[1]; + 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" }); + req.user = payload; + next(); + }); +} + +// User Authentication +// Prototype: Uses backend/users.json +app.post("/api/auth/login", bodyParser.json(), async (req, res) => { try { const { username, password } = req.body || {}; if (!username || !password) @@ -73,32 +110,25 @@ app.post("/api/auth/login", async (req, res) => { } }); -// Middleware -function authenticateMiddleware(req, res, next) { - if (req.path === "/auth/login") return next(); - - // Public Queries - if ( - req.method === "GET" && - (req.path.startsWith("/getProduce") || - req.path.startsWith("/getProduceByOwner")) - ) { - return next(); +app.post( + "/api/uploadImage", + authenticateMiddleware, + upload.single("image"), + (req, res) => { + if (!req.file) { + return res.status(400).json({ error: "no image file uploaded" }); + } + try { + const fileUrl = `${req.protocol}://${req.get("host")}/uploads/${ + req.file.filename + }`; + res.json({ url: fileUrl }); + } catch (err) { + console.error("upload error", err); + res.status(500).json({ error: "failed to process image upload" }); + } } - - const header = req.headers["authorization"]; - if (!header) - return res.status(401).json({ error: "missing authorization header" }); - const token = header.split(" ")[1]; - 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" }); - req.user = payload; - next(); - }); -} - -app.use("/api", authenticateMiddleware); +); // Functions const router = express.Router(); @@ -319,7 +349,9 @@ router.get("/getUser/:userKey", async (req, res) => { } }); -app.use("/api", router); +app.use("/api", authenticateMiddleware, bodyParser.json(), router); const PORT = process.env.PORT || 4000; -app.listen(PORT, () => console.log(`Backend server listening on ${PORT}`)); +app.listen(PORT, "0.0.0.0", () => + console.log(`Backend server listening on ${PORT}`) +); diff --git a/chaincode/index.js b/chaincode/index.js @@ -69,6 +69,7 @@ class ProduceContract extends Contract { quality: details.quality || null, expiryDate: details.expiryDate || null, storageConditions: details.storageConditions || [], + imageUrl: details.imageUrl || null, status: "Harvested", isAvailable: true, notAvailableReason: null, @@ -180,6 +181,7 @@ class ProduceContract extends Contract { produce.pricePerUnit = details.pricePerUnit; if (details.storageConditions !== undefined) produce.storageConditions = details.storageConditions; + if (details.imageUrl !== undefined) produce.imageUrl = details.imageUrl; produce.totalPrice = (produce.pricePerUnit || 0) * (produce.qty || 0); produce.actionHistory.push( diff --git a/frontend/components/ImageUploader.js b/frontend/components/ImageUploader.js @@ -0,0 +1,126 @@ +import { useState, forwardRef, useImperativeHandle } from "react"; +import { + Button, + Image, + View, + Platform, + ActivityIndicator, + Text, + Alert, +} from "react-native"; +import { launchImageLibraryAsync } from "expo-image-picker"; +import { API_BASE } from "../config"; +import { colors } from "../styles"; + +const ImageUploader = forwardRef(({ token }, ref) => { + const [imageAsset, setImageAsset] = useState(null); + const [isUploading, setIsUploading] = useState(false); + + useImperativeHandle(ref, () => ({ + upload: async () => { + if (!imageAsset) return null; + setIsUploading(true); + + try { + const imageUrl = await handleUpload(); + return imageUrl; + } catch (error) { + Alert.alert("Upload Failed", error.message); + return null; + } finally { + setIsUploading(false); + } + }, + + reset: () => { + setImageAsset(null); + setIsUploading(false); + }, + })); + + const pickImage = async () => { + const result = await launchImageLibraryAsync({ + allowsEditing: true, + quality: 0.7, + mediaTypes: "Images", + }); + + if (!result.canceled) setImageAsset(result.assets[0]); + }; + + const handleUpload = async () => { + const data = new FormData(); + + if (Platform.OS === "web") { + const response = await fetch(imageAsset.uri); + const blob = await response.blob(); + const fileName = imageAsset.fileName || `web-image-${Date.now()}.jpg`; + data.append("image", blob, fileName); + } else { + data.append("image", { + uri: imageAsset.uri, + type: imageAsset.mimeType || "image/jpeg", + name: + imageAsset.fileName || + imageAsset.uri.split("/").pop() || + `image-${Date.now()}.jpg`, + }); + } + + const res = await fetch(`${API_BASE}/uploadImage`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + }, + body: data, + }); + + const responseData = await res.json(); + if (!res.ok) throw new Error(responseData.error || "Image upload failed"); + return responseData.url; + }; + + const removeImage = () => { + setImageAsset(null); + }; + + return ( + <View style={{ marginVertical: 10 }}> + {imageAsset && ( + <View> + <Image + source={{ uri: imageAsset.uri }} + style={{ + width: "100%", + height: 200, + resizeMode: "contain", + marginBottom: 10, + }} + /> + <Button + title="Remove Image" + color={colors.danger} + onPress={removeImage} + /> + </View> + )} + + {isUploading ? ( + <View style={{ alignItems: "center", padding: 20 }}> + <ActivityIndicator size="large" color={colors.darkGreen} /> + <Text>Uploading...</Text> + </View> + ) : ( + !imageAsset && ( + <Button + title="Pick Image" + color={colors.darkGreen} + onPress={pickImage} + /> + ) + )} + </View> + ); +}); + +export default ImageUploader; diff --git a/frontend/components/LocationPicker.js b/frontend/components/LocationPicker.js @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { View, TextInput, Button, Text, Alert } from "react-native"; import * as Location from "expo-location"; import { colors } from "../styles"; @@ -6,6 +6,10 @@ import { colors } from "../styles"; export default function LocationPicker({ value, onChange }) { const [coords, setCoords] = useState(null); + useEffect(() => { + if (!value) setCoords(null); + }, [value]); + const pickLocation = async () => { try { const { status } = await Location.requestForegroundPermissionsAsync(); diff --git a/frontend/package-lock.json b/frontend/package-lock.json @@ -23,6 +23,7 @@ "expo-font": "~14.0.8", "expo-haptics": "~15.0.7", "expo-image": "~3.0.8", + "expo-image-picker": "~17.0.8", "expo-linking": "~8.0.8", "expo-location": "~19.0.7", "expo-router": "~6.0.9", @@ -5349,6 +5350,27 @@ } } }, + "node_modules/expo-image-loader": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-6.0.0.tgz", + "integrity": "sha512-nKs/xnOGw6ACb4g26xceBD57FKLFkSwEUTDXEDF3Gtcu3MqF3ZIYd3YM+sSb1/z9AKV1dYT7rMSGVNgsveXLIQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-image-picker": { + "version": "17.0.8", + "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-17.0.8.tgz", + "integrity": "sha512-489ByhVs2XPoAu9zodivAKLv7hG4S/FOe8hO/C2U6jVxmRjpAKakKNjMml0IwWjf1+c/RYBqm1XxKaZ+vq/fDQ==", + "license": "MIT", + "dependencies": { + "expo-image-loader": "~6.0.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-json-utils": { "version": "0.15.0", "resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-0.15.0.tgz", diff --git a/frontend/package.json b/frontend/package.json @@ -21,6 +21,7 @@ "expo-font": "~14.0.8", "expo-haptics": "~15.0.7", "expo-image": "~3.0.8", + "expo-image-picker": "~17.0.8", "expo-linking": "~8.0.8", "expo-location": "~19.0.7", "expo-router": "~6.0.9", diff --git a/frontend/screens/Farmer.js b/frontend/screens/Farmer.js @@ -1,4 +1,4 @@ -import { useContext, useState } from "react"; +import { useContext, useState, useRef } from "react"; import { Alert, ScrollView, @@ -13,6 +13,7 @@ import ActionButton from "../components/ActionButton"; import Scanner from "../components/Scanner"; import LocationPicker from "../components/LocationPicker"; import QRModal from "../components/QRModal"; +import ImageUploader from "../components/ImageUploader"; import { API_BASE } from "../config"; import styles from "../styles"; import { AuthContext } from "../AuthContext"; @@ -23,6 +24,7 @@ export default function FarmerScreen({ navigation, route }) { const token = user?.token; const [active, setActive] = useState(null); + const imageUploaderRef = useRef(null); // Common const [produceId, setProduceId] = useState(""); @@ -45,6 +47,7 @@ export default function FarmerScreen({ navigation, route }) { const [qrVisible, setQrVisible] = useState(false); const [lastProduceId, setLastProduceId] = useState(null); + // Reset const resetRegisterForm = () => { setCropType(""); setQty(""); @@ -55,14 +58,18 @@ export default function FarmerScreen({ navigation, route }) { setExpiryDate(""); setStorageConditions(""); setLocation(""); + imageUploaderRef.current?.reset(); }; const resetCommon = () => { setProduceId(""); }; + // Functions const registerProduce = async () => { try { + const imageUrl = await imageUploaderRef.current?.upload(); + const res = await fetch(`${API_BASE}/registerProduce`, { method: "POST", headers: { @@ -72,6 +79,7 @@ export default function FarmerScreen({ navigation, route }) { body: JSON.stringify({ farmerId: userId, details: { + imageUrl, cropType, qty: parseFloat(qty) || 0, qtyUnit, @@ -103,6 +111,11 @@ export default function FarmerScreen({ navigation, route }) { }; const updateDetails = async () => { + if (!produceId) { + Alert.alert("Error", "Please enter Produce ID"); + return; + } + try { const res = await fetch(`${API_BASE}/updateDetails`, { method: "POST", @@ -125,7 +138,6 @@ export default function FarmerScreen({ navigation, route }) { if (!res.ok) throw new Error(data.error || "Error"); Alert.alert("Updated", "Produce details updated"); - // Reset inputs for update flow only resetCommon(); setPricePerUnit(""); setStorageConditions(""); @@ -135,6 +147,11 @@ export default function FarmerScreen({ navigation, route }) { }; const splitProduce = async () => { + if (!produceId || !splitQty) { + Alert.alert("Error", "Please enter Produce ID quantity to split"); + return; + } + try { const res = await fetch(`${API_BASE}/splitProduce`, { method: "POST", @@ -160,6 +177,11 @@ export default function FarmerScreen({ navigation, route }) { }; const updateLocation = async () => { + if (!produceId || !location) { + Alert.alert("Error", "Please enter Produce ID and pick a location"); + return; + } + try { const res = await fetch(`${API_BASE}/updateLocation`, { method: "POST", @@ -175,6 +197,7 @@ export default function FarmerScreen({ navigation, route }) { }); const data = await res.json(); if (!res.ok) throw new Error(data.error || "Error"); + Alert.alert("Moved", "Location updated successfully"); resetCommon(); setLocation(""); @@ -267,11 +290,12 @@ export default function FarmerScreen({ navigation, route }) { onChangeText={setStorageConditions} /> <LocationPicker value={location} onChange={setLocation} /> + <ImageUploader ref={imageUploaderRef} token={token} /> <TouchableOpacity style={styles.primaryButton} onPress={registerProduce} > - <Text style={styles.buttonText}>Submit Registration</Text> + <Text style={styles.buttonText}>Register Produce</Text> </TouchableOpacity> </View> )} diff --git a/frontend/screens/Search.js b/frontend/screens/Search.js @@ -1,12 +1,13 @@ import { useState } from "react"; import { Alert, + Image, ScrollView, + StyleSheet, Text, TextInput, TouchableOpacity, View, - StyleSheet, } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { MaterialCommunityIcons } from "@expo/vector-icons"; @@ -44,10 +45,60 @@ export default function SearchScreen({ navigation }) { } }; + const renderTimeline = (produce) => ( + <View style={{ marginTop: 14 }}> + <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" + : a.action === "SALE" + ? "cash" + : a.action === "INSPECT" + ? "check-decagram" + : "close-circle" + } + size={22} + color={ + a.action === "REMOVED" + ? "red" + : a.action === "SALE" + ? "orange" + : colors.darkGreen + } + /> + <View style={{ marginLeft: 8 }}> + <Text style={{ fontWeight: "600" }}> + {a.action} at {a.currentLocation || "N/A"} + </Text> + <Text style={local.small}> + Owner: {a.currentOwner} | Time: {a.timestamp} + </Text> + </View> + </View> + ))} + </View> + ); + const renderProduce = (produce) => ( <View style={local.card}> - <Text style={local.title}>Produce ID: {produce.id}</Text> - <Text>Crop: {produce.cropType}</Text> + {produce.imageUrl && ( + <Image + source={{ uri: produce.imageUrl }} + style={{ + width: "100%", + height: 200, + borderRadius: 10, + marginBottom: 12, + }} + resizeMode="cover" + /> + )} + <Text style={local.title}>{produce.cropType}</Text> <Text> Quantity: {produce.qty} {produce.qtyUnit} </Text> @@ -56,77 +107,21 @@ export default function SearchScreen({ navigation }) { <Text>Status: {produce.status}</Text> <Text>Owner: {produce.currentOwner}</Text> <Text>Location: {produce.currentLocation}</Text> + <Text>Storage: {produce.storageConditions}</Text> <Text>Harvest Date: {produce.harvestDate}</Text> - <Text>Expiry: {produce.expiryDate}</Text> + <Text>Expiry Date: {produce.expiryDate}</Text> <Text>Available: {produce.isAvailable ? "Yes" : "No"}</Text> - - {/* Provenance Timeline */} - {produce.actionHistory && ( - <View style={{ marginTop: 14 }}> - <Text style={local.subtitle}>Action History:</Text> - {produce.actionHistory.map((a, idx) => ( - <View key={idx} style={local.timelineItem}> - <Text> - • {a.timestamp} → {a.action} - </Text> - <Text style={local.small}> - Location: {a.currentLocation || "N/A"} | Owner:{" "} - {a.currentOwner || "N/A"} - </Text> - </View> - ))} - </View> - )} - - {produce.saleHistory && produce.saleHistory.length > 0 && ( - <View style={{ marginTop: 14 }}> - <Text style={local.subtitle}>Sale History:</Text> - {produce.saleHistory.map((s, idx) => ( - <View key={idx} style={local.timelineItem}> - <Text> - • {s.timestamp} → {s.prevOwner} → {s.newOwner} - </Text> - <Text style={local.small}> - Qty: {s.qtyBought}, Price: {s.salePrice} - </Text> - </View> - ))} - </View> - )} + {renderTimeline(produce)} </View> ); - const renderResults = () => { - if (!result) return null; - - if (tab === "Produce" && result.produce) { - return renderProduce(result.produce); - } - if (tab === "User" && result.user) { - const u = result.user; - return ( - <View style={local.card}> - <Text style={local.title}>{u.role} Profile</Text> - <Text>ID: {u.id}</Text> - <Text>Name: {u.name}</Text> - {u.location && <Text>Location: {u.location}</Text>} - {u.walletId && <Text>Wallet: {u.walletId}</Text>} - {u.certification && ( - <Text>Certifications: {u.certification.join(", ")}</Text> - )} - </View> - ); - } - - return <Text style={{ color: "red" }}>No results found.</Text>; - }; - return ( <SafeAreaView style={styles.container}> <ScreenHeader title="Global Search" navigation={navigation} hideSearchButton={true} + showBack={true} /> <Text style={{ marginBottom: 12, color: colors.darkGreen }}> @@ -169,7 +164,6 @@ export default function SearchScreen({ navigation }) { ))} </View> - {/* ✅ For Produce tab → show scanner + input */} {tab === "Produce" ? ( <Scanner value={input} onChange={setInput} /> ) : ( @@ -187,7 +181,30 @@ export default function SearchScreen({ navigation }) { <ScrollView style={{ marginTop: 20, paddingHorizontal: 12 }}> {loading && <Text>Loading...</Text>} - {!loading && renderResults()} + {!loading && + result && + (tab === "Produce" && result.produce ? ( + renderProduce(result.produce) + ) : tab === "User" && result.user ? ( + <View style={local.card}> + <Text style={local.title}>{result.user.role} Profile</Text> + <Text>ID: {result.user.id}</Text> + <Text>Name: {result.user.name}</Text> + {result.user.location && ( + <Text>Location: {result.user.location}</Text> + )} + {result.user.walletId && ( + <Text>Wallet: {result.user.walletId}</Text> + )} + {result.user.certification && ( + <Text> + Certifications: {result.user.certification.join(", ")} + </Text> + )} + </View> + ) : ( + <Text>No results found.</Text> + ))} </ScrollView> </SafeAreaView> ); @@ -202,8 +219,17 @@ const local = StyleSheet.create({ marginBottom: 16, backgroundColor: "#fafafa", }, - title: { fontWeight: "700", marginBottom: 6, fontSize: 16 }, - subtitle: { fontWeight: "600", marginTop: 6, marginBottom: 4 }, - timelineItem: { marginLeft: 8, marginBottom: 4 }, + title: { + fontWeight: "700", + marginBottom: 6, + fontSize: 18, + color: colors.darkGreen, + }, + subtitle: { fontWeight: "600", marginTop: 6, marginBottom: 4, fontSize: 16 }, + timelineItem: { + flexDirection: "row", + alignItems: "center", + marginVertical: 6, + }, small: { fontSize: 12, color: "#555" }, }); diff --git a/frontend/styles.js b/frontend/styles.js @@ -8,6 +8,7 @@ export const colors = { cream: "#F3F7EE", lightGreen: "#DCEFCF", accent: "#BEE7A9", + danger: "#C04A4A", }; export default StyleSheet.create({