commit 15f5de3b58e534a9b125436f0ce189ffc22feac0
parent 4e39abcb6107a4a52816f199fa21ade3684143ec
Author: maydayv7 <maydayv7@gmail.com>
Date: Sat, 4 Oct 2025 15:25:07 +0530
feat: Image upload (Local)
Diffstat:
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({