Add meal v2 flow and user timezone support

Introduces a new add_meal_v2 blueprint with barcode scanning, item search, and improved meal logging UI. Adds user timezone support: login form now captures timezone, User model and database schema updated, and timezone is set on login. Refactors templates and forms to support these changes, and removes the old login template.
This commit is contained in:
2025-08-14 06:08:17 +02:00
parent 7ff345d3a2
commit d78f48710e
14 changed files with 503 additions and 43 deletions

2
app.py
View File

@@ -13,6 +13,7 @@ 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 application.auth.routes import bp as auth_bp
from application.add_meal_v2.routes import bp as add_meal_v2_bp
from typing import Optional
# Config
@@ -31,6 +32,7 @@ app.register_blueprint(admin_bp)
app.register_blueprint(user_bp)
app.register_blueprint(add_meal_bp)
app.register_blueprint(auth_bp)
app.register_blueprint(add_meal_v2_bp)
# Routes

View File

@@ -0,0 +1,187 @@
from flask import (
Blueprint,
redirect,
url_for,
render_template,
session,
request,
jsonify,
abort,
)
from flask_login import current_user
from forms import FoodItemForm, FoodLogForm
from application import db
from models import FoodItem, FoodLog
from sqlalchemy import and_, or_
from datetime import datetime, timedelta, timezone
from sqlalchemy.sql.elements import BinaryExpression
from typing import cast
bp = Blueprint(
"add_meal_v2",
__name__,
url_prefix="/add_meal_v2",
template_folder="templates",
)
@bp.before_request
def login_required():
if not current_user.is_authenticated:
return redirect(url_for("auth.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/<string:input>", methods=["GET"])
def step3(input: str):
# check if meal_type cookie is set
if "meal_type" not in session:
return redirect("/")
# Check if input is a barcode
if input.isdigit():
item = current_user.food_items.filter_by(barcode=input).first()
else:
item = current_user.food_items.filter_by(name=input).first()
if item is None:
# Does not exist, add item
return redirect(url_for("add_meal.step3_alt1", input=input))
# Track item to add and continue to next step
session["item_id"] = item.id
return redirect(url_for("add_meal.step4"))
@bp.route("/step3_alt1/<string:input>", methods=["GET"])
def step3_alt1(input: str):
form = FoodItemForm()
if input.isdigit():
form.barcode.data = input
else:
form.name.data = input
return render_template("add_item.html", form=form)
@bp.route("/step3_alt1/<string:input>", methods=["POST"])
def step3_alt1_post(input: str):
form = FoodItemForm()
if form.validate_on_submit():
# Form has valid input
barcode = form.barcode.data
name = form.name.data
assert name
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
# Check if name or barcode already exists
name_filter = cast(BinaryExpression, FoodItem.name == name)
barcode_filter = cast(BinaryExpression, FoodItem.barcode == barcode)
filter_exp = or_(name_filter, barcode_filter)
item = current_user.food_items.filter(filter_exp).first()
if item is None:
# Item does not exist, add to DB
barcode = (
barcode if barcode else None
) # Turn empty strings into None
db.session.add(
FoodItem(
name=name,
owner_id=current_user.id,
energy=form.energy.data,
protein=form.protein.data,
carbs=form.carbs.data,
fat=form.fat.data,
barcode=barcode,
saturated_fat=form.saturated_fat.data,
sugar=form.sugar.data,
)
)
db.session.commit()
print("[DEBUG] New FoodItem Added")
input = barcode if barcode else name # update input
else:
print(f"Item exists: {item.barcode} {item.name}")
# Item added or already present, return to step 3.
return redirect(url_for("add_meal.step3", input=input))
else:
print("[DEBUG] Form Invalid")
return redirect(url_for("add_meal.step3_alt1", input=input))
@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"])
offset = session["offset"]
if offset is None or item is None:
abort(404)
today = datetime.now(timezone.utc).date()
day = today + timedelta(days=offset)
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"],
date_=day,
)
)
db.session.commit()
session.pop("meal_type")
session.pop("item_id")
return redirect(url_for("user.daily_log", offset=offset))
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)
@bp.route("/query", methods=["GET"])
def query():
q = request.args.get("q", "").strip().lower()
if not q:
return jsonify([])
words = q.split()
filters = [
FoodItem.name.ilike(f"%{word}%") for word in words # type: ignore
]
results = current_user.food_items.filter(and_(*filters)).all()
return jsonify([item.name for item in results])

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.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>
<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.protein.label(class="form-label") }}
{{ form.protein(class="form-control") }}
</div>
{{ form.submit(class="btn btn-primary") }}
</form>
{% endblock%}

View File

@@ -0,0 +1,134 @@
{% extends "base.html" %}
{% block content %}
<div class="container py-5">
<!-- Header -->
<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>
<!-- Video preview -->
<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>
<!-- Start/Stop buttons -->
<div class="d-flex justify-content-center gap-3 mb-4">
<button id="startButton" class="btn btn-primary px-4">Start Scanning</button>
<button id="stopButton" class="btn btn-danger px-4">Stop</button>
</div>
<!-- Search box and suggestions -->
<div class="d-flex justify-content-center mt-4">
<div class="w-100 position-relative" style="max-width: 500px;">
<!-- Input group (search + go button) -->
<div class="input-group">
<input type="text" id="search-box" class="form-control" placeholder="Search..." autocomplete="off">
<button id="go-button" class="btn btn-success">Go</button>
</div>
<!-- Suggestions -->
<ul id="suggestions" class="list-group position-absolute w-100 mt-1" style="z-index: 1000;"></ul>
</div>
</div>
<!-- Suggestions list -->
<div class="d-flex justify-content-center">
<ul id="suggestions" class="list-group position-absolute mt-1 w-100" style="max-width: 500px; z-index: 1000;">
</ul>
</div>
</div>
<script>
const searchBox = document.getElementById('search-box');
const suggestionsBox = document.getElementById('suggestions');
const goButton = document.getElementById('go-button');
searchBox.addEventListener('input', function () {
const query = searchBox.value;
if (query.length < 2) {
suggestionsBox.innerHTML = '';
return;
}
fetch(`{{url_for("add_meal.query")}}?q=${encodeURIComponent(query)}`)
.then(response => response.json())
.then(data => {
suggestionsBox.innerHTML = '';
data.forEach(item => {
const li = document.createElement('li');
li.textContent = item;
// Apply Bootstrap classes
li.classList.add('list-group-item', 'list-group-item-action', 'cursor-pointer');
// Add click behavior
li.addEventListener('click', () => {
searchBox.value = item;
suggestionsBox.innerHTML = '';
});
// Add to suggestions box
suggestionsBox.appendChild(li);
});
});
});
// ✅ Redirect when the "Go" button is clicked
goButton.addEventListener('click', () => {
const value = searchBox.value.trim();
if (value) {
const baseURL = "{{url_for('add_meal.step3', input='!')}}";
window.location.href = baseURL.replace("!", encodeURIComponent(value));
}
});
</script>
<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', input='!')}}";
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

@@ -1,8 +1,8 @@
from flask import Blueprint, request, render_template, redirect, url_for
from flask import Blueprint, render_template, redirect, url_for
from flask_login import current_user, login_user, logout_user
from forms import LoginForm, ChangePasswordForm
from models import User
from application.utils import default_return
from application.utils import default_return, is_valid_timezone
from application import db
bp = Blueprint(
@@ -19,12 +19,16 @@ def login():
form = LoginForm()
if form.validate_on_submit():
assert form.timezone.data
user = User.query.filter_by(username=form.username.data).first()
if user and user.check_password(password=form.password.data):
# User found and password correct
next_page = request.args.get("next") # Get next page if given
tz = form.timezone.data
if is_valid_timezone(tz):
user.set_timezone(tz)
db.session.commit()
login_user(user) # Log in the user
return default_return(next_page=next_page)
return default_return()
else:
pass
# invalid user

View File

@@ -3,7 +3,10 @@
{% block content %}
<div class="container d-flex justify-content-center align-items-center">
<div class="card shadow-sm p-4" style="width: 100%; max-width: 400px;">
<h3 class="mb-4 text-center">Login</h3>
<h3 class="mb-1 text-center">Login</h3>
<p class="text-center text-muted small mb-4">
Your timezone will be saved to show times correctly.
</p>
<form method="post">
{{ form.hidden_tag() }}
@@ -27,10 +30,21 @@
{% endif %}
</div>
{{ form.timezone(id="timezone") }}
<div class="d-grid">
{{ form.submit(class="btn btn-primary btn-lg") }}
</div>
</form>
</div>
</div>
{% endblock%}
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', () => {
const tzField = document.getElementById('timezone');
tzField.value = Intl.DateTimeFormat().resolvedOptions().timeZone;
});
</script>
{% endblock %}

View File

@@ -83,6 +83,9 @@
</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
{% block scripts %}
{% endblock %}
</body>
</html>

View File

@@ -1,36 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="container d-flex justify-content-center align-items-center">
<div class="card shadow-sm p-4" style="width: 100%; max-width: 400px;">
<h3 class="mb-4 text-center">Login</h3>
<form method="post" novalidate>
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.username.label(class="form-label") }}
{{ form.username(class="form-control", placeholder="Enter username") }}
{% if form.username.errors %}
<div class="text-danger small">
{{ form.username.errors[0] }}
</div>
{% endif %}
</div>
<div class="mb-3">
{{ form.password.label(class="form-label") }}
{{ form.password(class="form-control", placeholder="Enter password") }}
{% if form.password.errors %}
<div class="text-danger small">
{{ form.password.errors[0] }}
</div>
{% endif %}
</div>
<div class="d-grid">
{{ form.submit(class="btn btn-primary btn-lg") }}
</div>
</form>
</div>
</div>
{% endblock%}

View File

@@ -52,7 +52,7 @@
</a>
<!-- Center Button (highlighted) -->
<a href="{{ url_for('add_meal.step1', meal_type=0) }}"
<a id="set_link_date" href="{{ url_for('add_meal.step1', meal_type=0) }}"
class="btn btn-success flex-fill mx-2 fw-bold rounded-pill">
Add Item
</a>
@@ -64,4 +64,21 @@
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function formatToday() {
const today = new Date();
const yyyy = today.getFullYear();
const mm = String(today.getMonth() + 1).padStart(2, '0');
const dd = String(today.getDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
}
const link = document.getElementById('set_link_date');
const todayString = formatToday();
// Set href on page load
link.href = `/select_date?date=${todayString}`;
</script>
{% endblock %}

View File

@@ -1,6 +1,7 @@
from flask_login import current_user
from flask import redirect, url_for, flash
from typing import Optional
from zoneinfo import ZoneInfo
def login_required():
@@ -19,3 +20,12 @@ def default_return(next_page: Optional[str] = None):
if current_user.is_admin:
return redirect(url_for("admin.food_items"))
return redirect(url_for("dashboard"))
def is_valid_timezone(tz: str) -> bool:
try:
ZoneInfo(tz)
except Exception:
print(Exception)
return False
return True

View File

@@ -4,13 +4,20 @@ from wtforms import (
PasswordField,
SubmitField,
FloatField,
HiddenField,
)
from wtforms.validators import DataRequired, InputRequired, Optional
class SelectDateForm(FlaskForm):
date = HiddenField(validators=[DataRequired()])
submit = SubmitField(" Add Item")
class LoginForm(FlaskForm):
username = StringField("Username", validators=[DataRequired()])
password = PasswordField("Password", validators=[DataRequired()])
timezone = HiddenField("Timezone", validators=[DataRequired()])
submit = SubmitField("Log in")

View File

@@ -0,0 +1,40 @@
"""empty message
Revision ID: 9eb23abd5294
Revises: 101002a6ef17
Create Date: 2025-08-14 05:40:27.342711
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "9eb23abd5294"
down_revision = "101002a6ef17"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("user", schema=None) as batch_op:
batch_op.add_column(
sa.Column(
"timezone",
sa.String(length=64),
nullable=False,
server_default="UTC",
)
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("user", schema=None) as batch_op:
batch_op.drop_column("timezone")
# ### end Alembic commands ###

View File

@@ -4,6 +4,7 @@ from application import db
from typing import Optional
from forms import FoodItemForm
from datetime import datetime, timezone, date
from application.utils import is_valid_timezone
class User(UserMixin, db.Model):
@@ -11,6 +12,7 @@ class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(150), unique=True, nullable=False)
password = db.Column(db.String, nullable=False)
timezone = db.Column(db.String(64), nullable=False, default="UTC")
is_admin = db.Column(db.Boolean, nullable=False, default=False)
must_change_password = db.Column(db.Boolean, nullable=False, default=False)
@@ -39,6 +41,10 @@ class User(UserMixin, db.Model):
def set_pw_change(self, change: bool) -> None:
self.must_change_password = change
def set_timezone(self, tz: str) -> None:
if is_valid_timezone(tz):
self.timezone = tz
class Unit(db.Model):
__tablename__ = "unit"