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