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:
2025-06-29 00:08:25 +02:00
parent d5e8c3fa94
commit 0919048cfd
4 changed files with 148 additions and 6 deletions

25
app.py
View File

@@ -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__":

View 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 %}

View File

@@ -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,
}

View File

@@ -16,6 +16,7 @@ with app.app_context():
protein=5.5, protein=5.5,
saturated_fats=10, saturated_fats=10,
sugar=35, sugar=35,
barcode=2278012003502,
) )
) )