chore: auto-merge develop → main

Triggered by: 696a9c6 feat: scan page redesign — fixed 2x zoom, torch, camera flip, tabs, recents, AI number OCR
This commit is contained in:
github-actions[bot]
2026-05-12 14:57:53 +00:00
7 changed files with 613 additions and 157 deletions
+43 -1
View File
@@ -118,7 +118,7 @@ function checkRateLimit(string $action): void {
} }
// Determine limit based on action // Determine limit based on action
$aiActions = ['gemini_readExpiry', 'gemini_chat', 'gemini_identify', 'gemini_suggest_shopping', 'chat_to_recipe', 'recipe_from_ingredient']; $aiActions = ['gemini_readExpiry', 'gemini_chat', 'gemini_identify', 'gemini_suggest_shopping', 'chat_to_recipe', 'recipe_from_ingredient', 'gemini_number_ocr'];
$loginActions = []; $loginActions = [];
$recipeActions = ['generate_recipe', 'generate_recipe_stream']; $recipeActions = ['generate_recipe', 'generate_recipe_stream'];
$errorActions = ['report_error', 'check_update']; $errorActions = ['report_error', 'check_update'];
@@ -454,6 +454,10 @@ try {
geminiAnomalyExplain(); geminiAnomalyExplain();
break; break;
case 'gemini_number_ocr':
geminiNumberOCR();
break;
case 'get_shopping_price': case 'get_shopping_price':
getShoppingPrice($db); getShoppingPrice($db);
break; break;
@@ -7287,6 +7291,44 @@ function geminiShoppingEnrich(PDO $db): void {
echo json_encode(['success' => true, 'items' => $enriched, 'source' => 'gemini']); echo json_encode(['success' => true, 'items' => $enriched, 'source' => 'gemini']);
} }
// =============================================================================
// ===== GEMINI AI: NUMBER OCR (read barcode digits from image) ================
// =============================================================================
/**
* POST /api/?action=gemini_number_ocr
* Body: { image: base64-jpeg }
* Returns: { success, barcode } or { success: false, error }
* Uses Gemini vision to read the barcode number printed on a product label.
*/
function geminiNumberOCR(): void {
$apiKey = env('GEMINI_API_KEY');
if (empty($apiKey)) { echo json_encode(['success' => false, 'error' => 'no_api_key']); return; }
$input = json_decode(file_get_contents('php://input'), true);
$imageBase64 = $input['image'] ?? '';
if (!$imageBase64) { echo json_encode(['success' => false, 'error' => 'no_image']); return; }
$payload = [
'contents' => [[
'parts' => [
['text' => 'Look at this product image. Find the barcode number (EAN-13 or EAN-8) printed on the label — it is usually a sequence of 8 or 13 digits printed below or near the barcode stripes. Return ONLY the digit sequence, nothing else. If you cannot find a valid barcode number, return exactly: none'],
['inline_data' => ['mime_type' => 'image/jpeg', 'data' => $imageBase64]]
]
]],
'generationConfig' => ['temperature' => 0, 'maxOutputTokens' => 20, 'thinkingConfig' => ['thinkingBudget' => 0]]
];
$result = callGeminiWithFallback($apiKey, $payload, 10);
$text = trim($result['text'] ?? '');
$digits = preg_replace('/\D/', '', $text);
if (strlen($digits) === 13 || strlen($digits) === 8) {
echo json_encode(['success' => true, 'barcode' => $digits]);
} else {
echo json_encode(['success' => false, 'error' => 'not_found']);
}
}
// ============================================================================= // =============================================================================
// ===== GEMINI AI: ANOMALY EXPLANATION ======================================= // ===== GEMINI AI: ANOMALY EXPLANATION =======================================
// ============================================================================= // =============================================================================
+242 -100
View File
@@ -1596,39 +1596,35 @@ body.server-offline .bottom-nav {
.scan-container { .scan-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 12px;
} }
/* — Spesa chip nel page-header — */
.scan-spesa-chip {
margin-left: auto;
padding: 5px 12px;
border-radius: 20px;
border: 1.5px solid var(--accent);
background: rgba(124,58,237,0.08);
color: var(--accent);
font-size: 0.82rem;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s;
}
.scan-spesa-chip:active { background: rgba(124,58,237,0.18); }
/* — Viewport 16/9 — */
.scanner-viewport { .scanner-viewport {
position: relative; position: relative;
width: 100%; width: 100%;
aspect-ratio: 4/3; aspect-ratio: 16/9;
background: #000; background: #000;
border-radius: var(--radius); border-radius: var(--radius);
overflow: hidden; overflow: hidden;
} }
.scan-zoom-btn {
position: absolute;
top: 10px;
right: 10px;
z-index: 20;
background: rgba(0,0,0,0.55);
color: #fff;
border: 1.5px solid rgba(255,255,255,0.5);
border-radius: 20px;
padding: 5px 13px;
font-size: 0.85rem;
font-weight: 700;
cursor: pointer;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
transition: background 0.15s;
}
.scan-zoom-btn:active {
background: rgba(255,255,255,0.25);
}
.scanner-viewport video { .scanner-viewport video {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -1637,6 +1633,26 @@ body.server-offline .bottom-nav {
transition: transform 0.2s ease; transition: transform 0.2s ease;
} }
/* — Guide frame corners — */
.scan-guide-frame {
position: absolute;
inset: 0;
z-index: 11;
pointer-events: none;
}
.sgf-corner {
position: absolute;
width: 22px;
height: 22px;
border-color: rgba(255,255,255,0.85);
border-style: solid;
}
.sgf-tl { top: 12px; left: 12px; border-width: 3px 0 0 3px; border-radius: 3px 0 0 0; }
.sgf-tr { top: 12px; right: 12px; border-width: 3px 3px 0 0; border-radius: 0 3px 0 0; }
.sgf-bl { bottom: 36px; left: 12px; border-width: 0 0 3px 3px; border-radius: 0 0 0 3px; }
.sgf-br { bottom: 36px; right: 12px; border-width: 0 3px 3px 0; border-radius: 0 0 3px 0; }
/* — Scan line — */
.scanner-overlay { .scanner-overlay {
position: absolute; position: absolute;
top: 50%; top: 50%;
@@ -1647,7 +1663,6 @@ body.server-offline .bottom-nav {
z-index: 10; z-index: 10;
pointer-events: none; pointer-events: none;
} }
.scanner-line { .scanner-line {
width: 100%; width: 100%;
height: 3px; height: 3px;
@@ -1656,37 +1671,127 @@ body.server-offline .bottom-nav {
animation: scanLine 2s ease-in-out infinite; animation: scanLine 2s ease-in-out infinite;
transition: background 0.2s, box-shadow 0.2s, height 0.2s; transition: background 0.2s, box-shadow 0.2s, height 0.2s;
} }
/* While Quagga is actively scanning frames */
.scanner-line.scanning { .scanner-line.scanning {
background: #00c853; background: #00c853;
box-shadow: 0 0 12px #00c853; box-shadow: 0 0 12px #00c853;
animation: scanLineActive 0.8s ease-in-out infinite; animation: scanLineActive 0.8s ease-in-out infinite;
} }
/* Barcode partially detected — strong pulse */
.scanner-line.detecting { .scanner-line.detecting {
background: #ffd600; background: #ffd600;
box-shadow: 0 0 20px #ffd600, 0 0 40px rgba(255,214,0,0.4); box-shadow: 0 0 20px #ffd600, 0 0 40px rgba(255,214,0,0.4);
height: 5px; height: 5px;
animation: scanLineDetect 0.3s ease-in-out infinite; animation: scanLineDetect 0.3s ease-in-out infinite;
} }
@keyframes scanLine { @keyframes scanLine {
0%, 100% { opacity: 1; } 0%, 100% { opacity: 1; }
50% { opacity: 0.3; } 50% { opacity: 0.3; }
} }
@keyframes scanLineActive { @keyframes scanLineActive {
0%, 100% { opacity: 1; } 0%, 100% { opacity: 1; }
50% { opacity: 0.5; } 50% { opacity: 0.5; }
} }
@keyframes scanLineDetect { @keyframes scanLineDetect {
0%, 100% { opacity: 1; transform: scaleY(1); } 0%, 100% { opacity: 1; transform: scaleY(1); }
50% { opacity: 0.7; transform: scaleY(2); } 50% { opacity: 0.7; transform: scaleY(2); }
} }
/* — Live partial code — */
.scan-live-code {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, calc(-50% - 14px));
z-index: 12;
background: rgba(0,0,0,0.65);
color: #ffd600;
font-family: monospace;
font-size: 1.1rem;
font-weight: 700;
letter-spacing: 2px;
padding: 4px 12px;
border-radius: 8px;
pointer-events: none;
white-space: nowrap;
}
/* — Success confirm overlay — */
.scan-confirm-overlay {
position: absolute;
inset: 0;
z-index: 20;
background: rgba(0,180,80,0.82);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
border-radius: var(--radius);
animation: scanConfirmFade 0.12s ease-in;
}
@keyframes scanConfirmFade {
from { opacity: 0; transform: scale(0.94); }
to { opacity: 1; transform: scale(1); }
}
.scan-confirm-check {
font-size: 2.8rem;
color: #fff;
line-height: 1;
}
.scan-confirm-name {
font-size: 0.92rem;
font-weight: 700;
color: #fff;
text-align: center;
padding: 0 16px;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* — Viewport overlay controls (torch / zoom / flip) — */
.scan-viewport-controls {
position: absolute;
bottom: 0;
left: 0;
right: 0;
z-index: 15;
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
background: linear-gradient(transparent, rgba(0,0,0,0.55));
}
.scan-ctrl-btn {
background: rgba(255,255,255,0.15);
border: 1.5px solid rgba(255,255,255,0.45);
border-radius: 50%;
width: 38px;
height: 38px;
font-size: 1.1rem;
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.scan-ctrl-btn:active { background: rgba(255,255,255,0.35); }
.scan-ctrl-btn.torch-on { background: rgba(255,220,0,0.35); border-color: #ffd600; }
.scan-zoom-badge {
background: rgba(0,0,0,0.55);
color: #fff;
font-size: 0.85rem;
font-weight: 800;
padding: 4px 10px;
border-radius: 14px;
border: 1.5px solid rgba(255,255,255,0.4);
letter-spacing: 0.5px;
}
/* — Scan result errors — */
.scan-result { .scan-result {
background: var(--bg-card); background: var(--bg-card);
border-radius: var(--radius); border-radius: var(--radius);
@@ -1694,64 +1799,126 @@ body.server-offline .bottom-nav {
box-shadow: var(--shadow); box-shadow: var(--shadow);
} }
.scan-actions { /* — Recent scans — */
.scan-recents {
display: flex; display: flex;
flex-direction: column; align-items: center;
gap: 10px; gap: 8px;
} overflow: hidden;
}
.barcode-manual-entry { .scan-recents-label {
margin-bottom: 12px; font-size: 0.75rem;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
white-space: nowrap;
flex-shrink: 0;
}
.scan-recents-chips {
display: flex;
gap: 6px;
overflow-x: auto;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
flex: 1;
}
.scan-recents-chips::-webkit-scrollbar { display: none; }
.scan-recent-chip {
display: flex;
align-items: center;
gap: 5px;
padding: 5px 10px;
background: var(--bg-card);
border-radius: 20px;
border: 1px solid var(--border);
font-size: 0.78rem;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
flex-shrink: 0;
box-shadow: var(--shadow);
transition: background 0.12s, transform 0.1s;
}
.scan-recent-chip:active { background: var(--bg-main); transform: scale(0.96); }
.scan-recent-chip-icon { font-size: 1rem; }
/* — Input panel (bottom card) — */
.scan-input-panel {
background: var(--bg-card);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow: hidden;
}
.scan-input-tabs {
display: flex;
border-bottom: 1px solid var(--border);
}
.scan-input-tab {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
padding: 10px 6px;
border: none;
background: transparent;
font-size: 0.82rem;
font-weight: 600;
color: var(--text-muted);
cursor: pointer;
transition: color 0.15s, background 0.15s;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.scan-input-tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
background: rgba(124,58,237,0.05);
}
.scan-tab-content {
padding: 12px;
} }
/* — Barcode input row — */
.barcode-input-row { .barcode-input-row {
display: flex; display: flex;
gap: 8px; gap: 8px;
align-items: center; align-items: center;
} }
.barcode-input-row .form-input { .barcode-input-row .form-input {
flex: 1; flex: 1;
margin: 0; margin: 0;
font-size: 1.1rem; font-size: 1.1rem;
letter-spacing: 1px; letter-spacing: 1px;
} }
.barcode-input-row .btn { .barcode-input-row .btn {
white-space: nowrap; white-space: nowrap;
flex-shrink: 0; flex-shrink: 0;
} }
.quick-name-entry { /* — AI tab buttons — */
margin-bottom: 12px; .scan-ai-tab-btns {
display: flex;
flex-direction: column;
gap: 8px;
} }
.scan-ai-tab-btns .btn { width: 100%; }
.quick-name-divider { /* — AI number OCR fallback button — */
text-align: center; .scan-num-ocr-btn {
margin: 10px 0 8px; width: 100%;
position: relative; margin-top: 8px;
} background: rgba(124,58,237,0.08);
border: 1.5px dashed var(--accent);
.quick-name-divider::before { color: var(--accent);
content: ''; font-size: 0.88rem;
position: absolute; padding: 9px;
top: 50%; border-radius: var(--radius);
left: 0;
right: 0;
height: 1px;
background: var(--border);
}
.quick-name-divider span {
background: var(--bg-main);
padding: 0 12px;
position: relative;
font-size: 0.85rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
} }
.scan-num-ocr-btn:active { background: rgba(124,58,237,0.18); }
/* — Quick name results (dropdown inside name tab) — */
.quick-name-results { .quick-name-results {
margin-top: 8px; margin-top: 8px;
display: flex; display: flex;
@@ -1760,56 +1927,31 @@ body.server-offline .bottom-nav {
max-height: 200px; max-height: 200px;
overflow-y: auto; overflow-y: auto;
} }
.quick-name-result-item { .quick-name-result-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding: 10px 12px; padding: 10px 12px;
background: var(--bg-card); background: var(--bg-main);
border-radius: var(--radius); border-radius: calc(var(--radius) - 2px);
box-shadow: var(--shadow); border: 1px solid var(--border);
cursor: pointer; cursor: pointer;
transition: transform 0.1s; transition: transform 0.1s;
} }
.quick-name-result-item:active { transform: scale(0.98); }
.quick-name-result-item:active { .quick-name-result-item .qnr-icon { font-size: 1.4rem; flex-shrink: 0; }
transform: scale(0.98); .quick-name-result-item .qnr-info { flex: 1; min-width: 0; }
}
.quick-name-result-item .qnr-icon {
font-size: 1.5rem;
flex-shrink: 0;
}
.quick-name-result-item .qnr-info {
flex: 1;
min-width: 0;
}
.quick-name-result-item .qnr-name { .quick-name-result-item .qnr-name {
font-weight: 600; font-weight: 600;
font-size: 0.95rem; font-size: 0.92rem;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.quick-name-result-item .qnr-detail { font-size: 0.78rem; color: var(--text-muted); }
.quick-name-result-item .qnr-detail {
font-size: 0.8rem;
color: var(--text-muted);
}
.quick-name-result-item.qnr-new { .quick-name-result-item.qnr-new {
border: 1px dashed var(--accent); border: 1px dashed var(--accent);
background: rgba(124, 58, 237, 0.06); background: rgba(124,58,237,0.05);
}
.scan-hint {
text-align: center;
font-size: 0.85rem;
color: var(--text-muted);
margin-top: 4px;
} }
/* ===== SHOPPING LIST (BRING!) ===== */ /* ===== SHOPPING LIST (BRING!) ===== */
+199 -28
View File
@@ -1774,28 +1774,176 @@ let currentLocation = '';
let scannerStream = null; let scannerStream = null;
let quaggaRunning = false; let quaggaRunning = false;
let aiStream = null; let aiStream = null;
let _scanZoomLevel = 1; // 1 or 2 let _scanZoomLevel = 2; // always 2x
let _torchActive = false;
async function toggleScanZoom() { // Apply fixed 2x zoom (hardware if available, CSS fallback)
_scanZoomLevel = _scanZoomLevel === 1 ? 2 : 1; async function _applyFixedZoom() {
const btn = document.getElementById('scan-zoom-btn'); if (!scannerStream) return;
if (btn) btn.textContent = `x${_scanZoomLevel}`; const track = scannerStream.getVideoTracks()[0];
if (scannerStream) { if (!track) return;
const track = scannerStream.getVideoTracks()[0]; const caps = track.getCapabilities ? track.getCapabilities() : {};
if (track) { if (caps.zoom && caps.zoom.max >= 2) {
const caps = track.getCapabilities ? track.getCapabilities() : {}; const z = Math.min(caps.zoom.max, caps.zoom.min * 2);
if (caps.zoom) { try { await track.applyConstraints({ advanced: [{ zoom: z }] }); scanLog(`HW zoom: ${z}`); } catch(e) {}
// Hardware zoom (Android Chrome) } else {
const z = _scanZoomLevel === 2 const video = document.getElementById('scanner-video');
? Math.min(caps.zoom.max, caps.zoom.min * 2 || 2) if (video) video.style.transform = 'scale(2)';
: caps.zoom.min; scanLog('SW zoom: scale(2)');
try { await track.applyConstraints({ advanced: [{ zoom: z }] }); } catch(e) {} }
} else { }
// Software zoom via CSS scale on the video element
const video = document.getElementById('scanner-video'); async function toggleTorch() {
if (video) video.style.transform = _scanZoomLevel === 2 ? 'scale(2)' : 'scale(1)'; if (!scannerStream) return;
const track = scannerStream.getVideoTracks()[0];
if (!track) return;
const caps = track.getCapabilities ? track.getCapabilities() : {};
if (!caps.torch) { showToast(t('scan.torch_unavailable'), 'info'); return; }
_torchActive = !_torchActive;
try {
await track.applyConstraints({ advanced: [{ torch: _torchActive }] });
const btn = document.getElementById('scan-torch-btn');
if (btn) btn.classList.toggle('torch-on', _torchActive);
showToast(_torchActive ? t('scan.torch_on') : t('scan.torch_off'), 'info');
} catch(e) { showToast(t('scan.torch_unavailable'), 'info'); _torchActive = false; }
}
async function flipCamera() {
const s = getSettings();
const current = s.camera_facing || 'environment';
const next = current === 'environment' ? 'user' : 'environment';
s.camera_facing = next;
try { localStorage.setItem('evershelf_settings', JSON.stringify(s)); } catch(_) {}
showToast(next === 'user' ? t('scan.flip_front') : t('scan.flip_back'), 'info');
stopScanner();
setTimeout(() => initScanner(), 150);
}
// ===== SCAN TAB SWITCHING =====
function switchScanTab(tab) {
['barcode','name','ai'].forEach(id => {
const btn = document.getElementById(`scan-tab-${id}`);
const content = document.getElementById(`scan-tabcontent-${id}`);
const active = id === tab;
if (btn) btn.classList.toggle('active', active);
if (content) content.style.display = active ? '' : 'none';
});
// Focus input on tab switch
if (tab === 'barcode') {
const el = document.getElementById('manual-barcode-input');
if (el) setTimeout(() => el.focus(), 80);
} else if (tab === 'name') {
const el = document.getElementById('quick-product-name');
if (el) setTimeout(() => el.focus(), 80);
}
}
// ===== SCAN RECENTS (localStorage) =====
const _SCAN_RECENTS_KEY = 'evershelf_scan_recents';
const _SCAN_RECENTS_MAX = 6;
function _getScanRecents() {
try { return JSON.parse(localStorage.getItem(_SCAN_RECENTS_KEY) || '[]'); } catch(_) { return []; }
}
function addToScanRecents(product) {
if (!product || !product.id) return;
let list = _getScanRecents().filter(r => r.id !== product.id);
list.unshift({ id: product.id, name: product.name, brand: product.brand || '', category: product.category || '' });
if (list.length > _SCAN_RECENTS_MAX) list = list.slice(0, _SCAN_RECENTS_MAX);
try { localStorage.setItem(_SCAN_RECENTS_KEY, JSON.stringify(list)); } catch(_) {}
}
function updateScanRecents() {
const list = _getScanRecents();
const wrap = document.getElementById('scan-recents');
const chips = document.getElementById('scan-recents-chips');
if (!wrap || !chips) return;
if (list.length === 0) { wrap.style.display = 'none'; return; }
wrap.style.display = 'flex';
chips.innerHTML = list.map(r => {
const icon = CATEGORY_ICONS[mapToLocalCategory(r.category, r.name)] || '📦';
const label = escapeHtml(r.name) + (r.brand ? ` <span style="color:var(--text-muted);font-weight:400">${escapeHtml(r.brand)}</span>` : '');
return `<button class="scan-recent-chip" onclick="_selectRecentProduct(${r.id})" title="${escapeHtml(r.name)}">
<span class="scan-recent-chip-icon">${icon}</span>${label}
</button>`;
}).join('');
}
async function _selectRecentProduct(productId) {
showLoading(true);
try {
const data = await api('product_get', { id: productId });
if (data.product) {
currentProduct = data.product;
if (!currentProduct.weight_info && currentProduct.notes) {
const m = currentProduct.notes.match(/Peso:\s*([^·]+)/);
if (m) currentProduct.weight_info = m[1].trim();
} }
showLoading(false);
stopScanner();
showProductAction();
} else {
showLoading(false);
showToast(t('error.not_found'), 'error');
} }
} catch(e) {
showLoading(false);
showToast(t('error.connection'), 'error');
}
}
// ===== SCAN LIVE CODE / CONFIRM OVERLAY =====
let _liveCodeTimer = null;
function _showScanLiveCode(code) {
const el = document.getElementById('scan-live-code');
if (!el) return;
el.textContent = code;
el.style.display = 'block';
clearTimeout(_liveCodeTimer);
_liveCodeTimer = setTimeout(() => { if (el) el.style.display = 'none'; }, 1500);
}
function _hideScanLiveCode() {
const el = document.getElementById('scan-live-code');
if (el) { el.style.display = 'none'; clearTimeout(_liveCodeTimer); }
}
function _showScanConfirm(name) {
const overlay = document.getElementById('scan-confirm-overlay');
const nameEl = document.getElementById('scan-confirm-name');
if (!overlay) return;
if (nameEl) nameEl.textContent = name || '';
overlay.style.display = 'flex';
setTimeout(() => { if (overlay) overlay.style.display = 'none'; }, 900);
}
// ===== AI NUMBER OCR (Gemini reads printed barcode digits) =====
let _numOcrRunning = false;
async function _tryGeminiNumberOCR() {
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'); }
try {
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];
const result = await api('gemini_number_ocr', {}, 'POST', { image: imageBase64 });
if (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');
}
} catch(e) {
showToast(t('error.connection'), 'error');
} finally {
_numOcrRunning = false;
if (btn) { btn.disabled = false; btn.textContent = t('scan.num_ocr_btn'); }
} }
} }
@@ -2594,7 +2742,7 @@ function showPage(pageId, param = null) {
} }
loadInventory(); loadInventory();
break; break;
case 'scan': initScanner(); clearQuickNameResults(); updateSpesaBanner(); case 'scan': initScanner(); clearQuickNameResults(); updateSpesaBanner(); updateScanRecents(); switchScanTab('barcode');
// Pre-warm the embedding model the first time user visits scan page // Pre-warm the embedding model the first time user visits scan page
if (typeof window._getCategoryPipeline === 'function' && !window._categoryPipelineReady) { if (typeof window._getCategoryPipeline === 'function' && !window._categoryPipelineReady) {
window._getCategoryPipeline(); // fire-and-forget window._getCategoryPipeline(); // fire-and-forget
@@ -4942,12 +5090,25 @@ async function initScanner() {
video.srcObject = stream; video.srcObject = stream;
await video.play(); await video.play();
scanLog(`Video playing — videoWidth: ${video.videoWidth}, videoHeight: ${video.videoHeight}`); scanLog(`Video playing — videoWidth: ${video.videoWidth}, videoHeight: ${video.videoHeight}`);
// Apply fixed 2x zoom
await _applyFixedZoom();
if (_useBarcodeDetector) { if (_useBarcodeDetector) {
startNativeScanner(video); startNativeScanner(video);
} else { } else {
startQuaggaScanner(video); startQuaggaScanner(video);
} }
// After 4s without a scan, reveal the AI number OCR fallback button
if (_geminiAvailable) {
setTimeout(() => {
if (scannerStream) { // still scanning
const btn = document.getElementById('scan-num-ocr-btn');
if (btn) btn.style.display = '';
}
}, 4000);
}
} catch (err) { } catch (err) {
scanLog(`CAMERA ERROR: ${err.name}: ${err.message}`); scanLog(`CAMERA ERROR: ${err.name}: ${err.message}`);
@@ -5024,6 +5185,7 @@ async function startNativeScanner(videoEl) {
partialCount++; partialCount++;
scanLog(`Native detect #${partialCount} [f${frameCount}]: ${code} (${format})`); scanLog(`Native detect #${partialCount} [f${frameCount}]: ${code} (${format})`);
updateFeedback('detecting'); updateFeedback('detecting');
_showScanLiveCode(code);
if (!detectionHistory[code]) detectionHistory[code] = { count: 0 }; if (!detectionHistory[code]) detectionHistory[code] = { count: 0 };
detectionHistory[code].count++; detectionHistory[code].count++;
@@ -5191,9 +5353,11 @@ function startQuaggaScanner(videoEl) {
quaggaRunning = false; quaggaRunning = false;
updateScannerFeedback(null); updateScannerFeedback(null);
scanLog(`CONFIRMED: ${code} [${passName2}] f${frameCount} consec:${detectCount} total:${dominated.count}`); scanLog(`CONFIRMED: ${code} [${passName2}] f${frameCount} consec:${detectCount} total:${dominated.count}`);
_hideScanLiveCode();
onBarcodeDetected(code); onBarcodeDetected(code);
return; return;
} }
_showScanLiveCode(code);
} else { } else {
updateScannerFeedback('scanning'); updateScannerFeedback('scanning');
} }
@@ -5235,16 +5399,19 @@ function enhanceCanvasForBarcode(ctx, w, h) {
function stopScanner() { function stopScanner() {
quaggaRunning = false; quaggaRunning = false;
_scanZoomLevel = 1; _scanZoomLevel = 2; // always 2x on next start
_torchActive = false;
if (scannerStream) { if (scannerStream) {
scannerStream.getTracks().forEach(t => t.stop()); scannerStream.getTracks().forEach(t => t.stop());
scannerStream = null; scannerStream = null;
} }
const video = document.getElementById('scanner-video'); const video = document.getElementById('scanner-video');
if (video) video.srcObject = null; if (video) { video.srcObject = null; video.style.transform = ''; }
const zoomBtn = document.getElementById('scan-zoom-btn'); // Reset torch button
if (zoomBtn) zoomBtn.textContent = 'x1'; const tb = document.getElementById('scan-torch-btn');
if (tb) tb.classList.remove('torch-on');
// Hide live code
_hideScanLiveCode();
// Also stop AI camera // Also stop AI camera
if (aiStream) { if (aiStream) {
aiStream.getTracks().forEach(t => t.stop()); aiStream.getTracks().forEach(t => t.stop());
@@ -5304,8 +5471,10 @@ async function onBarcodeDetected(barcode) {
if (detected.confCount) currentProduct._confCount = detected.confCount; if (detected.confCount) currentProduct._confCount = detected.confCount;
} }
showLoading(false); showLoading(false);
addToScanRecents(currentProduct);
_showScanConfirm(currentProduct.name);
stopScanner(); stopScanner();
showProductAction(); setTimeout(() => showProductAction(), 300);
return; return;
} }
@@ -5362,8 +5531,10 @@ async function onBarcodeDetected(barcode) {
stores: p.stores || '', stores: p.stores || '',
}; };
showLoading(false); showLoading(false);
addToScanRecents(currentProduct);
_showScanConfirm(currentProduct.name);
stopScanner(); stopScanner();
showProductAction(); setTimeout(() => showProductAction(), 300);
return; return;
} }
} }
+78 -25
View File
@@ -11,7 +11,7 @@
<title>EverShelf</title> <title>EverShelf</title>
<link rel="manifest" href="manifest.json"> <link rel="manifest" href="manifest.json">
<link rel="icon" type="image/png" href="assets/img/logo/logo_icon.png"> <link rel="icon" type="image/png" href="assets/img/logo/logo_icon.png">
<link rel="stylesheet" href="assets/css/style.css?v=20260511j"> <link rel="stylesheet" href="assets/css/style.css?v=20260512a">
<!-- QuaggaJS for barcode scanning --> <!-- QuaggaJS for barcode scanning -->
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2@1.8.4/dist/quagga.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2@1.8.4/dist/quagga.min.js"></script>
<!-- @xenova/transformers: ES-module bootstrap that exposes a lazy category-classifier as window._categoryPipelinePromise --> <!-- @xenova/transformers: ES-module bootstrap that exposes a lazy category-classifier as window._categoryPipelinePromise -->
@@ -213,7 +213,8 @@
<section class="page" id="page-scan"> <section class="page" id="page-scan">
<div class="page-header"> <div class="page-header">
<button class="back-btn" onclick="stopScanner(); showPage('dashboard')" data-i18n="btn.back">← Indietro</button> <button class="back-btn" onclick="stopScanner(); showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
<h2 data-i18n="scan.title">Scansiona Prodotto</h2> <h2 data-i18n="scan.title">Scansiona</h2>
<button class="scan-spesa-chip" id="scan-spesa-btn" onclick="startSpesaMode()" data-i18n="scan.spesa_btn">🛒 Spesa</button>
</div> </div>
<div class="spesa-mode-banner" id="spesa-mode-banner" style="display:none"> <div class="spesa-mode-banner" id="spesa-mode-banner" style="display:none">
<div class="spesa-banner-left"> <div class="spesa-banner-left">
@@ -223,39 +224,91 @@
<button class="btn btn-small" onclick="endSpesaMode()" data-i18n="scan.mode_shopping_end">✅ Fine spesa</button> <button class="btn btn-small" onclick="endSpesaMode()" data-i18n="scan.mode_shopping_end">✅ Fine spesa</button>
</div> </div>
<div class="scan-container"> <div class="scan-container">
<!-- Camera viewport -->
<div class="scanner-viewport" id="scanner-viewport"> <div class="scanner-viewport" id="scanner-viewport">
<!-- Guide corners -->
<div class="scan-guide-frame">
<div class="sgf-corner sgf-tl"></div>
<div class="sgf-corner sgf-tr"></div>
<div class="sgf-corner sgf-bl"></div>
<div class="sgf-corner sgf-br"></div>
</div>
<div class="scanner-overlay"> <div class="scanner-overlay">
<div class="scanner-line"></div> <div class="scanner-line"></div>
</div> </div>
<button class="scan-zoom-btn" id="scan-zoom-btn" onclick="toggleScanZoom()" title="Zoom">x1</button> <!-- Live partial code preview -->
<div class="scan-live-code" id="scan-live-code" style="display:none"></div>
<!-- Success flash overlay -->
<div class="scan-confirm-overlay" id="scan-confirm-overlay" style="display:none">
<div class="scan-confirm-check"></div>
<div class="scan-confirm-name" id="scan-confirm-name"></div>
</div>
<!-- Viewport inline controls (torch / zoom badge / flip) -->
<div class="scan-viewport-controls">
<button class="scan-ctrl-btn" id="scan-torch-btn" onclick="toggleTorch()" data-i18n-title="scan.torch_hint" title="Torcia">🔦</button>
<span class="scan-zoom-badge">2×</span>
<button class="scan-ctrl-btn" id="scan-flip-btn" onclick="flipCamera()" data-i18n-title="scan.flip_hint" title="Cambia fotocamera">🔄</button>
</div>
<video id="scanner-video" autoplay playsinline></video> <video id="scanner-video" autoplay playsinline></video>
<canvas id="scanner-canvas" style="display:none"></canvas> <canvas id="scanner-canvas" style="display:none"></canvas>
</div> </div>
<!-- Scan errors -->
<div class="scan-result" id="scan-result" style="display:none"></div> <div class="scan-result" id="scan-result" style="display:none"></div>
<div class="barcode-manual-entry">
<div class="barcode-input-row"> <!-- Recent scans -->
<input type="text" id="manual-barcode-input" class="form-input" placeholder="Inserisci codice a barre..." inputmode="numeric" pattern="[0-9]*" maxlength="14" oninput="autoSubmitEAN(this)" onkeydown="if(event.key==='Enter')submitManualBarcode()" data-i18n-placeholder="scan.barcode_placeholder"> <div class="scan-recents" id="scan-recents" style="display:none">
<button class="btn btn-primary" onclick="submitManualBarcode()" data-i18n="btn.search">🔍 Cerca</button> <span class="scan-recents-label" data-i18n="scan.recents_label">Recenti</span>
<div class="scan-recents-chips" id="scan-recents-chips"></div>
</div>
<!-- Input panel with tabs -->
<div class="scan-input-panel">
<div class="scan-input-tabs">
<button class="scan-input-tab active" id="scan-tab-barcode" onclick="switchScanTab('barcode')">
<span>📊</span><span data-i18n="scan.tab_barcode">Barcode</span>
</button>
<button class="scan-input-tab" id="scan-tab-name" onclick="switchScanTab('name')">
<span>✏️</span><span data-i18n="scan.tab_name">Nome</span>
</button>
<button class="scan-input-tab" id="scan-tab-ai" onclick="switchScanTab('ai')">
<span>🤖</span><span data-i18n="scan.tab_ai">AI</span>
</button>
</div>
<!-- Tab: Barcode manuale -->
<div class="scan-tab-content" id="scan-tabcontent-barcode">
<div class="barcode-input-row">
<input type="text" id="manual-barcode-input" class="form-input"
inputmode="numeric" pattern="[0-9]*" maxlength="14"
oninput="autoSubmitEAN(this)"
onkeydown="if(event.key==='Enter')submitManualBarcode()"
data-i18n-placeholder="scan.barcode_placeholder"
placeholder="Inserisci codice a barre...">
<button class="btn btn-primary" onclick="submitManualBarcode()">🔍</button>
</div>
</div>
<!-- Tab: Cerca per nome -->
<div class="scan-tab-content quick-name-entry" id="scan-tabcontent-name" style="display:none">
<div class="barcode-input-row">
<input type="text" id="quick-product-name" class="form-input"
list="common-products" autocomplete="off"
onkeydown="if(event.key==='Enter')submitQuickName()"
data-i18n-placeholder="scan.quick_name_placeholder"
placeholder="Es: Mele, Zucchine, Pane...">
<button class="btn btn-accent" onclick="submitQuickName()"></button>
</div>
</div>
<!-- Tab: AI e Manuale -->
<div class="scan-tab-content" id="scan-tabcontent-ai" style="display:none">
<div class="scan-ai-tab-btns">
<button class="btn btn-accent" onclick="captureForAI()" data-i18n="scan.ai_identify">🤖 Identifica con AI</button>
<button class="btn btn-secondary" onclick="startManualEntry()" data-i18n="scan.manual_entry">✏️ Inserimento Manuale</button>
</div>
<button class="btn scan-num-ocr-btn" id="scan-num-ocr-btn" style="display:none" onclick="_tryGeminiNumberOCR()" data-i18n="scan.num_ocr_btn">🔢 Leggi numeri con AI</button>
</div> </div>
</div> </div>
<div class="quick-name-entry"> <!-- Hidden debug log (accessible via Settings) -->
<div class="quick-name-divider"><span data-i18n="scan.quick_name_divider">oppure scrivi il nome</span></div>
<div class="barcode-input-row">
<input type="text" id="quick-product-name" class="form-input" placeholder="Es: Mele, Zucchine, Pane..." list="common-products" autocomplete="off" onkeydown="if(event.key==='Enter')submitQuickName()" data-i18n-placeholder="scan.quick_name_placeholder">
<button class="btn btn-accent" onclick="submitQuickName()" data-i18n="btn.go">✅ Vai</button>
</div>
</div>
<div class="scan-actions">
<button class="btn btn-large btn-secondary" onclick="startManualEntry()" data-i18n="scan.manual_entry">
✏️ Inserimento Manuale
</button>
<button class="btn btn-large btn-accent" onclick="captureForAI()" data-i18n="scan.ai_identify">
🤖 Identifica con AI
</button>
</div>
<p class="scan-hint" data-i18n="scan.hint">Scansiona il barcode, scrivi il nome del prodotto, oppure usa l'AI per identificarlo</p>
<div id="scan-debug-log" style="display:none;margin-top:12px;padding:10px;background:#1a1a2e;color:#0f0;font-family:monospace;font-size:0.7rem;max-height:200px;overflow-y:auto;border-radius:8px;white-space:pre-wrap"></div> <div id="scan-debug-log" style="display:none;margin-top:12px;padding:10px;background:#1a1a2e;color:#0f0;font-family:monospace;font-size:0.7rem;max-height:200px;overflow-y:auto;border-radius:8px;white-space:pre-wrap"></div>
<button class="btn btn-small btn-secondary" style="margin-top:8px;opacity:0.5" onclick="toggleScanDebug()">🐛 Debug Log</button>
</div> </div>
</section> </section>
@@ -1469,6 +1522,6 @@
</div> </div>
</div> </div>
<script src="assets/js/app.js?v=20260511j"></script> <script src="assets/js/app.js?v=20260512a"></script>
</body> </body>
</html> </html>
+17 -1
View File
@@ -170,10 +170,26 @@
"qty_trace": "< 1" "qty_trace": "< 1"
}, },
"scan": { "scan": {
"title": "Produkt scannen", "title": "Scannen",
"mode_shopping": "🛒 Einkaufsmodus", "mode_shopping": "🛒 Einkaufsmodus",
"mode_shopping_end": "✅ Einkauf beenden", "mode_shopping_end": "✅ Einkauf beenden",
"spesa_btn": "🛒 Einkauf",
"zoom": "Zoom", "zoom": "Zoom",
"tab_barcode": "Barcode",
"tab_name": "Name",
"tab_ai": "KI",
"recents_label": "Zuletzt",
"torch_hint": "Taschenlampe",
"torch_on": "Taschenlampe an",
"torch_off": "Taschenlampe aus",
"torch_unavailable": "Taschenlampe auf diesem Gerät nicht verfügbar",
"flip_hint": "Kamera wechseln",
"flip_front": "Frontkamera",
"flip_back": "Rückkamera",
"num_ocr_btn": "🔢 Zahlen mit KI lesen",
"num_ocr_searching": "Suche Barcode mit KI...",
"num_ocr_found": "Code gefunden: {code}",
"num_ocr_not_found": "Kein Barcode im Bild gefunden",
"barcode_placeholder": "Barcode eingeben...", "barcode_placeholder": "Barcode eingeben...",
"quick_name_divider": "oder Name eingeben", "quick_name_divider": "oder Name eingeben",
"quick_name_placeholder": "z.B.: Äpfel, Zucchini, Brot...", "quick_name_placeholder": "z.B.: Äpfel, Zucchini, Brot...",
+17 -1
View File
@@ -170,10 +170,26 @@
"qty_trace": "< 1" "qty_trace": "< 1"
}, },
"scan": { "scan": {
"title": "Scan Product", "title": "Scan",
"mode_shopping": "🛒 Shopping Mode", "mode_shopping": "🛒 Shopping Mode",
"mode_shopping_end": "✅ End shopping", "mode_shopping_end": "✅ End shopping",
"spesa_btn": "🛒 Shopping",
"zoom": "Zoom", "zoom": "Zoom",
"tab_barcode": "Barcode",
"tab_name": "Name",
"tab_ai": "AI",
"recents_label": "Recent",
"torch_hint": "Torch",
"torch_on": "Torch on",
"torch_off": "Torch off",
"torch_unavailable": "Torch not available on this device",
"flip_hint": "Flip camera",
"flip_front": "Front camera",
"flip_back": "Rear camera",
"num_ocr_btn": "🔢 Read numbers with AI",
"num_ocr_searching": "Looking for barcode with AI...",
"num_ocr_found": "Code found: {code}",
"num_ocr_not_found": "No barcode found in image",
"barcode_placeholder": "Enter barcode...", "barcode_placeholder": "Enter barcode...",
"quick_name_divider": "or type the name", "quick_name_divider": "or type the name",
"quick_name_placeholder": "E.g.: Apples, Zucchini, Bread...", "quick_name_placeholder": "E.g.: Apples, Zucchini, Bread...",
+17 -1
View File
@@ -170,10 +170,26 @@
"qty_trace": "< 1" "qty_trace": "< 1"
}, },
"scan": { "scan": {
"title": "Scansiona Prodotto", "title": "Scansiona",
"mode_shopping": "🛒 Modalità Spesa", "mode_shopping": "🛒 Modalità Spesa",
"mode_shopping_end": "✅ Fine spesa", "mode_shopping_end": "✅ Fine spesa",
"spesa_btn": "🛒 Spesa",
"zoom": "Zoom", "zoom": "Zoom",
"tab_barcode": "Barcode",
"tab_name": "Nome",
"tab_ai": "AI",
"recents_label": "Recenti",
"torch_hint": "Torcia",
"torch_on": "Torcia accesa",
"torch_off": "Torcia spenta",
"torch_unavailable": "Torcia non disponibile su questo dispositivo",
"flip_hint": "Cambia fotocamera",
"flip_front": "Fotocamera anteriore",
"flip_back": "Fotocamera posteriore",
"num_ocr_btn": "🔢 Leggi numeri con AI",
"num_ocr_searching": "Cerco il codice con AI...",
"num_ocr_found": "Codice trovato: {code}",
"num_ocr_not_found": "Nessun codice trovato nell'immagine",
"barcode_placeholder": "Inserisci codice a barre...", "barcode_placeholder": "Inserisci codice a barre...",
"quick_name_divider": "oppure scrivi il nome", "quick_name_divider": "oppure scrivi il nome",
"quick_name_placeholder": "Es: Mele, Zucchine, Pane...", "quick_name_placeholder": "Es: Mele, Zucchine, Pane...",