diff --git a/CHANGELOG.md b/CHANGELOG.md
index d7aec84..bb341d5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Recipe scraps tips** — During cooking steps, detect "waste" generated (peels, cores, bones, eggshells, coffee grounds, citrus zest, etc.) and surface AI-powered tips on how to reuse them (compost, natural cleaner, broth, candied peel, etc.). Could be shown as an optional collapsible hint card below the step that generates the scrap.
+## [1.7.35] - 2026-06-02
+
+### Fixed
+- **Barcode scanner accepts invalid codes** — Manual barcode input with an incorrect EAN checksum now blocks the lookup and shows an error (previously showed a warning but proceeded anyway). The native `BarcodeDetector` path now also validates EAN-8/EAN-13/UPC checksum before confirming a scan, consistent with the Quagga fallback which already did this check.
+- **Recipe persons +/− buttons stopped working in the generation dialog** — A duplicate `adjustRecipePersons` function added for the post-generation rescaler was overriding the one that updated the persons input in the recipe setup dialog. The rescaler is now named `scaleRecipePersons` to avoid the conflict.
+
+## [1.7.34] - 2026-05-30
+
+### Added
+- **AI visual barcode fallback** — When the barcode scanner fails to read a barcode within 5 seconds, EverShelf can now automatically capture a camera frame and send it to Gemini Vision to visually identify the product (name, brand, category). On success the product is saved and the inventory form opens just as if a barcode had been scanned. A new toggle in **Settings → Camera** (`AI visual identification (5s fallback)`) lets users enable or disable this feature at any time. Requires Gemini API key configured. Disabled by default.
+
## [1.7.33] - 2026-05-29
### Fixed
diff --git a/api/index.php b/api/index.php
index f28a7c4..775f626 100644
--- a/api/index.php
+++ b/api/index.php
@@ -604,7 +604,7 @@ function checkRateLimit(string $action): void {
}
// Determine limit based on action
- $aiActions = ['gemini_readExpiry', 'gemini_chat', 'gemini_identify', 'gemini_suggest_shopping', 'chat_to_recipe', 'recipe_from_ingredient', 'gemini_number_ocr'];
+ $aiActions = ['gemini_readExpiry', 'gemini_chat', 'gemini_identify', 'gemini_suggest_shopping', 'chat_to_recipe', 'recipe_from_ingredient', 'gemini_number_ocr', 'gemini_barcode_visual'];
$loginActions = [];
$recipeActions = ['generate_recipe', 'generate_recipe_stream'];
$errorActions = ['report_error', 'check_update'];
@@ -1109,6 +1109,10 @@ try {
geminiNumberOCR();
break;
+ case 'gemini_barcode_visual':
+ geminiBarcodeVisual();
+ break;
+
case 'get_shopping_price':
getShoppingPrice($db);
break;
@@ -4353,6 +4357,7 @@ function getServerSettings(): void {
'shopping_forecast' => env('SHOPPING_FORECAST', 'true') === 'true',
'shopping_auto_add_threshold' => (int)env('SHOPPING_AUTO_ADD_THRESHOLD', '0'),
'dark_mode' => env('DARK_MODE', 'auto'),
+ 'barcode_ai_fallback' => env('BARCODE_AI_FALLBACK', 'false') === 'true',
// Home Assistant Integration
'ha_enabled' => env('HA_ENABLED', 'false') === 'true',
'ha_url' => env('HA_URL', ''),
@@ -4455,6 +4460,7 @@ function saveSettings(): void {
'shopping_enabled' => 'SHOPPING_ENABLED',
'shopping_smart_suggestions' => 'SHOPPING_SMART_SUGGESTIONS',
'shopping_forecast' => 'SHOPPING_FORECAST',
+ 'barcode_ai_fallback' => 'BARCODE_AI_FALLBACK',
// Home Assistant
'ha_enabled' => 'HA_ENABLED',
];
@@ -10494,6 +10500,101 @@ function geminiNumberOCR(): void {
}
}
+// =============================================================================
+// ===== GEMINI AI: BARCODE VISUAL FALLBACK ====================================
+// =============================================================================
+/**
+ * POST /api/?action=gemini_barcode_visual
+ * Body: { image: base64-jpeg, lang: 'it'|'en'|'de'|... }
+ * Returns: { found, source, product } or { found: false, error }
+ * Uses Gemini vision to visually identify a product from a camera frame
+ * when the barcode scanner fails to read the barcode after 5 seconds.
+ */
+function geminiBarcodeVisual(): void {
+ EverLog::info('geminiBarcodeVisual');
+ $apiKey = env('GEMINI_API_KEY');
+ if (empty($apiKey)) {
+ echo json_encode(['found' => false, 'error' => 'no_api_key']);
+ return;
+ }
+
+ $input = json_decode(file_get_contents('php://input'), true) ?? [];
+ $imageBase64 = $input['image'] ?? '';
+ $lang = $input['lang'] ?? 'it';
+ if (empty($imageBase64)) {
+ echo json_encode(['found' => false, 'error' => 'no_image']);
+ return;
+ }
+
+ $langNote = match($lang) {
+ 'de' => 'Use the German product name if known.',
+ 'fr' => 'Use the French product name if known.',
+ 'es' => 'Use the Spanish product name if known.',
+ default => 'Use the Italian product name if known.',
+ };
+
+ $payload = [
+ 'contents' => [[
+ 'parts' => [
+ ['text' => "Identify the product shown in this image. {$langNote}\n" .
+ "Respond with ONLY valid JSON (no markdown, no backticks):\n" .
+ "{\"name\":\"...\",\"brand\":\"...\",\"category\":\"...\"}\n" .
+ "- name: the product name (as specific as possible, not just the brand)\n" .
+ "- brand: the brand/manufacturer, or empty string if not visible\n" .
+ "- category: one of: latticini, pasta, bevande, snack, carne, pesce, " .
+ "frutta, verdura, surgelati, condimenti, conserve, cereali, pane, " .
+ "igiene, pulizia, altro\n" .
+ "If you cannot identify the product at all, respond with: {\"unknown\":true}"],
+ ['inline_data' => ['mime_type' => 'image/jpeg', 'data' => $imageBase64]],
+ ],
+ ]],
+ 'generationConfig' => [
+ 'temperature' => 0,
+ 'maxOutputTokens' => 200,
+ 'responseMimeType' => 'application/json',
+ 'thinkingConfig' => ['thinkingBudget' => 0],
+ ],
+ ];
+
+ $result = callGeminiWithFallback($apiKey, $payload, 15, 'barcode_visual');
+ if ($result['http_code'] !== 200) {
+ echo json_encode(['found' => false, 'error' => 'gemini_error_' . $result['http_code']]);
+ return;
+ }
+
+ $text = trim($result['data']['candidates'][0]['content']['parts'][0]['text'] ?? '');
+ // Strip accidental markdown fences
+ $text = preg_replace('/^```json\s*/i', '', $text);
+ $text = preg_replace('/\s*```$/i', '', trim($text));
+
+ $data = json_decode($text, true);
+ if (!$data || !empty($data['unknown']) || empty($data['name'])) {
+ echo json_encode(['found' => false]);
+ return;
+ }
+
+ echo json_encode([
+ 'found' => true,
+ 'source' => 'gemini_visual',
+ 'product' => [
+ 'name' => $data['name'] ?? '',
+ 'brand' => $data['brand'] ?? '',
+ 'category' => $data['category'] ?? '',
+ 'image_url' => '',
+ 'quantity_info' => '',
+ 'nutriscore' => '',
+ 'ingredients' => '',
+ 'allergens' => '',
+ 'conservation' => '',
+ 'origin' => '',
+ 'nova_group' => '',
+ 'ecoscore' => '',
+ 'labels' => '',
+ 'stores' => '',
+ ],
+ ], JSON_UNESCAPED_UNICODE);
+}
+
// =============================================================================
// ===== GEMINI AI: ANOMALY EXPLANATION =======================================
// =============================================================================
diff --git a/assets/js/app.js b/assets/js/app.js
index 34d03d3..c0749ca 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -1943,6 +1943,8 @@ let quaggaRunning = false;
let aiStream = null;
let _scanZoomLevel = 2; // always 2x
let _torchActive = false;
+let _aiFallbackTimer = null;
+let _aiFallbackExhausted = false; // true after one failed AI visual attempt — reset when scanner is closed
// Apply fixed 2x zoom (hardware if available, CSS fallback)
async function _applyFixedZoom() {
@@ -2083,13 +2085,15 @@ function _showScanConfirm(name) {
// ===== AI NUMBER OCR (Gemini reads printed barcode digits) =====
let _numOcrRunning = false;
-async function _tryGeminiNumberOCR() {
+async function _tryGeminiNumberOCR(options = {}) {
+ const { chainToVisual = false } = options;
if (_numOcrRunning || !_requireGemini()) return;
const video = document.getElementById('scanner-video');
if (!video || !video.videoWidth) { showToast(t('error.camera'), 'error'); return; }
_numOcrRunning = true;
const btn = document.getElementById('scan-num-ocr-btn');
if (btn) { btn.disabled = true; btn.textContent = t('scan.num_ocr_searching'); }
+ _setScanStatus(t('scan.status_ocr_searching'), 'retry', t('scan.method_ai_ocr'));
try {
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
@@ -2098,12 +2102,28 @@ async function _tryGeminiNumberOCR() {
const imageBase64 = canvas.toDataURL('image/jpeg', 0.88).split(',')[1];
const result = await api('gemini_number_ocr', {}, 'POST', { image: imageBase64 });
if (result.barcode) {
+ scanLog(`AI OCR: found barcode ${result.barcode}`);
showToast(t('scan.num_ocr_found').replace('{code}', result.barcode), 'success');
onBarcodeDetected(result.barcode);
} else {
- showToast(t('scan.num_ocr_not_found'), 'warning');
+ scanLog('AI OCR: barcode digits not found');
+ if (chainToVisual && scannerStream && !_aiFallbackExhausted) {
+ scanLog('AI OCR failed — switching to visual product identification');
+ _setScanStatus(t('scan.status_ai_visual_searching'), 'retry', t('scan.method_ai_vision'));
+ await _tryGeminiVisualBarcode();
+ } else {
+ showToast(t('scan.num_ocr_not_found'), 'warning');
+ _setScanStatus(t('scan.status_scanning'), '', '');
+ }
}
} catch(e) {
+ scanLog(`AI OCR error: ${e.message}`);
+ if (chainToVisual && scannerStream && !_aiFallbackExhausted) {
+ _setScanStatus(t('scan.status_ai_visual_searching'), 'retry', t('scan.method_ai_vision'));
+ await _tryGeminiVisualBarcode();
+ } else {
+ _setScanStatus(t('scan.status_scanning'), '', '');
+ }
showToast(t('error.connection'), 'error');
} finally {
_numOcrRunning = false;
@@ -2111,6 +2131,225 @@ async function _tryGeminiNumberOCR() {
}
}
+// ===== AI VISUAL PRODUCT IDENTIFICATION (auto-fallback after 5s) =====
+let _aiBarcodeVisualRunning = false;
+let _aiDetectedProductDraft = null;
+let _aiInventoryCandidates = [];
+
+function _showScanAiOverlay(msg) {
+ const el = document.getElementById('scan-ai-overlay');
+ const msgEl = document.getElementById('scan-ai-overlay-msg');
+ if (el) el.style.display = 'flex';
+ if (msgEl) msgEl.textContent = msg || '';
+}
+function _hideScanAiOverlay() {
+ const el = document.getElementById('scan-ai-overlay');
+ if (el) el.style.display = 'none';
+}
+function _showAiRetryButton() {
+ const btn = document.getElementById('scan-ai-retry-btn');
+ if (btn) btn.style.display = '';
+}
+function _clearAiMatchPanel() {
+ const result = document.getElementById('scan-result');
+ if (!result) return;
+ result.style.display = 'none';
+ result.innerHTML = '';
+ _aiDetectedProductDraft = null;
+ _aiInventoryCandidates = [];
+}
+function _renderAiCandidateRow(item, idx) {
+ const catIcon = CATEGORY_ICONS[mapToLocalCategory(item.category, item.name)] || '📦';
+ const qty = (item.total_qty !== null && item.total_qty !== undefined)
+ ? `${parseFloat(item.total_qty)} ${item.unit || ''}`.trim()
+ : '';
+ return `
+
+ `;
+}
+function _showAiMatchChoices(aiProduct, candidates) {
+ const result = document.getElementById('scan-result');
+ if (!result) return;
+ const aiName = aiProduct?.name || t('product.not_recognized');
+ const aiBrand = aiProduct?.brand || '';
+ const catIcon = CATEGORY_ICONS[mapToLocalCategory(aiProduct?.category || '', aiName)] || '📦';
+ const itemsHtml = (candidates || []).slice(0, 3).map((it, i) => _renderAiCandidateRow(it, i)).join('');
+
+ result.innerHTML = `
+
+
+
${t('scan.ai_match_title')}
+
${t('scan.ai_match_subtitle')}
+
+
+ ${itemsHtml ? `
+
+
${t('scan.ai_match_existing')}
+
${itemsHtml}
+
+ ` : `
+
${t('scan.ai_match_none')}
+ `}
+
+
+
${t('scan.ai_detected_label')}
+
${catIcon} ${escapeHtml(aiName)}${aiBrand ? ' · ' + escapeHtml(aiBrand) : ''}
+
+ `;
+ result.style.display = 'block';
+}
+async function _confirmAiDetectedProduct() {
+ const p = _aiDetectedProductDraft;
+ if (!p) return;
+ showLoading(true);
+ try {
+ const saveResult = await api('product_save', {}, 'POST', {
+ barcode: '',
+ name: p.name || t('product.not_recognized'),
+ brand: p.brand || '',
+ category: p.category || '',
+ image_url: '',
+ unit: 'pz',
+ default_quantity: 1,
+ package_unit: '',
+ notes: '',
+ });
+ if (saveResult.id) {
+ currentProduct = {
+ id: saveResult.id,
+ barcode: '',
+ name: p.name || t('product.not_recognized'),
+ brand: p.brand || '',
+ category: p.category || '',
+ image_url: '',
+ unit: 'pz',
+ default_quantity: 1,
+ package_unit: '',
+ _confCount: 0,
+ weight_info: '',
+ };
+ addToScanRecents(currentProduct);
+ _clearAiMatchPanel();
+ showLoading(false);
+ setTimeout(() => showProductAction(), 250);
+ } else {
+ showLoading(false);
+ showToast(t('error.connection'), 'error');
+ }
+ } catch (_) {
+ showLoading(false);
+ showToast(t('error.connection'), 'error');
+ }
+}
+function _selectAiInventoryCandidate(idx) {
+ const p = _aiInventoryCandidates[idx];
+ if (!p) return;
+ currentProduct = {
+ id: p.id,
+ barcode: p.barcode || '',
+ name: p.name || '',
+ brand: p.brand || '',
+ category: p.category || '',
+ image_url: p.image_url || '',
+ unit: p.unit || 'pz',
+ default_quantity: p.default_quantity || 1,
+ package_unit: p.package_unit || '',
+ _confCount: 0,
+ weight_info: '',
+ };
+ if (p.notes) {
+ const pesoMatch = p.notes.match(/Peso:\s*([^·]+)/);
+ if (pesoMatch) currentProduct.weight_info = pesoMatch[1].trim();
+ }
+ addToScanRecents(currentProduct);
+ _clearAiMatchPanel();
+ setTimeout(() => showProductAction(), 250);
+}
+async function _retryAiScan() {
+ const btn = document.getElementById('scan-ai-retry-btn');
+ if (btn) btn.style.display = 'none';
+ _aiFallbackExhausted = false;
+ _clearAiMatchPanel();
+ await _tryGeminiNumberOCR({ chainToVisual: true });
+}
+
+async function _tryGeminiVisualBarcode() {
+ if (_aiBarcodeVisualRunning || !_requireGemini()) return;
+ const video = document.getElementById('scanner-video');
+ if (!video || !video.videoWidth) return;
+
+ // ★ Capture the frame BEFORE stopping the stream — after stopScanner() the
+ // video element is blanked and drawImage would send a black image to Gemini.
+ const canvas = document.createElement('canvas');
+ canvas.width = video.videoWidth;
+ canvas.height = video.videoHeight;
+ canvas.getContext('2d').drawImage(video, 0, 0);
+ const imageBase64 = canvas.toDataURL('image/jpeg', 0.88).split(',')[1];
+ if (!imageBase64) { scanLog('AI visual: failed to capture frame'); return; }
+
+ _aiBarcodeVisualRunning = true;
+ stopScanner(); // stop scanner loop while AI processes (stream already captured above)
+ _setScanStatus(t('scan.status_ai_visual_searching'), 'retry', t('scan.method_ai_vision'));
+ _showScanAiOverlay(t('scan.ai_overlay_msg'));
+
+ try {
+ const result = await api('gemini_barcode_visual', {}, 'POST', {
+ image: imageBase64,
+ lang: _currentLang || 'it',
+ });
+
+ if (result.found && result.product) {
+ const p = result.product;
+ scanLog(`AI visual: found "${p.name}" (${p.brand})`);
+ _hideScanAiOverlay();
+ showToast(t('scan.ai_fallback_found'), 'success');
+ _aiDetectedProductDraft = {
+ name: p.name || t('product.not_recognized'),
+ brand: p.brand || '',
+ category: p.category || '',
+ };
+ let candidates = [];
+ try {
+ const invRes = await api('inventory_search', {
+ q: _aiDetectedProductDraft.name,
+ limit: 3,
+ });
+ candidates = (invRes.items || []).slice(0, 3);
+ } catch (_) {
+ candidates = [];
+ }
+ _aiInventoryCandidates = candidates;
+ _showAiMatchChoices(_aiDetectedProductDraft, candidates);
+ } else {
+ scanLog('AI visual: product not identified — exhausted for this session');
+ _aiFallbackExhausted = true;
+ _hideScanAiOverlay();
+ _setScanStatus(t('scan.ai_fallback_exhausted'), 'retry', '');
+ _showAiRetryButton();
+ // Restart barcode scanner — AI timer won't fire again (_aiFallbackExhausted=true).
+ setTimeout(() => initScanner(), 300);
+ }
+ } catch (e) {
+ scanLog(`AI visual error: ${e.message}`);
+ _aiFallbackExhausted = true;
+ _hideScanAiOverlay();
+ _setScanStatus(t('scan.ai_fallback_exhausted'), 'retry', '');
+ _showAiRetryButton();
+ setTimeout(() => initScanner(), 300);
+ } finally {
+ _aiBarcodeVisualRunning = false;
+ }
+}
+
// ===== CAMERA HELPER =====
function getCameraConstraints(extraVideo = {}) {
const s = getSettings();
@@ -2296,6 +2535,7 @@ function _applySyncedSettings(serverSettings) {
'shopping_enabled','shopping_mode','shopping_smart_suggestions',
'shopping_forecast','shopping_auto_add_threshold',
'dark_mode',
+ 'barcode_ai_fallback',
// Home Assistant
'ha_enabled','ha_url','ha_tts_entity','ha_webhook_id','ha_webhook_events',
'ha_notify_service','ha_expiry_days'];
@@ -2887,6 +3127,8 @@ async function loadSettingsUI() {
const cameraSelect = document.getElementById('setting-camera-facing');
if (cameraSelect) cameraSelect.value = s.camera_facing || 'environment';
loadCameraDevices();
+ const baifEl = document.getElementById('setting-barcode-ai-fallback');
+ if (baifEl) baifEl.checked = s.barcode_ai_fallback === true;
renderAppliances(s.appliances || []);
const mealPlanEnabled = s.meal_plan_enabled !== false;
const mpEnabledEl = document.getElementById('setting-meal-plan-enabled');
@@ -3465,6 +3707,8 @@ async function saveSettings() {
s.dietary = document.getElementById('setting-dietary').value.trim();
// Camera
s.camera_facing = document.getElementById('setting-camera-facing').value;
+ const baifSave = document.getElementById('setting-barcode-ai-fallback');
+ if (baifSave) s.barcode_ai_fallback = baifSave.checked;
// Screensaver
const ssEl = document.getElementById('setting-screensaver-enabled');
if (ssEl) s.screensaver_enabled = ssEl.checked;
@@ -3608,6 +3852,7 @@ async function saveSettings() {
shopping_forecast: s.shopping_forecast !== false,
shopping_auto_add_threshold: s.shopping_auto_add_threshold || 0,
dark_mode: s.dark_mode || 'auto',
+ barcode_ai_fallback: !!s.barcode_ai_fallback,
// Home Assistant
ha_enabled: !!s.ha_enabled,
ha_url: s.ha_url || '',
@@ -3748,6 +3993,18 @@ async function api(action, params = {}, method = 'GET', body = null, extraHeader
// Track current page for auto-refresh
let _currentPageId = 'dashboard';
let _currentPageParam = null;
+let _pageHistory = [{ pageId: 'dashboard', param: null }];
+
+function goBack(fallbackPage = 'dashboard') {
+ if (_pageHistory.length > 1) {
+ // Drop current page and navigate to the previous entry without re-adding history.
+ _pageHistory.pop();
+ const prev = _pageHistory[_pageHistory.length - 1] || { pageId: fallbackPage, param: null };
+ showPage(prev.pageId, prev.param, { skipHistory: true });
+ return;
+ }
+ showPage(fallbackPage, null, { skipHistory: true });
+}
// Refresh current page data without full navigation
function refreshCurrentPage() {
@@ -3765,7 +4022,17 @@ function refreshCurrentPage() {
}
}
-function showPage(pageId, param = null) {
+function showPage(pageId, param = null, options = {}) {
+ const skipHistory = !!options.skipHistory;
+ if (!skipHistory) {
+ const last = _pageHistory[_pageHistory.length - 1];
+ const sameAsLast = !!last && last.pageId === pageId && (last.param ?? null) === (param ?? null);
+ if (!sameAsLast) {
+ _pageHistory.push({ pageId, param });
+ if (_pageHistory.length > 80) _pageHistory.shift();
+ }
+ }
+
_currentPageId = pageId;
_currentPageParam = param;
// Hide all pages
@@ -3797,7 +4064,7 @@ function showPage(pageId, param = null) {
}
loadInventory();
break;
- case 'scan': initScanner(); clearQuickNameResults(); updateSpesaBanner(); updateScanRecents(); switchScanTab('barcode');
+ case 'scan': _aiFallbackExhausted = false; _hideScanAiOverlay(); { const _rb = document.getElementById('scan-ai-retry-btn'); if (_rb) _rb.style.display = 'none'; } initScanner(); clearQuickNameResults(); updateSpesaBanner(); updateScanRecents(); switchScanTab('barcode');
// Pre-warm the embedding model the first time user visits scan page
if (typeof window._getCategoryPipeline === 'function' && !window._categoryPipelineReady) {
window._getCategoryPipeline(); // fire-and-forget
@@ -4942,10 +5209,11 @@ async function loadBannerAlerts() {
if (!banner) { _bannerLoading = false; console.warn('[Banner] #alert-banner not found'); return; }
try {
- const [invData, predData, anomalyData, finishedData, statsData] = await Promise.all([
+ const [invData, predData, anomalyData, dupLossData, finishedData, statsData] = await Promise.all([
api('inventory_list'),
api('consumption_predictions').catch(err => { console.warn('[Banner] predictions fetch failed:', err); return { predictions: [] }; }),
api('inventory_anomalies').catch(err => { console.warn('[Banner] anomalies fetch failed:', err); return { anomalies: [] }; }),
+ api('inventory_duplicate_loss_checks').catch(err => { console.warn('[Banner] duplicate loss checks fetch failed:', err); return { checks: [] }; }),
api('inventory_finished_items').catch(err => { console.warn('[Banner] finished_items fetch failed:', err); return { finished: [] }; }),
api('stats').catch(() => ({ opened: [] })),
]);
@@ -5069,14 +5337,21 @@ async function loadBannerAlerts() {
_bannerQueue.push({ type: 'anomaly', data: an });
});
- // 6. Finished products: inventory hit 0, waiting for user confirmation
+ // 6. Potentially lost products due to rapid duplicate "out" events
+ const dupChecks = dupLossData.checks || [];
+ dupChecks.forEach(ch => {
+ if (confirmed['dup_' + ch.dismiss_key]) return;
+ _bannerQueue.push({ type: 'dup_loss_check', data: ch });
+ });
+
+ // 7. Finished products: inventory hit 0, waiting for user confirmation
const finished = finishedData.finished || [];
finished.forEach(fin => {
if (confirmed['fin_' + fin.product_id]) return;
_bannerQueue.push({ type: 'finished', data: fin });
});
- // 7. Products with no expiry date set (and not permanently dismissed)
+ // 8. Products with no expiry date set (and not permanently dismissed)
// Warn for ALL food/drink items — only skip igiene/pulizia (non-food).
// Items are capped at 8 per load (opened packages first) to avoid banner overflow.
const noExpiryDismissed = _getNoExpiryDismissed();
@@ -5157,6 +5432,8 @@ function _bannerPriority(entry) {
// Phantom (inflated qty) = 250, Missing = 260 (slightly higher, means data is clearly wrong)
return entry.data.direction === 'missing' ? 260 : 250;
}
+ case 'dup_loss_check':
+ return 700; // high-priority check: likely double-consume loss
case 'finished':
return 600; // product ran out — confirm before removing from DB
case 'no_expiry':
@@ -5358,6 +5635,30 @@ function renderBannerItem() {
}
actionsEl.innerHTML = btns;
+ } else if (entry.type === 'dup_loss_check') {
+ const ch = entry.data;
+ banner.className = 'alert-banner banner-dup-loss';
+ iconEl.textContent = '🧪';
+
+ const locInfo = LOCATIONS[ch.location] || { icon: '📦', label: ch.location || '—' };
+ const locText = `${locInfo.icon} ${locInfo.label}`;
+ const qtyPair = `${ch.q1} + ${ch.q2}`;
+
+ titleEl.textContent = t('dashboard.banner_dup_loss_title').replace('{name}', ch.name);
+ detailEl.textContent = t('dashboard.banner_dup_loss_detail')
+ .replace('{location}', locText)
+ .replace('{seconds}', Math.round(ch.dt_sec || 0))
+ .replace('{qty_pair}', qtyPair);
+
+ let btns = '';
+ if (ch.inventory_id && ch.inventory_id > 0) {
+ btns += ``;
+ } else {
+ btns += ``;
+ }
+ btns += ``;
+ actionsEl.innerHTML = btns;
+
} else if (entry.type === 'no_expiry') {
const item = entry.data;
banner.className = 'alert-banner banner-no-expiry';
@@ -5495,6 +5796,32 @@ function dismissBannerAnomaly() {
dismissBannerItem();
}
+function dismissDuplicateLossCheck() {
+ const entry = _bannerQueue[_bannerIndex];
+ if (!entry || entry.type !== 'dup_loss_check') return;
+ const key = entry.data.dismiss_key;
+ setReviewConfirmed('dup_' + key);
+ showToast(t('dashboard.banner_dup_loss_toast_done'), 'success');
+ dismissBannerItem();
+}
+
+async function openDuplicateLossCheck(productId) {
+ showLoading(true);
+ try {
+ const data = await api('product_get', { id: productId });
+ if (data.product) {
+ currentProduct = data.product;
+ showProductAction();
+ } else {
+ showToast(t('error.not_found'), 'error');
+ }
+ } catch (e) {
+ showToast(t('error.connection'), 'error');
+ } finally {
+ showLoading(false);
+ }
+}
+
function weighBannerItem() {
const entry = _bannerQueue[_bannerIndex];
if (!entry) return;
@@ -6489,11 +6816,14 @@ async function initScanner() {
const constraints = getCameraConstraints();
scanLog(`Camera mode: ${getSettings().camera_facing || 'environment'}`);
- scanLog(`BarcodeDetector: ${_useBarcodeDetector ? 'YES (native)' : 'NO (Quagga fallback)'}`);
+ scanLog(`BarcodeDetector: ${_useBarcodeDetector ? 'YES (native)' : 'NO (fallback)'}`);
+ scanLog(`Gemini available: ${_geminiAvailable}`);
+ scanLog(`AI fallback enabled: ${getSettings().barcode_ai_fallback === true}`);
scanLog(`Constraints: ${JSON.stringify(constraints.video)}`);
try {
stopScanner();
+ _clearAiMatchPanel();
const stream = await navigator.mediaDevices.getUserMedia(constraints);
const track = stream.getVideoTracks()[0];
@@ -6527,6 +6857,17 @@ async function initScanner() {
}
}, 4000);
}
+
+ // After 5s without a scan, auto-trigger AI visual identification (if enabled)
+ if (_geminiAvailable && getSettings().barcode_ai_fallback && !_aiFallbackExhausted) {
+ clearTimeout(_aiFallbackTimer);
+ _aiFallbackTimer = setTimeout(() => {
+ if (scannerStream && !_aiFallbackExhausted) { // still scanning — no barcode found yet
+ scanLog('5s elapsed without barcode — triggering AI OCR fallback');
+ _tryGeminiNumberOCR({ chainToVisual: true });
+ }
+ }, 5000);
+ }
} catch (err) {
scanLog(`CAMERA ERROR: ${err.name}: ${err.message}`);
@@ -6598,14 +6939,14 @@ async function startNativeScanner(videoEl) {
if (frameCount === 1) {
updateFeedback('scanning');
- _setScanStatus(t('scan.status_scanning'), '', 'Native API');
+ _setScanStatus(t('scan.status_scanning'), '', '');
}
// After 2s without detection, also start Quagga in parallel as backup
if (!quaggaParallelStarted && (Date.now() - startTime) > 2000) {
quaggaParallelStarted = true;
- scanLog('Native: 2s elapsed, spawning Quagga in parallel');
- _setScanStatus(t('scan.status_parallel'), 'retry', 'Native + Quagga');
+ scanLog('Native: 2s elapsed, spawning fallback scanner in parallel');
+ _setScanStatus(t('scan.status_parallel'), 'retry', '');
quaggaRunning = false; // temporarily release so Quagga can start
startQuaggaScanner(videoEl, false);
quaggaRunning = true; // re-take ownership (Quagga will share)
@@ -6637,6 +6978,13 @@ async function startNativeScanner(videoEl) {
// For other formats (code_128, code_39) require 2 to avoid false reads.
const highConfidence = ['ean_13','ean_8','upc_a','upc_e'].includes(format);
if (highConfidence || detectCount >= 2 || detectionHistory[code].count >= 2) {
+ if (highConfidence && !validateEANChecksum(code)) {
+ _invalidBarcodeCount++;
+ scanLog(`Invalid EAN checksum (native): ${code} (retry #${_invalidBarcodeCount})`);
+ _setScanStatus(t('scan.status_invalid').replace('{code}', code), 'invalid', 'Native');
+ lastDetected = ''; detectCount = 0;
+ return;
+ }
scanning = false;
quaggaRunning = false;
updateFeedback(null);
@@ -6674,7 +7022,7 @@ function startQuaggaScanner(videoEl, isPrimary = true) {
let frameCount = 0;
let partialCount = 0;
- scanLog(`Quagga starting — frontCam: ${frontCam}`);
+ scanLog(`Fallback scanner starting — frontCam: ${frontCam}`);
let scanning = true;
quaggaRunning = true;
@@ -6850,6 +7198,10 @@ function stopScanner() {
quaggaRunning = false;
_scanZoomLevel = 2; // always 2x on next start
_torchActive = false;
+ clearTimeout(_aiFallbackTimer); _aiFallbackTimer = null;
+ // NOTE: _aiFallbackExhausted is intentionally NOT reset here.
+ // It is only reset in showPage('scan') so that internal stop/restart
+ // cycles (e.g. initScanner calling stopScanner) don't re-arm the AI timer.
if (scannerStream) {
scannerStream.getTracks().forEach(t => t.stop());
scannerStream = null;
@@ -6861,6 +7213,7 @@ function stopScanner() {
if (tb) tb.classList.remove('torch-on');
// Hide live code
_hideScanLiveCode();
+ _clearAiMatchPanel();
// Also stop AI camera
if (aiStream) {
aiStream.getTracks().forEach(t => t.stop());
@@ -6871,6 +7224,7 @@ function stopScanner() {
}
async function onBarcodeDetected(barcode) {
+ clearTimeout(_aiFallbackTimer); _aiFallbackTimer = null;
showLoading(true);
// Vibrate if available
@@ -7026,7 +7380,7 @@ function autoSubmitEAN(inputEl, force = false) {
if (!raw) { showToast(t('error.barcode_empty'), 'error'); inputEl.focus(); return; }
if (!/^\d{4,14}$/.test(raw)) { showToast(t('error.barcode_format'), 'error'); inputEl.focus(); return; }
if (isComplete && !isValid) {
- showToast('⚠️ Checksum EAN errato — verifica le cifre', 'warning');
+ showToast(t('error.barcode_checksum'), 'error'); inputEl.focus(); return;
}
stopScanner();
onBarcodeDetected(raw);
@@ -7781,7 +8135,7 @@ function showProductAction() {
// Update back button: go back to shopping if came from shopping list scan
const backBtn = document.getElementById('action-back-btn');
- if (backBtn) backBtn.onclick = _spesaScanTarget ? () => { _spesaScanTarget = null; showPage('shopping'); } : () => showPage('scan');
+ if (backBtn) backBtn.onclick = () => goBack();
// Show "shopping target" banner if we came from the shopping list
const banner = document.getElementById('shopping-scan-target-banner');
@@ -7795,7 +8149,7 @@ function showProductAction() {
-
+
`;
} else if (banner) {
@@ -13559,7 +13913,7 @@ async function toggleRecipeFavorite(btn) {
* Scale recipe ingredient quantities (#123).
* Delta: +1 or -1. Min 1, max 20 persons.
*/
-function adjustRecipePersons(delta) {
+function scaleRecipePersons(delta) {
const newPersons = Math.max(1, Math.min(20, _recipeCurrentPersons + delta));
if (newPersons === _recipeCurrentPersons) return;
_recipeCurrentPersons = newPersons;
@@ -13604,9 +13958,9 @@ function renderRecipe(r) {
html += '
@@ -77,7 +77,7 @@
+
+
✓
@@ -274,6 +282,9 @@
+
+
+
Recenti
@@ -333,7 +344,7 @@