Add barcode-based meal logging workflow

Introduces a new add_meal blueprint with routes and templates for scanning barcodes, adding new food items, and logging meals. Updates FoodItemForm and FoodLogForm validation, changes FoodLog.amount to float, and integrates the new workflow into the daily log UI. Refactors user routes and templates to support the new meal logging process.
This commit is contained in:
2025-08-07 17:00:56 +02:00
parent 5ae82e379e
commit c552a4571e
10 changed files with 287 additions and 28 deletions

2
app.py
View File

@@ -10,6 +10,7 @@ from models import User
from application import db, app, login_manager
from application.admin.routes import admin_bp
from application.user.routes import user_bp
from application.add_meal.routes import bp as add_meal_bp
from typing import Optional
# Config
@@ -26,6 +27,7 @@ def load_user(user_id: int):
# Register blueprints
app.register_blueprint(admin_bp)
app.register_blueprint(user_bp)
app.register_blueprint(add_meal_bp)
# Routes

View File

@@ -0,0 +1,131 @@
from flask import (
Blueprint,
redirect,
url_for,
render_template,
session,
)
from flask_login import current_user
from forms import FoodItemForm, FoodLogForm
from application import db
from models import FoodItem, FoodLog
bp = Blueprint(
"add_meal",
__name__,
url_prefix="/add_meal",
template_folder="templates",
)
@bp.before_request
def login_required():
if not current_user.is_authenticated:
return redirect(url_for("login"))
@bp.route("/select_meal/<int:meal_type>", methods=["GET"])
def step1(meal_type: int):
assert type(meal_type) is int
assert 0 <= meal_type <= 3
session["meal_type"] = meal_type
return redirect(url_for("add_meal.step2"))
@bp.route("/get_barcode", methods=["GET"])
def step2():
return render_template("scan_barcode.html")
@bp.route("/step3/<barcode>", methods=["GET"])
def step3(barcode: str):
if "meal_type" not in session:
return redirect("/")
assert barcode.isdigit()
item = current_user.food_items.filter_by(barcode=barcode).first()
if item is None:
# Does not exist, add item
return redirect(url_for("add_meal.step3_alt1", barcode=barcode))
else:
session["item_id"] = item.id
return redirect(url_for("add_meal.step4"))
@bp.route("/step3_alt1/<barcode>", methods=["GET", "POST"])
def step3_alt1(barcode: str):
form = FoodItemForm()
if form.validate_on_submit():
print("[DEBUG] Valid form")
if (
current_user.food_items.filter_by(
barcode=form.barcode.data
).first()
is None
):
assert form.name.data is not None
assert form.energy.data is not None
assert form.protein.data is not None
assert form.carbs.data is not None
assert form.fat.data is not None
assert form.barcode.data is not None
db.session.add(
FoodItem(
name=form.name.data,
owner_id=current_user.id,
energy=form.energy.data,
protein=form.protein.data,
carbs=form.carbs.data,
fat=form.fat.data,
barcode=(
form.barcode.data
if form.barcode.data.isdigit()
else None
),
saturated_fat=form.saturated_fat.data,
sugar=form.sugar.data,
)
)
db.session.commit()
print("[DEBUG] New item added")
return redirect(url_for("add_meal.step3", barcode=form.barcode.data))
print("[DEBUG] Invalid form")
if barcode.isdigit():
form.barcode.data = barcode
return render_template("add_item.html", form=form)
@bp.route("/step4", methods=["GET", "POST"])
def step4():
if "item_id" not in session:
return redirect(url_for("add_meal.step2"))
form = FoodLogForm()
item = db.session.get(FoodItem, session["item_id"])
assert item
if form.validate_on_submit():
assert form.amount.data
db.session.add(
FoodLog(
food_item_id=item.id,
user_id=current_user.id,
amount=form.amount.data,
part_of_day=session["meal_type"],
)
)
db.session.commit()
session.pop("meal_type")
session.pop("item_id")
return redirect("/")
match session["meal_type"]:
case 0:
tod = "Breakfast"
case 1:
tod = "Lunch"
case 2:
tod = "Dinner"
case 3:
tod = "Snack"
case _:
tod = "Unknown"
return render_template("step4.html", tod=tod, item=item, form=form)

View File

@@ -0,0 +1,49 @@
{% extends "base.html" %}
{% block content %}
<form method="POST">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.barcode.label(class="form-label") }}
{{ form.barcode(class="form-control-plaintext", readonly=true) }}
</div>
<div class="mb-3">
{{ form.name.label(class="form-label") }}
{{ form.name(class="form-control") }}
</div>
<div class="mb-3">
{{ form.energy.label(class="form-label") }}
{{ form.energy(class="form-control") }}
</div>
<div class="mb-3">
{{ form.protein.label(class="form-label") }}
{{ form.protein(class="form-control") }}
</div>
<div class="mb-3">
{{ form.carbs.label(class="form-label") }}
{{ form.carbs(class="form-control") }}
</div>
<div class="mb-3">
{{ form.sugar.label(class="form-label") }}
{{ form.sugar(class="form-control") }}
</div>
<div class="mb-3">
{{ form.fat.label(class="form-label") }}
{{ form.fat(class="form-control") }}
</div>
<div class="mb-3">
{{ form.saturated_fat.label(class="form-label") }}
{{ form.saturated_fat(class="form-control") }}
</div>
{{ form.submit(class="btn btn-primary") }}
</form>
{% endblock%}

View File

@@ -0,0 +1,62 @@
{% extends "base.html" %}
{% block content %}
<div class="container py-5">
<div class="text-center mb-4">
<h1 class="fw-bold">Barcode Scanner</h1>
<p class="text-muted">Use your camera to scan barcodes</p>
</div>
<div class="d-flex justify-content-center mb-4">
<video id="video" class="border rounded shadow-sm" style="width: 100%; max-width: 500px;" autoplay
muted></video>
</div>
<div class="d-flex justify-content-center">
<button id="startButton" class="btn btn-primary px-4">Start Scanning</button>
<button id="stopButton" class="btn btn-danger px-4 ms-3">Stop</button>
</div>
</div>
<script type="module">
import { BrowserMultiFormatReader } from 'https://cdn.jsdelivr.net/npm/@zxing/library@0.21.3/+esm';
// constants
const codeReader = new BrowserMultiFormatReader();
const videoElement = document.getElementById('video');
// Start scanning for barcode
document.getElementById('startButton').addEventListener('click', async () => {
console.log('[DEBUG] Start button clicked')
try {
await navigator.mediaDevices.getUserMedia({ video: true });
} 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 rearCamera = devices.find(device => device.label.toLowerCase().includes('back'))
|| devices.find(device => device.label.toLowerCase().includes('rear'))
|| devices[0]; // fallback
const selectedDeviceId = rearCamera?.deviceId;
await codeReader.decodeFromVideoDevice(selectedDeviceId, videoElement, async (result, err) => {
if (result) {
// Result found, this should post the barcode
const codeText = result.getText();
const baseURL = "{{url_for('add_meal.step3', barcode='!')}}";
window.location.href = baseURL.replace("!", encodeURIComponent(codeText));
}
})
})
document.getElementById('stopButton').addEventListener('click', () => {
codeReader.reset();
});
</script>
{% endblock %}

View File

@@ -0,0 +1,23 @@
{% extends "base.html" %}
{% block content %}
<p>{{ tod }}</p>
<p>{{ item.name }}</p>
<form method="POST">
{{ form.hidden_tag() }}
<div class="mb-3">
<div class="form-control-plaintext">
{{item_id}}
</div>
</div>
<div class="mb-3">
{{ form.amount.label(class="form-label") }}
{{ form.amount(class="form-control") }}
</div>
{{ form.submit(class="btn btn-primary") }}
</form>
{% endblock%}

View File

@@ -47,21 +47,10 @@ def delete_food_item(id: int):
return redirect(url_for("user.dashboard"))
fields = [
"barcode",
"name",
"energy",
"protein",
"carbs",
"sugar",
"fat",
"saturated_fat",
]
@user_bp.route("/add_food_item/<string:barcode>", methods=["GET", "POST"])
def add_food_item(barcode):
form = FoodItemForm(barcode=barcode)
print(form)
if form.validate_on_submit():
print("[DEBUG] Valid form")
@@ -92,6 +81,7 @@ def add_food_item(barcode):
else:
print("[DEBUG] Invalid form")
if form.barcode.data:
print("1")
return render_template("add_food_item.html", form=form)
else:
return redirect("/")
@@ -217,7 +207,7 @@ def add_meal():
@user_bp.route("/", methods=["GET"])
def test():
def daily_log():
today = datetime.now(timezone.utc).date()
logs_today = current_user.food_logs.filter_by(date_=today).all()
logs = [[], [], [], []]
@@ -225,7 +215,7 @@ def test():
logs[log.part_of_day].append(log)
print(logs)
return render_template(
"test.html", date=(today.strftime("%d/%m/%y")), logs=logs
"daily_log.html", date=(today.strftime("%d/%m/%y")), logs=logs
)

View File

@@ -25,11 +25,11 @@ Food Nutritional Info
<div class="p-3 mb-2 border rounded">
<div class="d-flex justify-content-between align-items-center">
<h4 class="mb-0">Breakfast</h4>
<a href="{{url_for('user.select_meal', meal_type=0)}}" class="btn btn-sm btn-primary">Add</a>
<a href="{{url_for('add_meal.step1', meal_type=0)}}" class="btn btn-sm btn-primary">Add</a>
</div>
<div>
{% for log in logs[0] %}
<p class="p-0 mb-0">{{log.food_item.name}}</p>
<p class="p-0 mb-0">{{log.food_item.name}} - {{log.amount}}</p>
{% endfor %}
</div>
</div>
@@ -37,33 +37,33 @@ Food Nutritional Info
<div class="p-3 mb-2 border rounded">
<div class="d-flex justify-content-between align-items-center">
<h4 class="mb-0">Lunch</h4>
<a href="{{url_for('user.select_meal', meal_type=1)}}" class="btn btn-sm btn-primary">Add</a>
<a href="{{url_for('add_meal.step1', meal_type=1)}}" class="btn btn-sm btn-primary">Add</a>
</div>
<div>
{% for log in logs[1] %}
<p class="p-0 mb-0">{{log.food_item.name}}</p>
<p class="p-0 mb-0">{{log.food_item.name}} - {{log.amount}}</p>
{% endfor %}
</div>
</div>
<div class="p-3 mb-2 border rounded">
<div class="d-flex justify-content-between align-items-center">
<h4 class="mb-0">Dinner</h4>
<a href="{{url_for('user.select_meal', meal_type=2)}}" class="btn btn-sm btn-primary">Add</a>
<a href="{{url_for('add_meal.step1', meal_type=2)}}" class="btn btn-sm btn-primary">Add</a>
</div>
<div>
{% for log in logs[2] %}
<p class="p-0 mb-0">{{log.food_item.name}}</p>
<p class="p-0 mb-0">{{log.food_item.name}} - {{log.amount}}</p>
{% endfor %}
</div>
</div>
<div class="p-3 mb-2 border rounded">
<div class="d-flex justify-content-between align-items-center">
<h4 class="mb-0">Snacks</h4>
<a href="{{url_for('user.select_meal', meal_type=3)}}" class="btn btn-sm btn-primary">Add</a>
<a href="{{url_for('add_meal.step1', meal_type=3)}}" class="btn btn-sm btn-primary">Add</a>
</div>
<div>
{% for log in logs[3] %}
<p class="p-0 mb-0">{{log.food_item.name}}</p>
<p class="p-0 mb-0">{{log.food_item.name}} - {{log.amount}}</p>
{% endfor %}
</div>
</div>

View File

@@ -55,7 +55,7 @@
if (!response.ok) {
throw new Error('Network response was not OK');
}
return response.json(); // or response.text(), response.blob(), etc.
return response.json();
})
.then(data => {
const baseURL2 = "{{url_for('user.select_item', item_id='0')}}"

View File

@@ -16,17 +16,19 @@ class LoginForm(FlaskForm):
class FoodItemForm(FlaskForm):
barcode = StringField("Barcode", validators=[Optional()])
barcode = StringField("Barcode", validators=[InputRequired()])
name = StringField("Product Name", validators=[DataRequired()])
energy = IntegerField("Energy per 100g", validators=[InputRequired()])
protein = FloatField("protein per 100g", validators=[InputRequired()])
carbs = FloatField("carbs per 100g", validators=[InputRequired()])
sugar = FloatField("sugar per 100g", validators=[Optional()])
fat = FloatField("fat per 100g", validators=[InputRequired()])
saturated_fat = FloatField("saturated_fat per 100g")
saturated_fat = FloatField(
"saturated_fat per 100g", validators=[Optional()]
)
submit = SubmitField("Add Item")
class FoodLogForm(FlaskForm):
amount = IntegerField("amount of food (g/ml)")
amount = FloatField("amount of food (g/ml)", validators=[DataRequired()])
submit = SubmitField("Log Item")

View File

@@ -120,13 +120,13 @@ class FoodLog(db.Model):
)
part_of_day = db.Column(db.Integer, nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
amount = db.Column(db.Integer, nullable=False)
amount = db.Column(db.Float, nullable=False)
def __init__(
self,
food_item_id: int,
user_id: int,
amount: int,
amount: float,
part_of_day: int,
date_: Optional[date] = None,
):