mirror of
https://github.com/StefBuwalda/cal_counter.git
synced 2025-10-29 19:00:00 +00:00
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:
2
app.py
2
app.py
@@ -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
|
||||
|
||||
131
application/add_meal/routes.py
Normal file
131
application/add_meal/routes.py
Normal 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)
|
||||
49
application/add_meal/templates/add_item.html
Normal file
49
application/add_meal/templates/add_item.html
Normal 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%}
|
||||
62
application/add_meal/templates/scan_barcode.html
Normal file
62
application/add_meal/templates/scan_barcode.html
Normal 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 %}
|
||||
23
application/add_meal/templates/step4.html
Normal file
23
application/add_meal/templates/step4.html
Normal 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%}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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')}}"
|
||||
|
||||
8
forms.py
8
forms.py
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user