mirror of
				https://github.com/StefBuwalda/cal_counter.git
				synced 2025-10-30 11:19:59 +00:00 
			
		
		
		
	Add barcode scanning and nutrition lookup feature
Introduces a new /scan route and template for barcode scanning using ZXing in the browser. Adds a /nutri/<barcode> API endpoint to fetch food item nutrition data by barcode. Updates the FoodItem model to include a barcode field and a to_dict method for JSON serialization. Also updates seed data to include a barcode.
This commit is contained in:
		
							
								
								
									
										25
									
								
								app.py
									
									
									
									
									
								
							
							
						
						
									
										25
									
								
								app.py
									
									
									
									
									
								
							| @@ -1,4 +1,4 @@ | |||||||
| from flask import render_template, redirect, url_for | from flask import render_template, redirect, url_for, request, jsonify | ||||||
| from flask_login import ( | from flask_login import ( | ||||||
|     login_required, |     login_required, | ||||||
|     logout_user, |     logout_user, | ||||||
| @@ -6,7 +6,7 @@ from flask_login import ( | |||||||
|     current_user, |     current_user, | ||||||
| ) | ) | ||||||
| from forms import LoginForm | from forms import LoginForm | ||||||
| from models import User | from models import User, FoodItem | ||||||
| from application import db, app, login_manager | from application import db, app, login_manager | ||||||
| from application.admin.routes import admin_bp | from application.admin.routes import admin_bp | ||||||
|  |  | ||||||
| @@ -43,8 +43,9 @@ def login(): | |||||||
|         user = User.query.filter_by(username=form.username.data).first() |         user = User.query.filter_by(username=form.username.data).first() | ||||||
|         if user and user.check_password(password=form.password.data): |         if user and user.check_password(password=form.password.data): | ||||||
|             # User found and password correct |             # User found and password correct | ||||||
|             login_user(user) |             next_page = request.args.get("next")  # Get next page if given | ||||||
|             return redirect(url_for("dashboard")) |             login_user(user)  # Log in the user | ||||||
|  |             return redirect(next_page or url_for("dashboard")) | ||||||
|         else: |         else: | ||||||
|             pass |             pass | ||||||
|             # invalid user |             # invalid user | ||||||
| @@ -64,6 +65,22 @@ def logout(): | |||||||
|     return redirect(url_for("index")) |     return redirect(url_for("index")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @app.route("/scan") | ||||||
|  | @login_required | ||||||
|  | def scan(): | ||||||
|  |     return render_template("scan.html") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @app.route("/nutri/<int:barcode>", methods=["GET"]) | ||||||
|  | @login_required | ||||||
|  | def nutri(barcode): | ||||||
|  |     food = FoodItem.query.filter_by(barcode=barcode).first() | ||||||
|  |     if food: | ||||||
|  |         return jsonify(food.to_dict()) | ||||||
|  |     else: | ||||||
|  |         return jsonify({}) | ||||||
|  |  | ||||||
|  |  | ||||||
| # Run | # Run | ||||||
|  |  | ||||||
| if __name__ == "__main__": | if __name__ == "__main__": | ||||||
|   | |||||||
							
								
								
									
										108
									
								
								application/templates/scan.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								application/templates/scan.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,108 @@ | |||||||
|  | {% extends "base.html" %} | ||||||
|  |  | ||||||
|  | {% block content %} | ||||||
|  | <div id="main" class="container text-center"> | ||||||
|  | </div> | ||||||
|  |  | ||||||
|  | <template id="scan_result"> | ||||||
|  |     <div> | ||||||
|  |         <h5>Result:</h5> | ||||||
|  |         <p id="result" class="fw-bold text-success"></p> | ||||||
|  |     </div> | ||||||
|  |  | ||||||
|  |     <div id="product-info"></div> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <template id="template_Reader"> | ||||||
|  |     <h1 class="mb-4">📷 ZXing Barcode Scanner</h1> | ||||||
|  |  | ||||||
|  |     <div class="mb-3"> | ||||||
|  |         <video id="video" class="border rounded shadow-sm" width="100%" style="max-width: 500px;"></video> | ||||||
|  |     </div> | ||||||
|  |  | ||||||
|  |     <div class="mb-3"> | ||||||
|  |         <button id="startButton" class="btn btn-primary">Start Scanning</button> | ||||||
|  |         <button id="stopButton" class="btn btn-danger ms-2">Stop</button> | ||||||
|  |     </div> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script type="module"> | ||||||
|  |     import { BrowserMultiFormatReader } from 'https://cdn.jsdelivr.net/npm/@zxing/library@0.21.3/+esm'; | ||||||
|  |  | ||||||
|  |     const mainElement = document.getElementById('main'); | ||||||
|  |     const readerTemplate = document.getElementById('template_Reader') | ||||||
|  |     const readerClone = readerTemplate.content.cloneNode(true); | ||||||
|  |     const resultTemplate = document.getElementById('scan_result') | ||||||
|  |     const resultClone = resultTemplate.content.cloneNode(true); | ||||||
|  |     mainElement.appendChild(readerClone); | ||||||
|  |  | ||||||
|  |     // constants | ||||||
|  |     const codeReader = new BrowserMultiFormatReader(); | ||||||
|  |     const videoElement = document.getElementById('video'); | ||||||
|  |  | ||||||
|  |  | ||||||
|  |     async function fetchProductData(barcode) { | ||||||
|  |         // Step 1: GET Data | ||||||
|  |         const response = await fetch(`/nutri/${barcode}`); | ||||||
|  |         // Step 2: Check if response wasn't ok | ||||||
|  |         if (!response.ok) throw new Error('Network response was not ok'); | ||||||
|  |         // Step 3: Convert response to json and check if empty | ||||||
|  |         const nutritionData = await response.json(); | ||||||
|  |         mainElement.innerHTML = ''; | ||||||
|  |         mainElement.appendChild(resultClone); | ||||||
|  |         const resultElement = document.getElementById('result'); | ||||||
|  |         resultElement.textContent = barcode; | ||||||
|  |         const container = document.getElementById('product-info'); | ||||||
|  |         if (Object.keys(nutritionData).length === 0) { | ||||||
|  |             // No data, enter new data | ||||||
|  |         } | ||||||
|  |         else { | ||||||
|  |             container.innerHTML = ` | ||||||
|  |                     <h2>${nutritionData.name}</h2> | ||||||
|  |                     <p><strong>Barcode:</strong> ${nutritionData.barcode}</p> | ||||||
|  |                     <p><strong>Energy:</strong> ${nutritionData.energy_100g} kcal per 100g</p> | ||||||
|  |                     <p><strong>Carbs:</strong> ${nutritionData.carbs_100g} g</p> | ||||||
|  |                     <p><strong>Sugar:</strong> ${nutritionData.sugar_100g} g</p> | ||||||
|  |                     <p><strong>Fats:</strong> ${nutritionData.fats_100g} g</p> | ||||||
|  |                     <p><strong>Saturated Fats:</strong> ${nutritionData.saturated_fats_100g} g</p> | ||||||
|  |                     <p><strong>Protein:</strong> ${nutritionData.protein_100g} g</p> | ||||||
|  |                     `; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |     // Start scanning for barcode | ||||||
|  |     document.getElementById('startButton').addEventListener('click', async () => { | ||||||
|  |         console.log('[DEBUG] Start button clicked') | ||||||
|  |         try { | ||||||
|  |             await navigator.mediaDevices.getUserMedia({ video: true }); | ||||||
|  |             // Use stream with video.srcObject = stream | ||||||
|  |         } catch (err) { | ||||||
|  |             alert("No camera found or no camera permission"); | ||||||
|  |             console.error("Could not access the camera:", err); | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |         console.log('[DEBUG] Permission given and at least one device present'); | ||||||
|  |         const devices = await codeReader.listVideoInputDevices(); | ||||||
|  |         console.log('[DEBUG] Cameras found:', devices); | ||||||
|  |         const selectedDeviceId = devices[0]?.deviceId; | ||||||
|  |         await codeReader.decodeFromVideoDevice(selectedDeviceId, videoElement, async (result, err) => { | ||||||
|  |             if (result) { | ||||||
|  |                 const codeText = result.getText(); | ||||||
|  |                 try { | ||||||
|  |                     await fetchProductData(codeText) | ||||||
|  |                     codeReader.reset(); | ||||||
|  |                 } catch (error) { | ||||||
|  |                     console.error('Error fetching nutrition data:', error); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     document.getElementById('stopButton').addEventListener('click', () => { | ||||||
|  |         codeReader.reset(); | ||||||
|  |         resultElement.textContent = ''; | ||||||
|  |         container.innerHTML = "" | ||||||
|  |     }); | ||||||
|  | </script> | ||||||
|  | {% endblock %} | ||||||
							
								
								
									
										20
									
								
								models.py
									
									
									
									
									
								
							
							
						
						
									
										20
									
								
								models.py
									
									
									
									
									
								
							| @@ -36,6 +36,7 @@ class Unit(db.Model): | |||||||
| class FoodItem(db.Model): | class FoodItem(db.Model): | ||||||
|     __tablename__ = "food_item" |     __tablename__ = "food_item" | ||||||
|     id = db.Column(db.Integer, primary_key=True) |     id = db.Column(db.Integer, primary_key=True) | ||||||
|  |     barcode = db.Column(db.Integer) | ||||||
|     name = db.Column(db.String(150), unique=True, nullable=False) |     name = db.Column(db.String(150), unique=True, nullable=False) | ||||||
|  |  | ||||||
|     energy_100g = db.Column(db.Integer, nullable=False) |     energy_100g = db.Column(db.Integer, nullable=False) | ||||||
| @@ -52,8 +53,9 @@ class FoodItem(db.Model): | |||||||
|         protein: float, |         protein: float, | ||||||
|         carbs: int, |         carbs: int, | ||||||
|         fats: int, |         fats: int, | ||||||
|         sugar: Optional[int] = False, |         sugar: Optional[int] = None, | ||||||
|         saturated_fats: Optional[int] = False, |         saturated_fats: Optional[int] = None, | ||||||
|  |         barcode: Optional[int] = None, | ||||||
|     ): |     ): | ||||||
|         self.name = name |         self.name = name | ||||||
|         self.energy_100g = energy |         self.energy_100g = energy | ||||||
| @@ -62,3 +64,17 @@ class FoodItem(db.Model): | |||||||
|         self.sugar_100g = sugar |         self.sugar_100g = sugar | ||||||
|         self.fats_100g = fats |         self.fats_100g = fats | ||||||
|         self.saturated_fats_100g = saturated_fats |         self.saturated_fats_100g = saturated_fats | ||||||
|  |         self.barcode = barcode | ||||||
|  |  | ||||||
|  |     def to_dict(self): | ||||||
|  |         return { | ||||||
|  |             "id": self.id, | ||||||
|  |             "barcode": self.barcode, | ||||||
|  |             "name": self.name, | ||||||
|  |             "energy_100g": self.energy_100g, | ||||||
|  |             "protein_100g": self.protein_100g, | ||||||
|  |             "carbs_100g": self.carbs_100g, | ||||||
|  |             "sugar_100g": self.sugar_100g, | ||||||
|  |             "fats_100g": self.fats_100g, | ||||||
|  |             "saturated_fats_100g": self.saturated_fats_100g, | ||||||
|  |         } | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user