(function () { 'use strict'; 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 valueOrEmpty(value) { return value === null || typeof value === 'undefined' ? '' : value; } 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 = valueOrEmpty(value); } 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 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 details = !isNaN(quantity) && !isNaN(calories) ? Math.round((calories * quantity) / 100) + ' kcal per ' + quantity + ' g' : 'Calorie: dato non disponibile'; var macroFields = [ { id:'carbs', label:'Carboidrati' }, { id:'proteins', label:'Proteine' }, { id:'fats', label:'Grassi' } ]; 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 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 !== '') metaParts.push(Math.round(item.calories_per_100g) + ' kcal / 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); 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') }); }); }); 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 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 (entryDate) entryDate.addEventListener('change', syncEntryContext); if (entryMealType) entryMealType.addEventListener('change', syncEntryContext); syncEntryContext(); function openEntryPanel(trigger, scrollToPanel) { var panelName = trigger.getAttribute('data-entry-panel'); 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)(?:&|$)/); 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', 'Intervallo stimato: ' + item.estimated_min + '-' + item.estimated_max + ' g'); 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 = item.estimated_min; number.max = item.estimated_max; number.step = '1'; number.value = item.recommended_quantity; number.setAttribute('aria-label', 'Grammi di ' + item.name); var unit = document.createElement('span'); unit.textContent = 'g'; range.addEventListener('input', function () { number.value = range.value; }); number.addEventListener('input', function () { range.value = number.value; }); control.appendChild(range); control.appendChild(number); control.appendChild(unit); 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 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 nell intervallo proposto.'; 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) }, 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); 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', 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]; }); } }());