feat: AI scan shows existing products, scale ml hint, 10g threshold

1. AI photo scan: searches local DB for matching products and shows
   'Già in dispensa' section before OFF matches. User can tap an
   existing product directly. 'Non è nessuno di questi' button for
   new products.

2. Scale live box: when product unit is ml, shows hint
   'Peso in grammi → verrà convertito in ml' so user knows the
   gram reading will be converted.

3. Scale auto-fill: ignores stable weight if it differs less than
   10g from the last confirmed reading. Prevents re-triggering the
   same weight when switching between products on the scale.
   _scaleLastConfirmedGrams tracks the last auto-confirmed weight
   and resets on page navigation.
This commit is contained in:
dadaloop82
2026-04-17 05:42:48 +00:00
parent ccd59269d4
commit 32e2833b27
+91 -4
View File
@@ -75,6 +75,7 @@ let _scaleStabilityRAF = null; // rAF handle for stability progress bar in t
let _scaleStabilityVal = null; // value we are currently timing for stability let _scaleStabilityVal = null; // value we are currently timing for stability
let _scaleUserDismissed = false; // user tapped or edited → don't retrigger for same value let _scaleUserDismissed = false; // user tapped or edited → don't retrigger for same value
let _scaleRecipeAutoFillPaused = false; // pause flag for recipe-use modal only let _scaleRecipeAutoFillPaused = false; // pause flag for recipe-use modal only
let _scaleLastConfirmedGrams = null; // grams of last auto-confirmed weight (to detect product change)
function scaleInit() { function scaleInit() {
const s = getSettings(); const s = getSettings();
@@ -212,7 +213,21 @@ function _scaleUpdateLiveBox(msg) {
box.classList.remove('scale-low-weight'); box.classList.remove('scale-low-weight');
const stIcon = msg.stable ? ' ✓' : ' …'; const stIcon = msg.stable ? ' ✓' : ' …';
if (valEl) valEl.textContent = `${isFinite(raw) ? raw : '—'} ${msg.unit || 'kg'}${stIcon}`; if (valEl) valEl.textContent = `${isFinite(raw) ? raw : '—'} ${msg.unit || 'kg'}${stIcon}`;
if (lblEl) lblEl.textContent = ''; // Show conversion hint when product unit is ml
let targetUnit = null;
if (_useConfMode && _useConfMode._activeUnit === 'sub') {
targetUnit = (_useConfMode.packageUnit || '').toLowerCase();
} else {
targetUnit = _useNormalUnit;
}
if (lblEl) {
if (targetUnit === 'ml' && rawUnit !== 'ml') {
lblEl.textContent = '⚖️ Peso in grammi → verrà convertito in ml';
lblEl.style.display = '';
} else {
lblEl.textContent = '';
}
}
} }
} }
@@ -255,6 +270,11 @@ function _scaleAutoFillUse(msg) {
return; return;
} }
// Reject if weight hasn't changed enough from last confirmed reading (same product still on scale)
if (_scaleLastConfirmedGrams !== null && Math.abs(grams - _scaleLastConfirmedGrams) < 10) {
return;
}
// Convert to target unit // Convert to target unit
let val; let val;
let hintExtra = ''; let hintExtra = '';
@@ -293,6 +313,7 @@ function _scaleAutoFillUse(msg) {
if (inp) inp.value = val; if (inp) inp.value = val;
// Start the 5-s confirm progress bar // Start the 5-s confirm progress bar
_startScaleAutoConfirm(() => { _startScaleAutoConfirm(() => {
_scaleLastConfirmedGrams = grams;
const form = document.querySelector('#page-use form'); const form = document.querySelector('#page-use form');
if (form) form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); if (form) form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
}, 'btn-use-submit'); }, 'btn-use-submit');
@@ -305,6 +326,7 @@ function _scaleAutoFillUse(msg) {
const inp = document.getElementById('use-quantity'); const inp = document.getElementById('use-quantity');
if (inp) inp.value = val; if (inp) inp.value = val;
_startScaleAutoConfirm(() => { _startScaleAutoConfirm(() => {
_scaleLastConfirmedGrams = grams;
const form = document.querySelector('#page-use form'); const form = document.querySelector('#page-use form');
if (form) form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); if (form) form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
}, 'btn-use-submit'); }, 'btn-use-submit');
@@ -364,6 +386,9 @@ function _scaleAutoFillRecipeUse(msg) {
const hint = document.getElementById('ruse-scale-hint'); const hint = document.getElementById('ruse-scale-hint');
if (hint) { if (hint) {
hint.textContent = `⚖️ Bilancia: ${msg.value} ${msg.unit || 'kg'}${msg.stable ? ' ✓' : ' …'}`; hint.textContent = `⚖️ Bilancia: ${msg.value} ${msg.unit || 'kg'}${msg.stable ? ' ✓' : ' …'}`;
if (unit === 'ml' && srcUnit !== 'ml') {
hint.textContent += ' (verrà convertito in ml)';
}
hint.style.display = ''; hint.style.display = '';
} }
@@ -372,6 +397,11 @@ function _scaleAutoFillRecipeUse(msg) {
return; return;
} }
// Reject if weight hasn't changed enough from last confirmed reading
if (_scaleLastConfirmedGrams !== null && Math.abs(grams - _scaleLastConfirmedGrams) < 10) {
return;
}
if (val !== _scaleStabilityVal) { if (val !== _scaleStabilityVal) {
_scaleStabilityVal = val; _scaleStabilityVal = val;
_scaleUserDismissed = false; _scaleUserDismissed = false;
@@ -383,14 +413,14 @@ function _scaleAutoFillRecipeUse(msg) {
hint.textContent = `⚖️ Peso bilancia: ${val} ${unit}${hintExtra}`; hint.textContent = `⚖️ Peso bilancia: ${val} ${unit}${hintExtra}`;
hint.style.display = ''; hint.style.display = '';
} }
_startScaleAutoConfirm(() => { submitRecipeUse(false); }, 'btn-ruse-submit'); _startScaleAutoConfirm(() => { _scaleLastConfirmedGrams = grams; submitRecipeUse(false); }, 'btn-ruse-submit');
}); });
} else if (!_scaleUserDismissed && !_scaleStabilityTimer && !_scaleAutoConfirmTimer) { } else if (!_scaleUserDismissed && !_scaleStabilityTimer && !_scaleAutoConfirmTimer) {
_cancelScaleTimersOnly(); _cancelScaleTimersOnly();
_startScaleStabilityWait(() => { _startScaleStabilityWait(() => {
const inp = document.getElementById('ruse-quantity'); const inp = document.getElementById('ruse-quantity');
if (inp) inp.value = val; if (inp) inp.value = val;
_startScaleAutoConfirm(() => { submitRecipeUse(false); }, 'btn-ruse-submit'); _startScaleAutoConfirm(() => { _scaleLastConfirmedGrams = grams; submitRecipeUse(false); }, 'btn-ruse-submit');
}); });
} }
} }
@@ -427,6 +457,7 @@ function _cancelScaleAutoConfirm(fromTouch) {
_scaleUserDismissed = true; _scaleUserDismissed = true;
} else { } else {
_scaleStabilityVal = null; _scaleStabilityVal = null;
_scaleLastConfirmedGrams = null;
} }
} }
@@ -5653,6 +5684,24 @@ async function analyzeWithAI() {
const id = result.identified; const id = result.identified;
const matches = result.off_matches || []; const matches = result.off_matches || [];
// Search local DB for existing products that match the AI identification
let localMatches = [];
try {
const nameWords = (id.name || '').split(/\s+/).filter(w => w.length > 2);
const searches = [api('products_search', { q: id.name })];
if (id.brand) searches.push(api('products_search', { q: id.brand }));
const results = await Promise.all(searches);
const seen = new Set();
results.forEach(r => {
(r.products || []).forEach(p => {
if (!seen.has(p.id)) {
seen.add(p.id);
localMatches.push(p);
}
});
});
} catch(e) { /* ignore search errors */ }
let html = `<h4>🤖 Prodotto identificato</h4>`; let html = `<h4>🤖 Prodotto identificato</h4>`;
html += `<div class="ai-identified-card">`; html += `<div class="ai-identified-card">`;
html += `<strong>${escapeHtml(id.name)}</strong>`; html += `<strong>${escapeHtml(id.name)}</strong>`;
@@ -5660,6 +5709,26 @@ async function analyzeWithAI() {
if (id.description) html += `<p style="font-size:0.85rem;color:var(--text-light);margin:4px 0 0">${escapeHtml(id.description)}</p>`; if (id.description) html += `<p style="font-size:0.85rem;color:var(--text-light);margin:4px 0 0">${escapeHtml(id.description)}</p>`;
html += `</div>`; html += `</div>`;
// Show existing local products first
if (localMatches.length > 0) {
html += `<h4 style="margin-top:16px">📋 Già in dispensa</h4>`;
html += `<div class="ai-matches-list">`;
localMatches.forEach((p, idx) => {
html += `<div class="ai-match-item" onclick="selectLocalMatch(${p.id})">`;
if (p.image_url) {
html += `<img src="${escapeHtml(p.image_url)}" alt="" class="ai-match-img" onerror="this.style.display='none'">`;
}
html += `<div class="ai-match-info">`;
html += `<strong>${escapeHtml(p.name)}</strong>`;
if (p.brand) html += `<br><small>${escapeHtml(p.brand)}</small>`;
if (p.default_quantity && p.unit) html += `<br><small style="color:var(--text-muted)">${p.default_quantity} ${p.unit}</small>`;
html += `</div>`;
if (p.barcode) html += `<span class="ai-match-barcode">${p.barcode}</span>`;
html += `</div>`;
});
html += `</div>`;
}
if (matches.length > 0) { if (matches.length > 0) {
html += `<h4 style="margin-top:16px">📦 Prodotti corrispondenti</h4>`; html += `<h4 style="margin-top:16px">📦 Prodotti corrispondenti</h4>`;
html += `<div class="ai-matches-list">`; html += `<div class="ai-matches-list">`;
@@ -5681,7 +5750,7 @@ async function analyzeWithAI() {
// Option to save as-is without barcode // Option to save as-is without barcode
html += `<div style="margin-top:16px; border-top: 1px solid var(--bg-light); padding-top: 12px">`; html += `<div style="margin-top:16px; border-top: 1px solid var(--bg-light); padding-top: 12px">`;
html += `<button class="btn btn-secondary full-width" onclick="saveAIProductDirect()">✏️ Salva senza barcode</button>`; html += `<button class="btn btn-secondary full-width" onclick="saveAIProductDirect()">🆕 Non è nessuno di questi — salva come nuovo</button>`;
html += `</div>`; html += `</div>`;
resultDiv.innerHTML = html; resultDiv.innerHTML = html;
@@ -5697,6 +5766,24 @@ async function analyzeWithAI() {
} }
} }
async function selectLocalMatch(productId) {
showLoading(true);
try {
const result = await api('product_get', { id: productId });
if (result.product) {
currentProduct = result.product;
showLoading(false);
showProductAction();
} else {
showLoading(false);
showToast('Prodotto non trovato', 'error');
}
} catch (err) {
showLoading(false);
showToast(t('error.connection'), 'error');
}
}
async function selectAIMatch(idx) { async function selectAIMatch(idx) {
const match = window._aiMatches[idx]; const match = window._aiMatches[idx];
if (!match) return; if (!match) return;