(function () { 'use strict'; var publicMenu = document.getElementById('kaliaNav'); var publicMenuTrigger = document.querySelector('[data-bs-target="#kaliaNav"][data-bs-toggle="offcanvas"]'); if (publicMenu && publicMenuTrigger) { publicMenu.addEventListener('shown.bs.offcanvas', function () { publicMenuTrigger.setAttribute('aria-expanded', 'true'); }); publicMenu.addEventListener('hidden.bs.offcanvas', function () { publicMenuTrigger.setAttribute('aria-expanded', 'false'); }); } var flashStack = document.querySelector('[data-kalia-flash-stack]'); if (flashStack) { var positionFlashStack = function () { var header = document.querySelector('.kalia-header'); var top = header ? Math.round(header.getBoundingClientRect().bottom + 12) : 12; flashStack.style.top = Math.max(12, top) + 'px'; }; positionFlashStack(); window.addEventListener('resize', positionFlashStack); document.addEventListener('shown.bs.collapse', positionFlashStack); document.addEventListener('hidden.bs.collapse', positionFlashStack); Array.prototype.forEach.call(flashStack.querySelectorAll('[data-kalia-flash]'), function (alert) { alert.addEventListener('closed.bs.alert', function () { if (!flashStack.querySelector('[data-kalia-flash]') && flashStack.parentNode) flashStack.parentNode.removeChild(flashStack); }); window.setTimeout(function () { if (!alert.parentNode) return; var closeButton = alert.querySelector('[data-bs-dismiss="alert"]'); if (closeButton) closeButton.click(); else alert.parentNode.removeChild(alert); }, 3000); }); } function showTransientMessage(message, variant) { var stack = document.querySelector('[data-kalia-flash-stack]'); if (!stack) { stack = document.createElement('div'); stack.className = 'kalia-flash-stack'; stack.setAttribute('data-kalia-flash-stack', ''); var container = document.createElement('div'); container.className = 'container'; stack.appendChild(container); document.body.appendChild(stack); } var header = document.querySelector('.kalia-header'); stack.style.top = Math.max(12, Math.round((header ? header.getBoundingClientRect().bottom : 0) + 12)) + 'px'; var host = stack.querySelector('.container') || stack; var alert = document.createElement('div'); alert.className = 'alert alert-' + (variant === 'danger' ? 'danger' : 'success') + ' alert-dismissible fade show'; alert.setAttribute('data-kalia-flash', ''); alert.setAttribute('role', variant === 'danger' ? 'alert' : 'status'); alert.setAttribute('aria-live', variant === 'danger' ? 'assertive' : 'polite'); alert.appendChild(document.createTextNode(message)); var closeButton = document.createElement('button'); closeButton.type = 'button'; closeButton.className = 'btn-close'; closeButton.setAttribute('data-bs-dismiss', 'alert'); closeButton.setAttribute('aria-label', 'Chiudi'); alert.appendChild(closeButton); host.appendChild(alert); alert.addEventListener('closed.bs.alert', function () { if (!host.querySelector('.alert') && stack.parentNode) stack.parentNode.removeChild(stack); }); window.setTimeout(function () { if (alert.parentNode) closeButton.click(); }, 3000); } Array.prototype.forEach.call(document.querySelectorAll('[data-meal-health-form]'), function (form) { var readingPairs = [ ['glucose_at_meal_mg_dl', 'glucose_at_meal_measured_at'], ['glucose_2h_mg_dl', 'glucose_measured_at'] ]; readingPairs.forEach(function (names) { var glucose = form.querySelector('[name="' + names[0] + '"]'); var readingTime = form.querySelector('[name="' + names[1] + '"]'); if (!glucose || !readingTime) return; var syncReadingTime = function () { readingTime.required = glucose.value !== ''; }; glucose.addEventListener('input', syncReadingTime); syncReadingTime(); }); var card = form.closest('.kalia-meal-health'); var summary = card ? card.querySelector('[data-meal-health-summary]') : null; var editor = card ? card.querySelector('[data-meal-health-editor]') : null; var feedback = form.querySelector('[data-meal-health-feedback]'); var cancel = form.querySelector('[data-meal-health-cancel]'); if (!summary || !editor || !feedback || !cancel) return; var pending = false; var savedValues = []; var fields = form.querySelectorAll('input, textarea'); var rememberValues = function () { savedValues = Array.prototype.map.call(fields, function (field) { return field.value; }); }; rememberValues(); var showEditor = function (editing) { var previousTop = card.getBoundingClientRect().top; var header = document.querySelector('.kalia-header'); var visibleTop = header ? Math.max(12, header.getBoundingClientRect().bottom + 12) : 12; summary.hidden = editing; editor.hidden = !editing; summary.setAttribute('aria-expanded', editing ? 'true' : 'false'); var focusTarget = editing ? form.querySelector('input:not([type="hidden"])') : summary; if (focusTarget) focusTarget.focus({ preventScroll: true }); // Mantiene il riquadro sullo stesso pasto anche quando il modulo mobile si accorcia. var targetTop = Math.max(visibleTop, previousTop); window.scrollTo({ top: window.pageYOffset + card.getBoundingClientRect().top - targetTop, behavior: 'instant' }); }; summary.addEventListener('click', function () { showEditor(true); }); if (window.location.hash === '#' + editor.id) { var mealPanel = card.closest('[data-diary-meal-panel]'); if (mealPanel && window.bootstrap) { mealPanel.addEventListener('shown.bs.collapse', function () { showEditor(true); var glucoseField = form.querySelector('[name="glucose_2h_mg_dl"]'); if (glucoseField) glucoseField.focus(); }, { once: true }); window.bootstrap.Collapse.getOrCreateInstance(mealPanel, { toggle: false }).show(); } else { showEditor(true); } } summary.addEventListener('keydown', function (event) { if (event.key !== 'Enter' && event.key !== ' ') return; event.preventDefault(); showEditor(true); }); cancel.addEventListener('click', function () { if (pending) return; Array.prototype.forEach.call(fields, function (field, index) { field.value = savedValues[index]; }); readingPairs.forEach(function (names) { form.querySelector('[name="' + names[1] + '"]').required = form.querySelector('[name="' + names[0] + '"]').value !== ''; }); feedback.hidden = true; showEditor(false); }); form.addEventListener('submit', function (event) { if (!window.fetch || !window.FormData) return; event.preventDefault(); if (pending || !form.reportValidity()) return; var data = new FormData(form); data.append('response_format', 'json'); pending = true; form.setAttribute('aria-busy', 'true'); var controls = form.querySelectorAll('input, textarea, button'); Array.prototype.forEach.call(controls, function (control) { control.disabled = true; }); feedback.hidden = false; feedback.classList.remove('text-danger'); feedback.textContent = 'Salvataggio in corso...'; postFormData(data).then(function (response) { if (!response.success || !response.data || typeof response.data.summary_html !== 'string' || Number(response.data.meal_id) !== Number(data.get('meal_id'))) { feedback.classList.add('text-danger'); feedback.textContent = response.message || 'Salvataggio non riuscito. Riprova.'; return; } summary.innerHTML = response.data.summary_html; var meal = card.closest('.kalia-diary-meal'); if (meal) { meal.setAttribute('data-diary-has-health-content', response.data.has_health_content ? '1' : '0'); var indicator = meal.querySelector('.kalia-diary-health-indicator'); var completed = response.data.completed_fields; if (indicator && Array.isArray(completed) && completed.length === 3) { var count = completed.filter(function (value) { return value; }).length; var state = count === 3 ? 'completi' : (count > 0 ? 'parziali' : 'non registrati'); indicator.classList.remove('is-complete', 'is-partial', 'is-missing', 'is-compact'); indicator.classList.add(count === 3 ? 'is-complete' : (count > 0 ? 'is-partial' : 'is-missing')); if (count !== 1 && count !== 2) indicator.classList.add('is-compact'); indicator.title = 'Dati salute ' + state + ': ' + count + ' su 3'; indicator.setAttribute('aria-label', 'Dati salute ' + state + ': insulina ' + (completed[0] ? 'presente' : 'assente') + ', glicemia al pasto ' + (completed[1] ? 'presente' : 'assente') + ', glicemia dopo due ore ' + (completed[2] ? 'presente' : 'assente')); var badges = indicator.querySelector('.kalia-diary-diabetes-fields'); if (badges) badges.remove(); if (count === 1 || count === 2) { badges = document.createElement('span'); badges.className = 'kalia-diary-diabetes-fields'; badges.setAttribute('aria-hidden', 'true'); ['I', 'G', '2h'].forEach(function (label, index) { var badge = document.createElement('b'); badge.className = completed[index] ? 'is-complete' : 'is-missing'; badge.textContent = label; badges.appendChild(badge); }); indicator.appendChild(badges); } } } rememberValues(); feedback.hidden = true; showEditor(false); showTransientMessage(response.message || 'Dati del pasto salvati.', 'success'); }).catch(function () { feedback.classList.add('text-danger'); feedback.textContent = 'Non e stato possibile confermare il salvataggio. Controlla la connessione e riprova.'; }).then(function () { pending = false; form.removeAttribute('aria-busy'); Array.prototype.forEach.call(controls, function (control) { control.disabled = false; }); }); }); }); Array.prototype.forEach.call(document.querySelectorAll('[data-diary-entry-edit]'), function (form) { var quantityField = form.querySelector('[name="quantity"]'); var caloriesField = form.querySelector('[name="calories_kcal"]'); var caloriesManualField = form.querySelector('[name="calories_manual"]'); var nutritionPreview = form.querySelector('[data-diary-nutrition-preview]'); if (!quantityField || !caloriesField || !caloriesManualField) return; var originalQuantity = parseFloat(String(quantityField.value).replace(',', '.')); var originalCalories = parseFloat(String(caloriesField.value).replace(',', '.')); var originalNutrients = [ ['originalCarbohydrates', 'g carboidrati'], ['originalProteins', 'g proteine'], ['originalFats', 'g grassi'] ]; var updateDiaryNutritionPreview = function () { if (!nutritionPreview) return; var quantity = parseFloat(String(quantityField.value).replace(',', '.')); var ratio = !isNaN(quantity) && quantity > 0 && !isNaN(originalQuantity) && originalQuantity > 0 ? quantity / originalQuantity : null; var details = []; var calories = parseFloat(String(caloriesField.value).replace(',', '.')); if (!isNaN(calories)) details.push((Math.round(calories * 10) / 10).toFixed(1).replace('.', ',') + ' kcal'); if (ratio !== null) originalNutrients.forEach(function (nutrient) { var originalValue = parseFloat(String(form.dataset[nutrient[0]] || '').replace(',', '.')); if (!isNaN(originalValue)) details.push((originalValue * ratio).toFixed(1).replace('.', ',') + ' ' + nutrient[1]); }); nutritionPreview.textContent = details.length ? 'Valori aggiornati: ' + details.join(' · ') + '. Le calorie possono essere corrette separatamente.' : 'I valori nutrizionali saranno ricalcolati in proporzione alla quantita.'; }; quantityField.addEventListener('input', function () { var quantity = parseFloat(String(quantityField.value).replace(',', '.')); if (caloriesManualField.value !== '1' && !isNaN(originalQuantity) && originalQuantity > 0 && !isNaN(originalCalories)) { caloriesField.value = !isNaN(quantity) && quantity > 0 ? String(Math.round((originalCalories * quantity / originalQuantity) * 1000) / 1000) : ''; } updateDiaryNutritionPreview(); }); caloriesField.addEventListener('input', function () { caloriesManualField.value = '1'; updateDiaryNutritionPreview(); }); updateDiaryNutritionPreview(); }); document.addEventListener('click', function (event) { var trigger = event.target.closest('[data-premium-feature]'); if (!trigger) return; event.preventDefault(); var element = document.getElementById('kaliaPremiumFeature'); if (!element || !window.bootstrap) return; var title = document.getElementById('kaliaPremiumFeatureTitle'); var description = document.getElementById('kaliaPremiumFeatureDescription'); var recipeBenefits = document.getElementById('kaliaPremiumRecipeBenefits'); var savedRecipes = document.getElementById('kaliaPremiumSavedRecipes'); if (title) title.textContent = trigger.getAttribute('data-premium-feature') || 'Funzione Premium'; if (description) description.textContent = trigger.getAttribute('data-premium-description') || 'Sblocca questa funzione con il profilo Premium.'; if (recipeBenefits) recipeBenefits.hidden = trigger.getAttribute('data-premium-kind') !== 'recipe'; if (savedRecipes) savedRecipes.hidden = trigger.getAttribute('data-premium-kind') !== 'recipe'; window.bootstrap.Modal.getOrCreateInstance(element).show(); }); function selectAppShareLink(widget) { var field = widget.querySelector('[data-share-app-url]'); var status = widget.querySelector('[data-share-app-status]'); if (field) { field.focus(); field.select(); } if (status) status.textContent = 'Seleziona e copia il link qui sopra.'; } function copyAppShareLink(widget) { var field = widget.querySelector('[data-share-app-url]'); var status = widget.querySelector('[data-share-app-status]'); if (!field) return; if (!navigator.clipboard || !navigator.clipboard.writeText) { selectAppShareLink(widget); return; } navigator.clipboard.writeText(field.value).then(function () { if (status) status.textContent = 'Link di Kalia copiato.'; }).catch(function () { selectAppShareLink(widget); }); } document.addEventListener('click', function (event) { var button = event.target.closest('[data-share-app], [data-copy-app-link]'); if (!button) return; var widget = button.closest('[data-share-app-widget]'); var field = widget ? widget.querySelector('[data-share-app-url]') : null; if (!field) return; if (button.hasAttribute('data-copy-app-link') || !navigator.share) { copyAppShareLink(widget); return; } navigator.share({ title: 'Kalia - diario e ricette', text: 'Scopri Kalia: diario alimentare, ricette e strumenti per il tuo benessere.', url: field.value }).then(function () { var status = widget.querySelector('[data-share-app-status]'); if (status) status.textContent = 'Kalia condivisa.'; }).catch(function (error) { if (!error || error.name !== 'AbortError') copyAppShareLink(widget); }); }); if ('serviceWorker' in navigator && window.location.protocol === 'https:') { window.addEventListener('load', function () { navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(function () {}); }); } var exploreSheet = document.getElementById('kaliaExplore'); var exploreTrigger = document.querySelector('[data-bs-target="#kaliaExplore"]'); if (exploreSheet && exploreTrigger) { exploreSheet.addEventListener('shown.bs.offcanvas', function () { exploreTrigger.setAttribute('aria-expanded', 'true'); }); exploreSheet.addEventListener('hidden.bs.offcanvas', function () { exploreTrigger.setAttribute('aria-expanded', 'false'); }); window.addEventListener('resize', function () { if (window.innerWidth >= 992 && exploreSheet.classList.contains('show') && window.bootstrap) { window.bootstrap.Offcanvas.getOrCreateInstance(exploreSheet).hide(); } }); } var installWidget = document.getElementById('kalia-install-widget'); var installButton = document.getElementById('kalia-install-button'); var installDismiss = document.getElementById('kalia-install-dismiss'); var installMessage = document.getElementById('kalia-install-message'); var installInstructions = document.getElementById('kalia-install-instructions'); var installSteps = document.getElementById('kalia-install-steps'); var deferredInstallPrompt = null; var installDismissedAt = 0; var userAgent = navigator.userAgent || ''; var isIos = /iPhone|iPad|iPod/i.test(userAgent) || (/Macintosh/i.test(userAgent) && navigator.maxTouchPoints > 1); var isAndroid = /Android/i.test(userAgent); var isMobileDevice = isIos || isAndroid || /Mobile/i.test(userAgent) || !!(navigator.userAgentData && navigator.userAgentData.mobile); try { installDismissedAt = parseInt(window.localStorage.getItem('kalia_install_dismissed_at') || '0', 10); } catch (ignore) {} function installDismissalExpired() { return !installDismissedAt || Date.now() - installDismissedAt > 7 * 24 * 60 * 60 * 1000; } function isInstalledApp() { return window.matchMedia('(display-mode: standalone)').matches || window.matchMedia('(display-mode: fullscreen)').matches || navigator.standalone === true; } function hideInstallWidget() { if (installWidget) installWidget.hidden = true; } function setInstallSteps(steps) { if (!installSteps) return; installSteps.innerHTML = ''; steps.forEach(function (step) { var item = document.createElement('li'); item.textContent = step; installSteps.appendChild(item); }); } function showInstallWidget(mode) { if (!installWidget || !installButton || !installDismissalExpired() || isInstalledApp()) return; installWidget.setAttribute('data-install-mode', mode); installWidget.classList.remove('is-guided'); installInstructions.hidden = true; installButton.setAttribute('aria-expanded', 'false'); if (mode === 'native') { installMessage.textContent = isMobileDevice ? 'Tienila nella schermata Home e aprila senza cercare il sito.' : 'Aprila come un app, senza schede del browser.'; installButton.textContent = 'Installa'; setInstallSteps([]); } else if (mode === 'ios') { installMessage.textContent = 'Bastano il menu Condividi e pochi tocchi.'; installButton.textContent = 'Mostra i passaggi'; setInstallSteps([ 'Apri il menu Pagina o tocca Condividi nella barra del browser.', 'Scorri e scegli Aggiungi alla schermata Home.', 'Se disponibile, attiva Apri come app web e poi tocca Aggiungi.' ]); } else { installMessage.textContent = 'Aggiungila dalla voce di installazione del browser.'; installButton.textContent = 'Mostra i passaggi'; setInstallSteps([ 'Apri il menu del browser, di solito indicato con tre puntini.', 'Scegli Installa app oppure Aggiungi alla schermata Home.', 'Conferma il nome Kalia e tocca Installa o Aggiungi.' ]); } installWidget.hidden = false; } window.addEventListener('beforeinstallprompt', function (event) { event.preventDefault(); deferredInstallPrompt = event; showInstallWidget('native'); }); if (installButton) installButton.addEventListener('click', function () { if (!deferredInstallPrompt) { if (!installInstructions) return; var willOpen = installInstructions.hidden; installInstructions.hidden = !willOpen; installButton.setAttribute('aria-expanded', willOpen ? 'true' : 'false'); installButton.textContent = willOpen ? 'Nascondi i passaggi' : 'Mostra i passaggi'; installWidget.classList.toggle('is-guided', willOpen); return; } var installEvent = deferredInstallPrompt; deferredInstallPrompt = null; var promptResult = installEvent.prompt(); var choicePromise = promptResult && typeof promptResult.then === 'function' ? promptResult : installEvent.userChoice; if (!choicePromise || typeof choicePromise.then !== 'function') { hideInstallWidget(); return; } choicePromise.then(function (choice) { deferredInstallPrompt = null; hideInstallWidget(); if (choice && choice.outcome === 'dismissed') { installDismissedAt = Date.now(); try { window.localStorage.setItem('kalia_install_dismissed_at', String(installDismissedAt)); } catch (ignore) {} } }); }); if (installDismiss) installDismiss.addEventListener('click', function () { hideInstallWidget(); installDismissedAt = Date.now(); try { window.localStorage.setItem('kalia_install_dismissed_at', String(installDismissedAt)); } catch (ignore) {} }); window.addEventListener('appinstalled', function () { deferredInstallPrompt = null; hideInstallWidget(); }); if (!isInstalledApp() && installDismissalExpired()) { if (isIos) { showInstallWidget('ios'); } else if (isMobileDevice) { window.setTimeout(function () { if (!deferredInstallPrompt) showInstallWidget('manual'); }, 1600); } } function readJsonResponse(response) { return response.text().then(function (body) { var json = null; try { json = JSON.parse(body); } catch (ignore) {} if (!json || typeof json !== 'object') { var message = response.status === 413 ? 'La registrazione supera il limite di caricamento del server.' : 'Il server non ha completato la richiesta (HTTP ' + response.status + '). Riprova tra poco.'; return { success: false, message: message, data: {}, httpStatus: response.status }; } json.httpStatus = response.status; return json; }); } function postAction(action, fields, csrfToken) { var data = new FormData(); data.append('action', action); data.append('csrf_token', csrfToken); Object.keys(fields).forEach(function (key) { data.append(key, fields[key]); }); return fetch('/it', { method: 'POST', credentials: 'same-origin', body: data }) .then(readJsonResponse); } function postFormData(data) { return fetch('/it', { method: 'POST', credentials: 'same-origin', body: data }) .then(readJsonResponse); } function formatItalianNumber(value, decimals) { var number = Number(value); if (!isFinite(number)) number = 0; try { return number.toLocaleString('it-IT', { minimumFractionDigits: decimals, maximumFractionDigits: decimals }); } catch (ignore) { return number.toFixed(decimals).replace('.', ','); } } function updateDiarySummary(data) { if (!data || !data.summary) return; var root = document.querySelector('[data-diary-summary]'); if (!root) return; var summary = data.summary; var calories = Number(summary.food_calories_kcal) || 0; var target = Number(root.getAttribute('data-calorie-target')) || 0; var remaining = target - calories; var caloriesNode = root.querySelector('[data-diary-food-calories]'); var remainingNode = root.querySelector('[data-diary-remaining-calories]'); var progress = root.querySelector('[data-diary-calorie-progress]'); if (caloriesNode) caloriesNode.textContent = formatItalianNumber(calories, 0); if (remainingNode) remainingNode.textContent = formatItalianNumber(Math.abs(remaining), 0) + ' kcal ' + (remaining >= 0 ? 'rimanenti' : 'oltre il piano'); if (progress) { var percent = target > 0 ? Math.max(0, Math.min(100, Math.round(calories / target * 100))) : 0; progress.style.width = percent + '%'; progress.setAttribute('aria-valuenow', String(percent)); } var carbsNode = root.querySelector('[data-diary-total-carbs]'); if (carbsNode) { carbsNode.innerHTML = ''; var carbsStrong = document.createElement('strong'); carbsStrong.className = 'kalia-diary-cho'; carbsStrong.textContent = formatItalianNumber(summary.carbohydrates_g, 1) + ' g CHO'; carbsNode.appendChild(carbsStrong); carbsNode.appendChild(document.createTextNode(' assunti' + (data.has_unknown_carbs ? ' (dati parziali)' : ''))); } Array.prototype.forEach.call(root.querySelectorAll('[data-diary-macro]'), function (node) { var field = node.getAttribute('data-diary-macro'); if (Object.prototype.hasOwnProperty.call(summary, field)) node.textContent = formatItalianNumber(summary[field], 1) + ' g'; }); } function updateDeleteList(list) { if (!list) return; var items = list.querySelectorAll('[data-ajax-delete-item]'); var hasItems = items.length > 0; var hasHealthContent = list.getAttribute('data-diary-has-health-content') === '1'; var hasAccordionContent = hasItems || hasHealthContent; var empty = list.querySelector('[data-ajax-delete-empty]'); if (empty) empty.hidden = hasItems; var saveMeal = list.querySelector('[data-diary-save-meal]'); if (saveMeal) saveMeal.hidden = !hasItems; var foodStatus = list.querySelector('[data-diary-food-status]'); if (foodStatus) { foodStatus.classList.remove('is-complete', 'is-missing'); foodStatus.classList.add(hasItems ? 'is-complete' : 'is-missing'); foodStatus.setAttribute('title', hasItems ? 'Almeno un alimento registrato' : 'Nessun alimento registrato'); foodStatus.setAttribute('aria-label', hasItems ? 'Alimenti presenti' : 'Alimenti non registrati'); var foodStatusIcon = foodStatus.querySelector('[data-diary-food-status-icon]'); if (foodStatusIcon) foodStatusIcon.className = 'fa-solid ' + (hasItems ? 'fa-check' : 'fa-xmark'); } if (!hasAccordionContent) { var mealToggle = list.querySelector('.kalia-diary-meal-toggle'); if (mealToggle) { mealToggle.disabled = true; mealToggle.classList.add('is-static', 'collapsed'); mealToggle.removeAttribute('data-bs-toggle'); mealToggle.removeAttribute('data-bs-target'); mealToggle.removeAttribute('aria-expanded'); mealToggle.removeAttribute('aria-controls'); mealToggle.setAttribute('aria-disabled', 'true'); } var mealChevron = list.querySelector('.kalia-diary-meal-chevron'); if (mealChevron) mealChevron.hidden = true; var mealPanel = list.querySelector('[data-diary-meal-panel]'); if (mealPanel) { mealPanel.classList.remove('show', 'collapsing'); mealPanel.style.height = ''; mealPanel.hidden = true; } } var mealSummary = list.querySelector('[data-diary-meal-summary]'); if (!mealSummary) return; if (!hasItems) { mealSummary.textContent = 'Nessun alimento'; return; } var calories = 0; var carbs = 0; var partialCarbs = false; Array.prototype.forEach.call(items, function (item) { calories += Number(item.getAttribute('data-calories')) || 0; var carbValue = item.getAttribute('data-carbohydrates'); if (carbValue === '') partialCarbs = true; else carbs += Number(carbValue) || 0; }); mealSummary.textContent = items.length + ' ' + (items.length === 1 ? 'alimento' : 'alimenti') + ' · ' + formatItalianNumber(calories, 0) + ' kcal'; if (mealSummary.getAttribute('data-diabetes-mode') === '1') { mealSummary.appendChild(document.createTextNode(' e ')); var carbsStrong = document.createElement('strong'); carbsStrong.className = 'kalia-diary-cho'; carbsStrong.textContent = formatItalianNumber(carbs, 1) + ' g CHO'; mealSummary.appendChild(carbsStrong); if (partialCarbs) mealSummary.appendChild(document.createTextNode(' (parziali)')); } } Array.prototype.forEach.call(document.querySelectorAll('[data-ajax-delete-form]'), function (form) { var confirmation = form.getAttribute('data-delete-confirm') || 'Eliminare questo elemento?'; form.removeAttribute('onsubmit'); form.addEventListener('submit', function (event) { event.preventDefault(); if (form.getAttribute('data-busy') === '1' || !window.confirm(confirmation)) return; var item = form.closest('[data-ajax-delete-item]'); var list = item ? item.closest('[data-ajax-delete-list]') : null; var submit = form.querySelector('[type="submit"]'); var data = new FormData(form); data.append('response_format', 'json'); form.setAttribute('data-busy', '1'); if (submit) { submit.disabled = true; submit.setAttribute('aria-busy', 'true'); } postFormData(data).then(function (response) { if (!response.success) throw new Error(response.message || 'Eliminazione non riuscita.'); updateDiarySummary(response.data || {}); showTransientMessage(response.message || 'Elemento eliminato.', 'success'); if (!item) return; item.style.height = item.offsetHeight + 'px'; item.style.overflow = 'hidden'; window.requestAnimationFrame(function () { item.classList.add('is-removing'); item.style.height = '0px'; item.style.paddingTop = '0'; item.style.paddingBottom = '0'; }); window.setTimeout(function () { if (item.parentNode) item.parentNode.removeChild(item); updateDeleteList(list); }, 260); }).catch(function (error) { form.removeAttribute('data-busy'); if (submit) { submit.disabled = false; submit.removeAttribute('aria-busy'); } showTransientMessage(error && error.message ? error.message : 'Eliminazione non riuscita. Riprova.', 'danger'); }); }); }); function valueOrEmpty(value) { return value === null || typeof value === 'undefined' ? '' : value; } function trimDecimalZeros(value) { var raw = String(valueOrEmpty(value)); if (!/^-?\d+(?:[.,]\d+)?$/.test(raw)) return raw; return raw.replace(/([.,]\d*?[1-9])0+$/, '$1').replace(/[.,]0+$/, ''); } function localIsoDate() { var now = new Date(); var month = String(now.getMonth() + 1); if (month.length < 2) month = '0' + month; var day = String(now.getDate()); if (day.length < 2) day = '0' + day; return now.getFullYear() + '-' + month + '-' + day; } function setField(id, value) { var field = document.getElementById(id); if (field) field.value = field.getAttribute('inputmode') === 'decimal' ? trimDecimalZeros(value) : valueOrEmpty(value); } Array.prototype.forEach.call(document.querySelectorAll('input[inputmode="decimal"]'), function (field) { field.value = trimDecimalZeros(field.value); }); function formatCho(value) { return (Math.round(value * 10) / 10).toString().replace('.', ',') + ' g CHO'; } function applyFood(item) { setField('food_id', item.food_id); setField('external_id', item.external_id); setField('source', item.source); setField('food_name', item.name); setField('brand_name', item.brand_name); setField('calories', item.calories_per_100g); setField('carbs', item.carbohydrates_per_100g); setField('sugars', item.sugars_per_100g); setField('proteins', item.proteins_per_100g); setField('fats', item.fats_per_100g); setField('saturated_fat', item.saturated_fat_per_100g); setField('fiber', item.fiber_per_100g); setField('salt', item.salt_per_100g); if (item.quantity) setField('quantity', item.quantity); else if (item.serving_quantity) setField('quantity', item.serving_quantity); updateFoodSummary(); var panel = document.getElementById('food-entry-panel'); if (panel) panel.hidden = false; var recipePanel = document.getElementById('recipe-entry-panel'); if (recipePanel) recipePanel.hidden = true; var form = document.getElementById('food-entry-form'); if (form) form.scrollIntoView({ behavior: 'smooth', block: 'start' }); } function updateFoodSummary() { var summary = document.getElementById('food-summary'); var quantityField = document.getElementById('quantity'); if (!summary || !quantityField) return; var quantity = parseFloat(String(quantityField.value).replace(',', '.')); var caloriesField = document.getElementById('calories'); var calories = caloriesField ? parseFloat(String(caloriesField.value).replace(',', '.')) : NaN; var nameField = document.getElementById('food_name'); var name = nameField && nameField.value ? nameField.value : 'Alimento'; var carbsField = document.getElementById('carbs'); var carbs = carbsField ? parseFloat(String(carbsField.value).replace(',', '.')) : NaN; var details = 'Calorie: dato non disponibile'; if (!isNaN(quantity) && quantity > 0) { details = !isNaN(calories) ? Math.round((calories * quantity) / 100) + ' kcal' : 'Calorie n.d.'; if (diabetesMode) details += ' e ' + (!isNaN(carbs) ? formatCho((carbs * quantity) / 100) : 'CHO n.d.'); details += ' per ' + quantity + ' g'; } var macroFields = [ { id:'proteins', label:'Proteine' }, { id:'fats', label:'Grassi' } ]; if (!diabetesMode) macroFields.unshift({ id:'carbs', label:'Carboidrati' }); var macroParts = []; if (!isNaN(quantity)) { macroFields.forEach(function (macroField) { var input = document.getElementById(macroField.id); var value = input ? parseFloat(String(input.value).replace(',', '.')) : NaN; if (!isNaN(value)) macroParts.push(macroField.label + ' ' + ((value * quantity) / 100).toFixed(1).replace('.', ',') + ' g'); }); } summary.innerHTML = ''; var strong = document.createElement('strong'); strong.textContent = name; var span = document.createElement('span'); span.textContent = details; summary.appendChild(strong); summary.appendChild(span); if (macroParts.length) { var small = document.createElement('small'); small.textContent = macroParts.join(' · '); summary.appendChild(small); } } var search = document.getElementById('food-search'); var searchResults = document.getElementById('food-search-results'); var entryForm = document.getElementById('food-entry-form'); var diabetesMode = !!(entryForm && entryForm.getAttribute('data-diabetes-mode') === '1'); function updateRecipeGramSummary(form) { var summary = form.querySelector('[data-recipe-gram-summary]'); var gramsField = form.querySelector('[name="grams"]'); if (!summary || !gramsField) return; var grams = parseFloat(String(gramsField.value).replace(',', '.')); var caloriesRaw = form.getAttribute('data-calories-per-100g') || ''; var carbsRaw = form.getAttribute('data-carbohydrates-per-100g') || ''; var calories = caloriesRaw !== '' ? Number(caloriesRaw) : NaN; var carbs = carbsRaw !== '' ? Number(carbsRaw) : NaN; var detail = 'Inserisci una quantit\u00e0 valida in grammi.'; if (!isNaN(grams) && grams >= 0.1 && grams <= 10000) { detail = !isNaN(calories) ? Math.round(calories * grams / 100) + ' kcal' : 'Calorie n.d.'; if (form.getAttribute('data-diabetes-mode') === '1') detail += ' e ' + (!isNaN(carbs) ? formatCho(carbs * grams / 100) : 'CHO n.d.'); detail += ' per ' + grams + ' g'; } summary.innerHTML = ''; var title = document.createElement('strong'); title.textContent = form.getAttribute('data-recipe-name') || 'Ricetta'; var value = document.createElement('span'); value.textContent = detail; summary.appendChild(title); summary.appendChild(value); } Array.prototype.forEach.call(document.querySelectorAll('[data-recipe-grams-form]'), function (form) { form.addEventListener('input', function () { updateRecipeGramSummary(form); }); Array.prototype.forEach.call(form.querySelectorAll('[data-recipe-grams]'), function (button) { button.addEventListener('click', function () { var gramsField = form.querySelector('[name="grams"]'); if (gramsField) gramsField.value = button.getAttribute('data-recipe-grams'); updateRecipeGramSummary(form); }); }); updateRecipeGramSummary(form); }); var searchTimer = null; if (search && searchResults && entryForm) { var csrf = entryForm.querySelector('[name="csrf_token"]').value; search.addEventListener('input', function () { window.clearTimeout(searchTimer); var term = search.value.trim(); if (term.length < 3) { searchResults.innerHTML = ''; return; } searchResults.textContent = 'Ricerca in corso...'; searchTimer = window.setTimeout(function () { postAction('foods/search', { term: term }, csrf).then(function (response) { searchResults.innerHTML = ''; if (!response.success) { searchResults.textContent = response.message || 'Ricerca temporaneamente non disponibile.'; return; } if (response.message) { var notice = document.createElement('p'); notice.className = 'small text-warning-emphasis mb-2'; notice.textContent = response.message; searchResults.appendChild(notice); } var items = response.data ? response.data.items : []; if (!items || !items.length) { searchResults.textContent = 'Nessun risultato. Puoi inserire manualmente i dati disponibili.'; return; } items.forEach(function (item) { var button = document.createElement('button'); button.type = 'button'; button.className = 'kalia-search-item'; var text = document.createElement('span'); var title = document.createElement('strong'); title.textContent = item.name; var metaParts = [item.brand_name, item.source].filter(Boolean); if ((item.calories_per_100g !== null && item.calories_per_100g !== '') || diabetesMode) { var nutritionLabel = item.calories_per_100g !== null && item.calories_per_100g !== '' ? Math.round(item.calories_per_100g) + ' kcal' : 'Calorie n.d.'; if (diabetesMode) nutritionLabel += ' e ' + (item.carbohydrates_per_100g !== null && item.carbohydrates_per_100g !== '' ? formatCho(Number(item.carbohydrates_per_100g)) : 'CHO n.d.'); metaParts.push(nutritionLabel + ' / 100 g'); } if (item.nutrition_available === false) metaParts.push('valori nutrizionali non disponibili'); var meta = document.createElement('small'); meta.textContent = metaParts.join(' - '); var icon = document.createElement('i'); icon.className = 'fa-solid fa-chevron-right'; icon.setAttribute('aria-hidden','true'); text.appendChild(title); text.appendChild(meta); button.appendChild(text); button.appendChild(icon); button.addEventListener('click', function () { applyFood(item); searchResults.innerHTML = ''; }); searchResults.appendChild(button); }); }).catch(function () { searchResults.textContent = 'Ricerca temporaneamente non disponibile.'; }); }, 350); }); entryForm.addEventListener('input', updateFoodSummary); var foodHistoryMenu = document.getElementById('food-history-menu'); var foodHistorySearch = document.getElementById('food-history-search'); if (foodHistoryMenu && foodHistorySearch) { var foodHistoryButtons = foodHistoryMenu.querySelectorAll('.kalia-recent-food'); var foodHistoryCount = document.getElementById('food-history-count'); var foodHistoryEmpty = document.getElementById('food-history-empty'); function normalizeFoodHistoryText(value) { var text = String(value || '').toLowerCase(); if (text.normalize) text = text.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); return text; } function filterFoodHistory() { var terms = normalizeFoodHistoryText(foodHistorySearch.value).trim().split(/\s+/).filter(Boolean); var visible = 0; Array.prototype.forEach.call(foodHistoryButtons, function (button) { var text = normalizeFoodHistoryText(button.getAttribute('data-name') + ' ' + button.getAttribute('data-brand')); var matches = terms.every(function (term) { return text.indexOf(term) !== -1; }); button.hidden = !matches; if (matches) visible++; }); foodHistoryCount.textContent = visible + ' di ' + foodHistoryButtons.length + ' alimenti'; foodHistoryEmpty.hidden = visible > 0; } foodHistorySearch.addEventListener('input', filterFoodHistory); foodHistorySearch.addEventListener('search', filterFoodHistory); foodHistoryMenu.addEventListener('toggle', function () { if (foodHistoryMenu.open) foodHistorySearch.focus(); }); foodHistoryMenu.addEventListener('keydown', function (event) { if (event.key === 'Escape' && foodHistoryMenu.open) { foodHistoryMenu.open = false; foodHistoryMenu.querySelector('summary').focus(); } }); filterFoodHistory(); } Array.prototype.forEach.call(document.querySelectorAll('.kalia-recent-food'), function (button) { button.addEventListener('click', function () { applyFood({ food_id: button.getAttribute('data-food-id'), external_id: '', source: button.getAttribute('data-source'), name: button.getAttribute('data-name'), brand_name: button.getAttribute('data-brand'), quantity: button.getAttribute('data-quantity'), calories_per_100g: button.getAttribute('data-calories'), carbohydrates_per_100g: button.getAttribute('data-carbs'), sugars_per_100g: button.getAttribute('data-sugars'), proteins_per_100g: button.getAttribute('data-proteins'), fats_per_100g: button.getAttribute('data-fats'), saturated_fat_per_100g: button.getAttribute('data-saturated-fat'), fiber_per_100g: button.getAttribute('data-fiber'), salt_per_100g: button.getAttribute('data-salt') }); if (foodHistoryMenu && foodHistoryMenu.contains(button)) { foodHistoryMenu.open = false; var quantityField = document.getElementById('quantity'); if (quantityField) quantityField.focus({ preventScroll: true }); } }); }); Array.prototype.forEach.call(document.querySelectorAll('.kalia-portion-buttons [data-quantity]'), function (button) { button.addEventListener('click', function () { setField('quantity', button.getAttribute('data-quantity')); updateFoodSummary(); }); }); var manualReset = document.getElementById('food-manual-reset'); if (manualReset) manualReset.addEventListener('click', function () { ['food_id','external_id','food_name','brand_name','calories','carbs','sugars','proteins','fats','saturated_fat','fiber','salt'].forEach(function (id) { setField(id, ''); }); setField('source', 'manual'); setField('quantity', '100'); updateFoodSummary(); var foodName = document.getElementById('food_name'); if (foodName) foodName.focus(); }); var manualOpen = document.getElementById('food-manual-open'); if (manualOpen) manualOpen.addEventListener('click', function () { var panel = document.getElementById('food-entry-panel'); if (panel) panel.hidden = false; var foodName = document.getElementById('food_name'); if (foodName) { foodName.focus(); foodName.scrollIntoView({ behavior: 'smooth', block: 'center' }); } }); } var recipeSearch = document.getElementById('recipe-search'); var recipeSearchResults = document.getElementById('recipe-search-results'); var recipeEntryForm = document.getElementById('recipe-entry-form'); var recipeSearchTimer = null; var recipeSearchRequest = 0; function recipeDetailUrl(type, id) { var context = 'date=' + encodeURIComponent(entryDate ? entryDate.value : localIsoDate()) + '&meal=' + encodeURIComponent(entryMealType ? entryMealType.value : 'lunch'); return type === 'catalog' ? '/it/ricetta?id=' + id + '&' + context : '/it/mie-ricette?' + context + '#ricetta-' + id; } if (recipeSearch && recipeSearchResults && entryForm) { recipeSearch.addEventListener('input', function () { window.clearTimeout(recipeSearchTimer); var term = recipeSearch.value.trim(); var requestId = ++recipeSearchRequest; recipeSearchResults.innerHTML = ''; if (term.length < 3) return; recipeSearchResults.textContent = 'Ricerca in corso...'; recipeSearchTimer = window.setTimeout(function () { postAction('recipes/search', { term: term }, csrf).then(function (response) { if (requestId !== recipeSearchRequest) return; recipeSearchResults.innerHTML = ''; if (!response.success) { recipeSearchResults.textContent = response.message || 'Ricerca temporaneamente non disponibile.'; return; } var items = response.data ? response.data.items : []; if (!items || !items.length) { recipeSearchResults.textContent = 'Nessuna ricetta trovata.'; return; } items.forEach(function (item) { if (item.type !== 'catalog' && item.type !== 'personal') return; var id = parseInt(item.id, 10); if (!(id > 0)) return; var hasWeight = item.grams_per_portion !== null && Number(item.grams_per_portion) > 0; var result = document.createElement(hasWeight ? 'button' : 'a'); result.className = 'kalia-search-item'; if (hasWeight) result.type = 'button'; else { result.href = recipeDetailUrl(item.type, id); result.addEventListener('click', function () { result.href = recipeDetailUrl(item.type, id); }); } var label = document.createElement('span'); var title = document.createElement('strong'); title.textContent = item.name; var meta = document.createElement('small'); var calories = item.calories_per_100g !== null && item.calories_per_100g !== '' ? Math.round(Number(item.calories_per_100g)) + ' kcal' : 'Calorie n.d.'; if (diabetesMode) calories += ' e ' + (item.carbohydrates_per_100g !== null && item.carbohydrates_per_100g !== '' ? formatCho(Number(item.carbohydrates_per_100g)) : 'CHO n.d.'); meta.textContent = (item.type === 'personal' ? 'Ricetta personale' : 'Catalogo ricette') + ' - ' + (hasWeight ? calories + ' / 100 g' : 'peso non disponibile'); var icon = document.createElement('i'); icon.className = 'fa-solid fa-chevron-right'; icon.setAttribute('aria-hidden', 'true'); label.appendChild(title); label.appendChild(meta); result.appendChild(label); result.appendChild(icon); if (hasWeight) result.addEventListener('click', function () { if (!recipeEntryForm) return; recipeEntryForm.querySelector('[name="recipe_type"]').value = item.type; recipeEntryForm.querySelector('[name="recipe_id"]').value = id; recipeEntryForm.setAttribute('data-recipe-name', item.name); recipeEntryForm.setAttribute('data-calories-per-100g', valueOrEmpty(item.calories_per_100g)); recipeEntryForm.setAttribute('data-carbohydrates-per-100g', valueOrEmpty(item.carbohydrates_per_100g)); recipeEntryForm.querySelector('[data-recipe-selected-name]').textContent = item.name; recipeEntryForm.querySelector('[name="grams"]').value = '100'; var detailLink = document.getElementById('recipe-entry-detail-link'); if (detailLink) { detailLink.href = recipeDetailUrl(item.type, id); detailLink.hidden = false; } syncEntryContext(); updateRecipeGramSummary(recipeEntryForm); var recipePanel = document.getElementById('recipe-entry-panel'); if (recipePanel) { recipePanel.hidden = false; recipePanel.scrollIntoView({ behavior: 'smooth', block: 'start' }); } var foodPanel = document.getElementById('food-entry-panel'); if (foodPanel) foodPanel.hidden = true; }); recipeSearchResults.appendChild(result); }); }).catch(function () { if (requestId === recipeSearchRequest) recipeSearchResults.textContent = 'Ricerca temporaneamente non disponibile.'; }); }, 350); }); } var entryDate = document.getElementById('entry_date'); var entryMealType = document.getElementById('entry_meal_type'); function syncEntryContext() { setField('manual_entry_date', entryDate ? entryDate.value : localIsoDate()); setField('manual_meal_type', entryMealType ? entryMealType.value : 'other'); if (recipeEntryForm) { recipeEntryForm.querySelector('[name="date"]').value = entryDate ? entryDate.value : localIsoDate(); recipeEntryForm.querySelector('[name="meal_type"]').value = entryMealType ? entryMealType.value : 'other'; var selectedType = recipeEntryForm.querySelector('[name="recipe_type"]').value; var selectedId = parseInt(recipeEntryForm.querySelector('[name="recipe_id"]').value, 10); var selectedDetailLink = document.getElementById('recipe-entry-detail-link'); if (selectedDetailLink && selectedId > 0 && (selectedType === 'catalog' || selectedType === 'personal')) { selectedDetailLink.href = recipeDetailUrl(selectedType, selectedId); } } } if (entryDate) entryDate.addEventListener('change', syncEntryContext); if (entryMealType) entryMealType.addEventListener('change', syncEntryContext); syncEntryContext(); function openEntryPanel(trigger, scrollToPanel) { var panelName = trigger.getAttribute('data-entry-panel'); var selectedFoodPanel = document.getElementById('food-entry-panel'); var selectedRecipePanel = document.getElementById('recipe-entry-panel'); if (selectedFoodPanel) selectedFoodPanel.hidden = true; if (selectedRecipePanel) selectedRecipePanel.hidden = true; var selectedPanel = null; Array.prototype.forEach.call(document.querySelectorAll('[data-entry-panel]'), function (item) { var active = item === trigger; item.classList.toggle('is-active', active); item.setAttribute('aria-pressed', active ? 'true' : 'false'); }); Array.prototype.forEach.call(document.querySelectorAll('[data-entry-panel-content]'), function (panel) { var active = panel.getAttribute('data-entry-panel-content') === panelName; panel.hidden = !active; if (active) selectedPanel = panel; }); if (scrollToPanel && selectedPanel) window.requestAnimationFrame(function () { var scrollTarget = panelName === 'ai' ? (document.getElementById('ai-photo-picker') || selectedPanel) : selectedPanel; if (scrollTarget) scrollTarget.scrollIntoView({ behavior: 'smooth', block: 'start' }); }); } Array.prototype.forEach.call(document.querySelectorAll('[data-entry-panel]'), function (trigger) { trigger.addEventListener('click', function () { openEntryPanel(trigger, true); }); }); var requestedEntryMethod = window.location.search.match(/[?&]method=(ai|automatic|recipes)(?:&|$)/); if (requestedEntryMethod) { var requestedEntryTrigger = document.querySelector('[data-entry-panel="' + requestedEntryMethod[1] + '"]'); if (requestedEntryTrigger) openEntryPanel(requestedEntryTrigger, true); } var automaticForm = document.getElementById('automatic-entry-form'); var voiceStart = document.getElementById('voice-record-start'); var voiceStop = document.getElementById('voice-record-stop'); var voiceStatus = document.getElementById('voice-record-status'); var voicePreview = document.getElementById('voice-record-preview'); var voiceRecorder = null; var voiceStream = null; var voiceChunks = []; var voiceBlob = null; var automaticEntryPending = false; function stopVoiceTracks() { if (voiceStream && voiceStream.getTracks) voiceStream.getTracks().forEach(function (track) { track.stop(); }); voiceStream = null; } function preferredVoiceMimeType() { if (!window.MediaRecorder || typeof window.MediaRecorder.isTypeSupported !== 'function') return ''; var supportedTypes = ['audio/webm;codecs=opus', 'audio/mp4', 'audio/webm', 'audio/ogg;codecs=opus', 'audio/ogg']; for (var index = 0; index < supportedTypes.length; index++) { if (window.MediaRecorder.isTypeSupported(supportedTypes[index])) return supportedTypes[index]; } return ''; } function voiceUploadName(blob) { var mimeType = blob && blob.type ? blob.type.toLowerCase() : ''; if (mimeType.indexOf('mp4') !== -1) return 'pasto.m4a'; if (mimeType.indexOf('ogg') !== -1) return 'pasto.ogg'; if (mimeType.indexOf('wav') !== -1) return 'pasto.wav'; if (mimeType.indexOf('mpeg') !== -1 || mimeType.indexOf('mp3') !== -1) return 'pasto.mp3'; return 'pasto.webm'; } function blobArrayBuffer(blob) { if (blob && typeof blob.arrayBuffer === 'function') return blob.arrayBuffer(); return new Promise(function (resolve, reject) { var reader = new FileReader(); reader.onload = function () { resolve(reader.result); }; reader.onerror = function () { reject(reader.error); }; reader.readAsArrayBuffer(blob); }); } function writeWavText(view, offset, text) { for (var index = 0; index < text.length; index++) view.setUint8(offset + index, text.charCodeAt(index)); } function encodeVoiceWav(audioBuffer) { var sampleCount = audioBuffer.length; var channelCount = audioBuffer.numberOfChannels; var output = new ArrayBuffer(44 + sampleCount * 2); var view = new DataView(output); writeWavText(view, 0, 'RIFF'); view.setUint32(4, 36 + sampleCount * 2, true); writeWavText(view, 8, 'WAVE'); writeWavText(view, 12, 'fmt '); view.setUint32(16, 16, true); view.setUint16(20, 1, true); view.setUint16(22, 1, true); view.setUint32(24, audioBuffer.sampleRate, true); view.setUint32(28, audioBuffer.sampleRate * 2, true); view.setUint16(32, 2, true); view.setUint16(34, 16, true); writeWavText(view, 36, 'data'); view.setUint32(40, sampleCount * 2, true); var channels = []; for (var channel = 0; channel < channelCount; channel++) channels.push(audioBuffer.getChannelData(channel)); for (var sampleIndex = 0; sampleIndex < sampleCount; sampleIndex++) { var sample = 0; for (var channelIndex = 0; channelIndex < channelCount; channelIndex++) sample += channels[channelIndex][sampleIndex]; sample = Math.max(-1, Math.min(1, sample / channelCount)); view.setInt16(44 + sampleIndex * 2, sample < 0 ? sample * 32768 : sample * 32767, true); } return new Blob([output], { type: 'audio/wav' }); } function prepareVoiceBlob(recordedBlob) { var AudioContextClass = window.AudioContext || window.webkitAudioContext; if (!AudioContextClass || recordedBlob.type.indexOf('wav') !== -1) return Promise.resolve(recordedBlob); var audioContext = new AudioContextClass(); return blobArrayBuffer(recordedBlob).then(function (buffer) { return new Promise(function (resolve, reject) { audioContext.decodeAudioData(buffer.slice(0), resolve, reject); }); }).then(function (decodedAudio) { if (typeof audioContext.close === 'function') audioContext.close(); return encodeVoiceWav(decodedAudio); }).catch(function () { if (typeof audioContext.close === 'function') audioContext.close(); return recordedBlob; }); } if (voiceStart) voiceStart.addEventListener('click', function () { if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia || typeof window.MediaRecorder === 'undefined') { if (voiceStatus) voiceStatus.textContent = 'La registrazione non e supportata da questo browser. Usa il testo.'; return; } navigator.mediaDevices.getUserMedia({ audio: true }).then(function (stream) { voiceStream = stream; voiceChunks = []; voiceBlob = null; var mimeType = preferredVoiceMimeType(); try { voiceRecorder = mimeType ? new window.MediaRecorder(stream, { mimeType: mimeType }) : new window.MediaRecorder(stream); } catch (error) { stopVoiceTracks(); if (voiceStatus) voiceStatus.textContent = 'Il browser non riesce ad avviare la registrazione. Usa il testo.'; return; } voiceRecorder.addEventListener('dataavailable', function (event) { if (event.data && event.data.size) voiceChunks.push(event.data); }); voiceRecorder.addEventListener('stop', function () { var recordedMimeType = voiceRecorder && voiceRecorder.mimeType ? voiceRecorder.mimeType : (mimeType || 'audio/webm'); var recordedBlob = new Blob(voiceChunks, { type: recordedMimeType }); stopVoiceTracks(); if (voiceStatus) voiceStatus.textContent = 'Preparazione della registrazione...'; prepareVoiceBlob(recordedBlob).then(function (preparedBlob) { voiceBlob = preparedBlob; if (voicePreview) { voicePreview.src = window.URL.createObjectURL(voiceBlob); voicePreview.hidden = false; } if (voiceStatus) voiceStatus.textContent = 'Registrazione pronta. Premi Interpreta e aggiungi.'; }); }); voiceRecorder.start(); voiceStart.hidden = true; if (voiceStop) voiceStop.hidden = false; if (voiceStatus) voiceStatus.textContent = 'Registrazione in corso...'; }).catch(function () { if (voiceStatus) voiceStatus.textContent = 'Microfono non disponibile. Controlla il permesso o usa il testo.'; }); }); if (voiceStop) voiceStop.addEventListener('click', function () { if (voiceRecorder && voiceRecorder.state !== 'inactive') voiceRecorder.stop(); voiceStop.hidden = true; if (voiceStart) voiceStart.hidden = false; }); if (automaticForm) automaticForm.addEventListener('submit', function (event) { event.preventDefault(); if (automaticEntryPending) return; var textField = document.getElementById('automatic-meal-text'); var result = document.getElementById('automatic-entry-result'); var submit = document.getElementById('automatic-entry-submit'); var textValue = textField ? textField.value.trim() : ''; if (!textValue && !voiceBlob) { if (result) result.textContent = 'Scrivi una descrizione o registra un messaggio vocale.'; return; } automaticEntryPending = true; var data = new FormData(); data.append('action', 'foods/automatic-entry'); data.append('csrf_token', automaticForm.querySelector('[name="csrf_token"]').value); data.append('_type_', automaticForm.querySelector('[name="_type_"]').value); data.append('date', entryDate ? entryDate.value : localIsoDate()); data.append('meal_type', entryMealType ? entryMealType.value : 'other'); data.append('input_kind', voiceBlob && !textValue ? 'voice' : 'text'); data.append('meal_text', textValue); if (voiceBlob && !textValue) data.append('meal_audio', voiceBlob, voiceUploadName(voiceBlob)); if (submit) submit.disabled = true; if (result) result.textContent = ''; showAiLoading(voiceBlob && !textValue ? 'Sto ascoltando la registrazione' : 'Sto interpretando il pasto', 'Riconosco gli alimenti e preparo gli intervalli di peso.'); postFormData(data).then(function (response) { if (response.success && response.data && response.data.analysis_required) { var continuation = new FormData(); continuation.append('action', 'foods/automatic-entry'); continuation.append('csrf_token', automaticForm.querySelector('[name="csrf_token"]').value); continuation.append('_type_', automaticForm.querySelector('[name="_type_"]').value); continuation.append('date', entryDate ? entryDate.value : localIsoDate()); continuation.append('meal_type', entryMealType ? entryMealType.value : 'other'); continuation.append('input_kind', 'text'); continuation.append('meal_text', response.data.transcription || ''); continuation.append('analysis_id', response.data.analysis_id || '0'); setAiLoadingContent('Registrazione trascritta', 'Ora riconosco gli alimenti e preparo gli intervalli di peso.'); return postFormData(continuation); } return response; }).then(function (response) { if (!response.success) { if (result) result.textContent = response.message || 'Interpretazione non riuscita.'; return; } renderAiEvaluation(response.data, automaticForm.querySelector('[name="csrf_token"]').value, result); var quotaLabel = document.getElementById('ai-quota-label'); if (quotaLabel && response.data.quota && !response.data.quota.unlimited) quotaLabel.textContent = response.data.quota.remaining + ' valutazioni disponibili'; }).catch(function () { if (result) result.textContent = 'Connessione interrotta. Controlla la rete e riprova.'; }) .then(function () { automaticEntryPending = false; if (submit) submit.disabled = false; hideAiLoading(function () { if (result) result.scrollIntoView({ behavior: 'smooth', block: 'start' }); }); }); }); var aiForm = document.getElementById('ai-evaluation-form'); var aiResults = document.getElementById('ai-evaluation-results'); var aiImageInputs = document.querySelectorAll('[data-ai-image-input]'); var aiPhotoPreview = document.getElementById('ai-photo-preview'); var aiPhotoPreviewImage = document.getElementById('ai-photo-preview-image'); var aiPhotoFileName = document.getElementById('ai-photo-file-name'); var aiPhotoRemove = document.getElementById('ai-photo-remove'); var aiEvaluationSubmit = document.getElementById('ai-evaluation-submit'); var aiLoadingElement = document.getElementById('ai-evaluation-loading'); var aiLoadingTitle = document.getElementById('ai-evaluation-loading-title'); var aiLoadingDescription = document.getElementById('ai-evaluation-loading-description'); var aiLoadingModal = aiLoadingElement && window.bootstrap && window.bootstrap.Modal ? window.bootstrap.Modal.getOrCreateInstance(aiLoadingElement) : null; var aiSelectedImage = null; var aiPreviewUrl = ''; var aiEvaluationPending = false; function submitAiEvaluation() { if (!aiForm || aiEvaluationPending) return; if (typeof aiForm.requestSubmit === 'function') { aiForm.requestSubmit(); return; } var submitEvent = document.createEvent('Event'); submitEvent.initEvent('submit', true, true); aiForm.dispatchEvent(submitEvent); } function setAiLoadingContent(title, description) { if (aiLoadingTitle && title) aiLoadingTitle.textContent = title; if (aiLoadingDescription && description) aiLoadingDescription.textContent = description; } function showAiLoading(title, description) { setAiLoadingContent(title, description); if (aiLoadingModal) aiLoadingModal.show(); else if (aiLoadingElement) aiLoadingElement.hidden = false; } function hideAiLoading(callback) { if (!aiLoadingModal || !aiLoadingElement) { if (aiLoadingElement) aiLoadingElement.hidden = true; if (callback) callback(); return; } var completed = false; var complete = function () { if (completed) return; completed = true; aiLoadingElement.removeEventListener('hidden.bs.modal', complete); if (callback) callback(); }; aiLoadingElement.addEventListener('hidden.bs.modal', complete); aiLoadingModal.hide(); window.setTimeout(complete, 500); } function clearAiImage() { aiSelectedImage = null; Array.prototype.forEach.call(aiImageInputs, function (input) { input.value = ''; }); if (aiPreviewUrl) window.URL.revokeObjectURL(aiPreviewUrl); aiPreviewUrl = ''; if (aiPhotoPreviewImage) aiPhotoPreviewImage.removeAttribute('src'); if (aiPhotoFileName) aiPhotoFileName.textContent = ''; if (aiPhotoPreview) aiPhotoPreview.hidden = true; if (aiEvaluationSubmit) aiEvaluationSubmit.hidden = true; } Array.prototype.forEach.call(aiImageInputs, function (input) { input.addEventListener('change', function () { if (!input.files || !input.files.length) return; aiSelectedImage = input.files[0]; Array.prototype.forEach.call(aiImageInputs, function (otherInput) { if (otherInput !== input) otherInput.value = ''; }); if (aiPreviewUrl) window.URL.revokeObjectURL(aiPreviewUrl); aiPreviewUrl = window.URL.createObjectURL(aiSelectedImage); if (aiPhotoPreviewImage) aiPhotoPreviewImage.src = aiPreviewUrl; if (aiPhotoFileName) aiPhotoFileName.textContent = aiSelectedImage.name; if (aiPhotoPreview) aiPhotoPreview.hidden = false; if (aiResults) aiResults.textContent = ''; if (aiEvaluationSubmit) aiEvaluationSubmit.hidden = true; window.setTimeout(submitAiEvaluation, 0); }); }); if (aiPhotoRemove) aiPhotoRemove.addEventListener('click', clearAiImage); function appendAiText(parent, tagName, className, value) { var element = document.createElement(tagName); element.className = className || ''; element.textContent = value; parent.appendChild(element); return element; } function renderAiEvaluation(data, csrfToken, resultsContainer) { resultsContainer = resultsContainer || aiResults; if (!resultsContainer) return; resultsContainer.innerHTML = ''; if (data.summary) appendAiText(resultsContainer, 'p', 'fw-semibold mb-1', data.summary); if (data.reliability_note) appendAiText(resultsContainer, 'p', 'small text-secondary mb-3', data.reliability_note); var controls = []; (data.items || []).forEach(function (item) { var card = document.createElement('div'); card.className = 'kalia-ai-food'; var heading = document.createElement('div'); appendAiText(heading, 'strong', '', item.name); appendAiText(heading, 'small', 'text-secondary', 'Stima AI: ' + item.estimated_min + '-' + item.estimated_max + ' g. Inserisci il peso reale anche se e fuori dalla stima.'); var control = document.createElement('div'); control.className = 'kalia-ai-weight'; var range = document.createElement('input'); range.type = 'range'; range.min = item.estimated_min; range.max = item.estimated_max; range.step = '1'; range.value = item.recommended_quantity; range.setAttribute('aria-label', 'Peso di ' + item.name); var number = document.createElement('input'); number.type = 'number'; number.className = 'form-control'; number.min = '0.1'; number.max = '5000'; number.step = '0.1'; number.value = item.recommended_quantity; number.setAttribute('aria-label', 'Grammi effettivi di ' + item.name); var unit = document.createElement('span'); unit.textContent = 'g'; control.appendChild(range); control.appendChild(number); control.appendChild(unit); var nutrition = appendAiText(heading, 'small', 'text-secondary kalia-ai-food-nutrition', ''); var updateNutrition = function () { var quantity = parseFloat(number.value); var details = []; var nutrients = [ ['calories_per_100g', 'kcal', 0], ['carbohydrates_per_100g', 'g carboidrati', 1], ['proteins_per_100g', 'g proteine', 1], ['fats_per_100g', 'g grassi', 1] ]; if (!isNaN(quantity) && quantity > 0) nutrients.forEach(function (nutrient) { var value = parseFloat(item[nutrient[0]]); if (!isNaN(value)) details.push(((value * quantity) / 100).toFixed(nutrient[2]).replace('.', ',') + ' ' + nutrient[1]); }); nutrition.textContent = details.length ? 'Valori per ' + String(number.value).replace('.', ',') + ' g: ' + details.join(' · ') : 'Valori nutrizionali non disponibili.'; }; range.addEventListener('input', function () { number.value = range.value; updateNutrition(); }); number.addEventListener('input', function () { var value = parseFloat(number.value); if (!isNaN(value) && value >= parseFloat(range.min) && value <= parseFloat(range.max)) range.value = number.value; updateNutrition(); }); updateNutrition(); var remove = document.createElement('button'); remove.type = 'button'; remove.className = 'btn btn-outline-danger kalia-delete-button kalia-ai-food-remove'; remove.setAttribute('aria-label', 'Rimuovi ' + item.name + ' dall\'analisi'); remove.setAttribute('title', 'Rimuovi'); var removeIcon = document.createElement('i'); removeIcon.className = 'fa-solid fa-trash-can'; removeIcon.setAttribute('aria-hidden', 'true'); remove.appendChild(removeIcon); var controlEntry = { id: item.analysis_item_id, range: range, number: number, removed: false }; remove.addEventListener('click', function () { controlEntry.removed = true; card.remove(); var remaining = controls.filter(function (entry) { return !entry.removed; }).length; button.disabled = remaining === 0; feedback.textContent = remaining === 0 ? 'Mantieni almeno una voce per aggiungere il pasto.' : item.name + ' rimosso dall\'analisi.'; }); card.appendChild(heading); card.appendChild(control); card.appendChild(remove); resultsContainer.appendChild(card); controls.push(controlEntry); }); var insulinField = null; if (diabetesMode) { var insulinWrap = document.createElement('div'); insulinWrap.className = 'mt-3'; var insulinLabel = document.createElement('label'); insulinLabel.className = 'form-label'; insulinLabel.textContent = 'Insulina utilizzata (U) - facoltativa'; insulinField = document.createElement('input'); insulinField.type = 'number'; insulinField.className = 'form-control'; insulinField.min = '0'; insulinField.max = '1000'; insulinField.step = '0.01'; insulinField.inputMode = 'decimal'; insulinField.id = resultsContainer.id + '-insulin'; insulinLabel.htmlFor = insulinField.id; insulinWrap.appendChild(insulinLabel); insulinWrap.appendChild(insulinField); resultsContainer.appendChild(insulinWrap); } var button = document.createElement('button'); button.type = 'button'; button.className = 'btn btn-kalia mt-3'; button.textContent = 'Conferma i pesi e aggiungi'; button.disabled = controls.length === 0; resultsContainer.appendChild(button); var feedback = appendAiText(resultsContainer, 'div', 'small mt-2', ''); button.addEventListener('click', function () { var items = []; for (var index = 0; index < controls.length; index++) { if (controls[index].removed) continue; var value = parseFloat(controls[index].number.value); if (isNaN(value) || value < parseFloat(controls[index].number.min) || value > parseFloat(controls[index].number.max)) { feedback.textContent = 'Controlla che ogni peso sia compreso tra 0,1 e 5000 grammi.'; return; } items.push({ id: controls[index].id, quantity: value }); } button.disabled = true; feedback.textContent = 'Salvataggio in corso...'; postAction('foods/ai-confirm', { analysis_id: data.analysis_id, date: entryDate ? entryDate.value : localIsoDate(), meal_type: entryMealType ? entryMealType.value : 'other', items: JSON.stringify(items), insulin_units: insulinField ? insulinField.value : '' }, csrfToken).then(function (response) { if (!response.success) { feedback.textContent = response.message || 'Salvataggio non riuscito.'; button.disabled = false; return; } if (response.data && response.data.redirect_url) window.location.href = response.data.redirect_url; }).catch(function () { feedback.textContent = 'Servizio temporaneamente non disponibile.'; button.disabled = false; }); }); } if (aiForm && aiResults) aiForm.addEventListener('submit', function (event) { event.preventDefault(); if (!aiSelectedImage) { aiResults.textContent = 'Scatta una foto o scegline una dalla galleria.'; return; } if (aiEvaluationPending) return; aiEvaluationPending = true; var evaluationFailed = false; var csrfToken = aiForm.querySelector('[name="csrf_token"]').value; var data = new FormData(); data.append('action', 'foods/ai-evaluate'); data.append('csrf_token', csrfToken); data.append('_type_', aiForm.querySelector('[name="_type_"]').value); data.append('meal_image', aiSelectedImage); if (aiEvaluationSubmit) { aiEvaluationSubmit.disabled = true; aiEvaluationSubmit.hidden = true; } aiResults.textContent = ''; showAiLoading('Sto valutando la foto', 'Riconosco gli alimenti e preparo gli intervalli di peso.'); postFormData(data).then(function (response) { if (!response.success) { evaluationFailed = true; aiResults.textContent = response.message || 'Valutazione non riuscita.'; return; } renderAiEvaluation(response.data, csrfToken); var quotaLabel = document.getElementById('ai-quota-label'); if (quotaLabel && response.data.quota && !response.data.quota.unlimited) quotaLabel.textContent = response.data.quota.remaining + ' valutazioni disponibili'; }).catch(function () { evaluationFailed = true; aiResults.textContent = 'Servizio temporaneamente non disponibile.'; }) .then(function () { aiEvaluationPending = false; if (aiEvaluationSubmit) { aiEvaluationSubmit.disabled = false; aiEvaluationSubmit.hidden = !evaluationFailed; } hideAiLoading(function () { aiResults.scrollIntoView({ behavior: 'smooth', block: 'start' }); }); }); }); function hidden(name, value) { var input = document.createElement('input'); input.type = 'hidden'; input.name = name; input.value = valueOrEmpty(value); return input; } var barcodeForm = document.getElementById('barcode-form'); var barcodeResult = document.getElementById('barcode-result'); if (barcodeForm && barcodeResult) { var barcodeInput = document.getElementById('barcode'); var barcodeSubmit = document.getElementById('barcode-submit'); var barcodeCameraStart = document.getElementById('barcode-camera-start'); var barcodeCameraStop = document.getElementById('barcode-camera-stop'); var barcodeCameraPreview = document.getElementById('barcode-camera-preview'); var barcodeCameraStatus = document.getElementById('barcode-camera-status'); var barcodeVideo = document.getElementById('barcode-video'); var barcodeScannerControls = null; var barcodeScannerStarting = false; var barcodeScannerActive = false; var zxingPromise = null; function barcodeSourceLabel(source) { if (source === 'open_food_facts') return 'Open Food Facts'; if (source === 'local' || source === 'custom') return 'Catalogo Kalia'; return source || 'Catalogo alimentare'; } function renderBarcodeItem(item, csrf) { var card = document.createElement('div'); card.className = 'card bg-light border-0'; var body = document.createElement('div'); body.className = 'card-body p-4'; var title = document.createElement('h2'); title.className = 'h5'; title.textContent = item.name; var meta = document.createElement('p'); meta.className = 'text-secondary'; meta.textContent = [item.brand_name, barcodeSourceLabel(item.source)].filter(Boolean).join(' - '); body.appendChild(title); body.appendChild(meta); if (item.nutrition_available === false) { var warning = document.createElement('p'); warning.className = 'alert alert-warning py-2 small'; warning.textContent = 'Il prodotto esiste, ma il catalogo non contiene ancora calorie e macronutrienti completi.'; body.appendChild(warning); } var form = document.createElement('form'); form.method = 'post'; form.action = '/it'; form.className = 'row g-3'; var fields = { action:'diary/add-entry', csrf_token:csrf, food_id:item.food_id, external_id:item.external_id, source:item.source, food_name:item.name, brand_name:item.brand_name, date:localIsoDate(), unit:'g', calories_per_100g:item.calories_per_100g, carbohydrates_per_100g:item.carbohydrates_per_100g, sugars_per_100g:item.sugars_per_100g, proteins_per_100g:item.proteins_per_100g, fats_per_100g:item.fats_per_100g, saturated_fat_per_100g:item.saturated_fat_per_100g, fiber_per_100g:item.fiber_per_100g, salt_per_100g:item.salt_per_100g }; Object.keys(fields).forEach(function (key) { form.appendChild(hidden(key, fields[key])); }); var quantityWrap = document.createElement('div'); quantityWrap.className = 'col-md-5'; var quantity = document.createElement('input'); quantity.className = 'form-control'; quantity.name = 'quantity'; quantity.inputMode = 'decimal'; quantity.required = true; quantity.value = item.serving_quantity || 100; quantity.setAttribute('aria-label','Quantita in grammi'); quantityWrap.appendChild(quantity); var mealWrap = document.createElement('div'); mealWrap.className = 'col-md-4'; var meal = document.createElement('select'); meal.className = 'form-select'; meal.name = 'meal_type'; [['breakfast','Colazione'],['morning_snack','Spuntino mattutino'],['lunch','Pranzo'],['afternoon_snack','Spuntino pomeridiano'],['dinner','Cena'],['other','Altro']].forEach(function (entry) { var option=document.createElement('option'); option.value=entry[0]; option.textContent=entry[1]; meal.appendChild(option); }); mealWrap.appendChild(meal); if (barcodeResult.getAttribute('data-diabetes-mode') === '1') { var insulinWrap = document.createElement('div'); insulinWrap.className = 'col-md-5'; var insulinLabel = document.createElement('label'); insulinLabel.className = 'form-label'; insulinLabel.htmlFor = 'barcode-insulin'; insulinLabel.textContent = 'Insulina utilizzata (U) - facoltativa'; var insulin = document.createElement('input'); insulin.id = 'barcode-insulin'; insulin.name = 'insulin_units'; insulin.type = 'number'; insulin.className = 'form-control'; insulin.min = '0'; insulin.max = '1000'; insulin.step = '0.01'; insulin.inputMode = 'decimal'; insulinWrap.appendChild(insulinLabel); insulinWrap.appendChild(insulin); form.appendChild(insulinWrap); } var submitWrap = document.createElement('div'); submitWrap.className = 'col-md-3'; var submit = document.createElement('button'); submit.type = 'submit'; submit.className = 'btn btn-kalia w-100'; submit.textContent = 'Aggiungi'; submitWrap.appendChild(submit); form.appendChild(quantityWrap); form.appendChild(mealWrap); form.appendChild(submitWrap); body.appendChild(form); card.appendChild(body); barcodeResult.appendChild(card); } function lookupBarcode(code) { if (!/^[0-9]{8,14}$/.test(code)) { barcodeResult.textContent = 'Inserisci un codice numerico da 8 a 14 cifre.'; return Promise.resolve(); } barcodeResult.textContent = 'Ricerca in corso...'; var csrf = document.getElementById('barcode-csrf').value; if (barcodeSubmit) barcodeSubmit.disabled = true; return postAction('foods/barcode', { barcode: code }, csrf).then(function (response) { barcodeResult.innerHTML = ''; if (!response.success || !response.data || !response.data.item) { barcodeResult.textContent = response.message || 'Prodotto non trovato.'; return; } renderBarcodeItem(response.data.item, csrf); }).catch(function () { barcodeResult.textContent = 'Servizio temporaneamente non disponibile.'; }).then(function () { if (barcodeSubmit) barcodeSubmit.disabled = false; }); } function setBarcodeCameraStatus(message) { if (barcodeCameraStatus) barcodeCameraStatus.textContent = message; } function stopBarcodeScanner(message) { barcodeScannerStarting = false; barcodeScannerActive = false; if (barcodeScannerControls && typeof barcodeScannerControls.stop === 'function') { try { barcodeScannerControls.stop(); } catch (ignore) {} } barcodeScannerControls = null; if (barcodeVideo && barcodeVideo.srcObject && barcodeVideo.srcObject.getTracks) { barcodeVideo.srcObject.getTracks().forEach(function (track) { track.stop(); }); barcodeVideo.srcObject = null; } if (barcodeCameraPreview) barcodeCameraPreview.classList.add('d-none'); if (barcodeCameraStart) { barcodeCameraStart.hidden = false; barcodeCameraStart.disabled = false; } if (barcodeCameraStop) barcodeCameraStop.hidden = true; if (message) setBarcodeCameraStatus(message); } function barcodeCameraErrorMessage(error) { var name = error && error.name ? error.name : ''; if (name === 'NotAllowedError' || name === 'PermissionDeniedError') return 'Permesso fotocamera negato. Abilitalo nelle impostazioni del browser oppure inserisci il codice manualmente.'; if (name === 'NotFoundError' || name === 'DevicesNotFoundError') return 'Nessuna fotocamera disponibile su questo dispositivo.'; if (name === 'NotReadableError' || name === 'TrackStartError') return 'La fotocamera e gia in uso da un\'altra applicazione.'; if (name === 'SecurityError') return 'Per usare la fotocamera apri Kalia tramite una connessione HTTPS.'; return 'Non e stato possibile avviare la fotocamera. Puoi inserire il codice manualmente.'; } function loadBarcodeScannerLibrary() { if (window.ZXingBrowser) return Promise.resolve(window.ZXingBrowser); if (zxingPromise) return zxingPromise; zxingPromise = new Promise(function (resolve, reject) { var script = document.createElement('script'); script.src = '/assets/vendors/zxing/zxing-browser.min.js'; script.async = true; script.onload = function () { if (window.ZXingBrowser) resolve(window.ZXingBrowser); else { zxingPromise = null; reject(new Error('ZXing non disponibile')); } }; script.onerror = function () { zxingPromise = null; reject(new Error('Caricamento ZXing fallito')); }; document.head.appendChild(script); }); return zxingPromise; } function startBarcodeScanner() { if (barcodeScannerStarting || barcodeScannerActive) return; if (window.isSecureContext === false && window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1') { setBarcodeCameraStatus('Per usare la fotocamera apri Kalia tramite una connessione HTTPS.'); return; } if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { setBarcodeCameraStatus('Questo browser non consente l\'accesso alla fotocamera. Inserisci il codice manualmente.'); return; } barcodeScannerStarting = true; if (barcodeCameraStart) barcodeCameraStart.disabled = true; setBarcodeCameraStatus('Attendo il permesso per usare la fotocamera...'); loadBarcodeScannerLibrary().then(function (ZXingBrowser) { if (!barcodeScannerStarting) return null; var reader = new ZXingBrowser.BrowserMultiFormatOneDReader(); barcodeScannerStarting = false; barcodeScannerActive = true; if (barcodeCameraPreview) barcodeCameraPreview.classList.remove('d-none'); if (barcodeCameraStart) barcodeCameraStart.hidden = true; if (barcodeCameraStop) barcodeCameraStop.hidden = false; setBarcodeCameraStatus('Inquadra il barcode dentro al riquadro.'); return reader.decodeFromConstraints({ audio: false, video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 }, height: { ideal: 720 } } }, barcodeVideo, function (result, error, controls) { if (!result || !barcodeScannerActive) return; var code = typeof result.getText === 'function' ? result.getText() : result.text; code = String(code || '').replace(/\s/g, ''); if (!/^[0-9]{8,14}$/.test(code)) { setBarcodeCameraStatus('Codice rilevato ma non valido. Prova a centrare meglio le cifre del barcode.'); return; } barcodeScannerActive = false; if (controls && typeof controls.stop === 'function') controls.stop(); if (barcodeInput) barcodeInput.value = code; stopBarcodeScanner('Barcode rilevato: ' + code + '. Ricerca del prodotto in corso...'); lookupBarcode(code); }); }).then(function (controls) { if (!controls) return; if (!barcodeScannerActive) { controls.stop(); return; } barcodeScannerControls = controls; }).catch(function (error) { stopBarcodeScanner(barcodeCameraErrorMessage(error)); }); } barcodeForm.addEventListener('submit', function (event) { event.preventDefault(); lookupBarcode(barcodeInput.value.trim()); }); if (barcodeCameraStart) barcodeCameraStart.addEventListener('click', startBarcodeScanner); if (barcodeCameraStop) barcodeCameraStop.addEventListener('click', function () { stopBarcodeScanner('Scansione interrotta. Puoi riprovare o inserire il codice manualmente.'); }); window.addEventListener('pagehide', function () { stopBarcodeScanner(); }); document.addEventListener('visibilitychange', function () { if (document.hidden && barcodeScannerActive) stopBarcodeScanner('Scansione interrotta quando hai lasciato la pagina.'); }); } var measurementType = document.getElementById('measurement_type'); var measurementUnit = document.getElementById('unit'); if (measurementType && measurementUnit && measurementType.closest('form') && measurementType.closest('form').querySelector('[name="action"][value="measurements/save"]')) { var units = { weight:'kg', hba1c:'%', blood_glucose:'mg/dL', insulin_bolus:'U', basal_insulin:'U', waist:'cm', body_fat:'%' }; measurementType.addEventListener('change', function () { measurementUnit.value = units[measurementType.value] || ''; }); } var activityType = document.getElementById('activity_type'); var customActivityWrap = document.getElementById('custom_label_wrap'); var customActivityLabel = document.getElementById('custom_label'); if (activityType && customActivityWrap && customActivityLabel) { var updateCustomActivity = function () { var isCustom = activityType.value === 'other'; customActivityWrap.hidden = !isCustom; customActivityLabel.disabled = !isCustom; customActivityLabel.required = isCustom; customActivityLabel.setAttribute('aria-required', isCustom ? 'true' : 'false'); if (!isCustom) customActivityLabel.value = ''; }; activityType.addEventListener('change', updateCustomActivity); updateCustomActivity(); } var goalType = document.getElementById('goal_type'); var goalUnit = document.getElementById('goal_unit'); if (goalType && goalUnit) { var goalUnits = { weight:'kg', daily_calories:'kcal', carbohydrates:'g', proteins:'g', fats:'g', fiber:'g', water:'ml', hba1c:'%' }; goalType.addEventListener('change', function () { if (goalUnits[goalType.value]) goalUnit.value = goalUnits[goalType.value]; }); } var profileDiabetesSwitch = document.getElementById('diabetes_mode'); var basalInsulinField = document.getElementById('basal-insulin-field'); var basalInsulinInput = document.getElementById('ins_basale'); var diabetesPreferencesForm = document.getElementById('profile-diabetes-form'); var diabetesPreferencesSaving = false; if (profileDiabetesSwitch && basalInsulinField && basalInsulinInput) { var updateBasalInsulinVisibility = function () { basalInsulinField.hidden = !profileDiabetesSwitch.checked; basalInsulinInput.disabled = !profileDiabetesSwitch.checked || diabetesPreferencesSaving; profileDiabetesSwitch.setAttribute('aria-expanded', profileDiabetesSwitch.checked ? 'true' : 'false'); }; profileDiabetesSwitch.addEventListener('change', updateBasalInsulinVisibility); updateBasalInsulinVisibility(); if (diabetesPreferencesForm) { var savedDiabetesMode = profileDiabetesSwitch.checked; var savedBasalInsulin = basalInsulinInput.value; var diabetesPreferencesStatus = document.getElementById('diabetes-preferences-status'); var diabetesCardStatus = document.querySelector('.kalia-profile-card.is-diabetes .kalia-profile-card-state'); var glucoseReminderField = document.getElementById('push-glucose-field'); var glucoseReminderInput = document.getElementById('push-glucose'); var updateGlucoseReminderVisibility = function () { if (glucoseReminderField) glucoseReminderField.hidden = !savedDiabetesMode; if (glucoseReminderInput) glucoseReminderInput.disabled = !savedDiabetesMode; }; updateGlucoseReminderVisibility(); var setDiabetesPreferencesStatus = function (message, failed) { diabetesPreferencesStatus.textContent = message; diabetesPreferencesStatus.classList.toggle('text-danger', !!failed); }; var saveDiabetesPreference = function (field) { if (diabetesPreferencesSaving) return; if (field === 'ins_basale') { if (!basalInsulinInput.checkValidity()) { setDiabetesPreferencesStatus('Indica un valore tra 0,01 e 200 U/giorno, oppure lascia il campo vuoto.', true); return; } if (basalInsulinInput.value === savedBasalInsulin) return; } var data = new FormData(); data.append('action', diabetesPreferencesForm.elements.action.value); data.append('csrf_token', diabetesPreferencesForm.elements.csrf_token.value); data.append('field', field); data.append(field, field === 'diabetes_mode' ? (profileDiabetesSwitch.checked ? '1' : '0') : basalInsulinInput.value); diabetesPreferencesSaving = true; profileDiabetesSwitch.disabled = true; diabetesPreferencesForm.setAttribute('aria-busy', 'true'); updateBasalInsulinVisibility(); setDiabetesPreferencesStatus('Salvataggio in corso...', false); var finishDiabetesSave = function () { diabetesPreferencesSaving = false; profileDiabetesSwitch.disabled = false; diabetesPreferencesForm.setAttribute('aria-busy', 'false'); updateBasalInsulinVisibility(); }; // Un errore o una connessione interrotta ripristina gli ultimi valori confermati. var failDiabetesSave = function (message) { profileDiabetesSwitch.checked = savedDiabetesMode; basalInsulinInput.value = savedBasalInsulin; finishDiabetesSave(); setDiabetesPreferencesStatus(message, true); }; var requestFinished = false; var saveTimeout = window.setTimeout(function () { requestFinished = true; failDiabetesSave('Il server non risponde. Riprova tra poco.'); }, 20000); postFormData(data).then(function (response) { window.clearTimeout(saveTimeout); // Ignora risposte tardive dopo il timeout, anche se nel frattempo si riprova. if (requestFinished) return; requestFinished = true; if (!response.success || !response.data || typeof response.data.diabetes_mode !== 'boolean' || typeof response.data.ins_basale !== 'string') { failDiabetesSave(response.message || 'Salvataggio non riuscito. Riprova.'); return; } savedDiabetesMode = response.data.diabetes_mode; savedBasalInsulin = response.data.ins_basale; updateGlucoseReminderVisibility(); profileDiabetesSwitch.checked = savedDiabetesMode; basalInsulinInput.value = savedBasalInsulin; if (diabetesCardStatus) { diabetesCardStatus.textContent = savedDiabetesMode ? 'Attiva' : 'Non attiva'; diabetesCardStatus.classList.toggle('is-active', savedDiabetesMode); } finishDiabetesSave(); setDiabetesPreferencesStatus('Preferenze salvate.', false); }).catch(function () { window.clearTimeout(saveTimeout); if (!requestFinished) { requestFinished = true; failDiabetesSave('Connessione non disponibile. Riprova.'); } }); }; profileDiabetesSwitch.addEventListener('change', function () { saveDiabetesPreference('diabetes_mode'); }); basalInsulinInput.addEventListener('change', function () { saveDiabetesPreference('ins_basale'); }); diabetesPreferencesForm.addEventListener('submit', function (event) { event.preventDefault(); if (profileDiabetesSwitch.checked) saveDiabetesPreference('ins_basale'); }); } } }());