feat: barcode scan visual feedback + EAN checksum validation

- Add scan-status-bar overlay inside scanner viewport showing:
  - Active scan method (Native API / Quagga / Native + Quagga)
  - Scanning state: ready, scanning, partial read, invalid, confirmed
- Validate EAN-8/EAN-13/UPC checksums in Quagga path before confirming
  (native BarcodeDetector validates internally; Quagga can return false positives)
- Show 'invalid barcode, retrying' message with invalid code highlighted
- Reset invalid barcode confidence counter on invalid read so scanner retries
- Spawn parallel Quagga scan with 'combined scan active' status message
- Add 6 translation keys (scan.status_*) in all 5 language files
This commit is contained in:
dadaloop82
2026-05-27 05:26:47 +00:00
parent b83db76a8d
commit bc39361246
8 changed files with 117 additions and 8 deletions
+40
View File
@@ -1969,6 +1969,46 @@ body.server-offline .bottom-nav {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
/* — Scan status bar — */
.scan-status-bar {
position: absolute;
bottom: 38px;
left: 0;
right: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
pointer-events: none;
z-index: 12;
}
.scan-status-method {
font-size: 0.58rem;
color: rgba(255,255,255,0.45);
text-transform: uppercase;
letter-spacing: 0.07em;
font-family: monospace;
}
.scan-status-msg {
font-size: 0.74rem;
color: rgba(255,255,255,0.9);
background: rgba(0,0,0,0.55);
padding: 3px 10px;
border-radius: 12px;
text-align: center;
max-width: 92%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: color 0.2s, background 0.2s;
backdrop-filter: blur(3px);
}
.scan-status-msg:empty { visibility: hidden; }
.scan-status-msg.state-partial { color: #fbbf24; }
.scan-status-msg.state-invalid { color: #f87171; background: rgba(239,68,68,0.28); }
.scan-status-msg.state-confirmed { color: #4ade80; background: rgba(74,222,128,0.22); }
.scan-status-msg.state-retry { color: #fb923c; }
/* — Viewport overlay controls (torch / zoom / flip) — */ /* — Viewport overlay controls (torch / zoom / flip) — */
.scan-viewport-controls { .scan-viewport-controls {
position: absolute; position: absolute;
+37 -3
View File
@@ -6420,6 +6420,9 @@ async function initScanner() {
// Apply fixed 2x zoom // Apply fixed 2x zoom
await _applyFixedZoom(); await _applyFixedZoom();
_invalidBarcodeCount = 0;
_setScanStatus(t('scan.status_ready'), '', '');
if (_useBarcodeDetector) { if (_useBarcodeDetector) {
startNativeScanner(video); startNativeScanner(video);
} else { } else {
@@ -6460,6 +6463,19 @@ function validateEANChecksum(code) {
return check === last; return check === last;
} }
// ===== SCAN STATUS BAR =====
let _invalidBarcodeCount = 0;
function _setScanStatus(msg, state, method) {
const msgEl = document.getElementById('scan-status-msg');
const methodEl = document.getElementById('scan-status-method');
if (msgEl) {
msgEl.textContent = msg || '';
msgEl.className = 'scan-status-msg' + (state ? ' state-' + state : '');
}
if (methodEl && method !== undefined) methodEl.textContent = method || '';
}
// ===== NATIVE BarcodeDetector SCANNER ===== // ===== NATIVE BarcodeDetector SCANNER =====
async function startNativeScanner(videoEl) { async function startNativeScanner(videoEl) {
if (quaggaRunning) return; if (quaggaRunning) return;
@@ -6491,14 +6507,18 @@ async function startNativeScanner(videoEl) {
if (!scanning || !scannerStream) return; if (!scanning || !scannerStream) return;
frameCount++; frameCount++;
if (frameCount === 1) updateFeedback('scanning'); if (frameCount === 1) {
updateFeedback('scanning');
_setScanStatus(t('scan.status_scanning'), '', 'Native API');
}
// After 2s without detection, also start Quagga in parallel as backup // After 2s without detection, also start Quagga in parallel as backup
if (!quaggaParallelStarted && (Date.now() - startTime) > 2000) { if (!quaggaParallelStarted && (Date.now() - startTime) > 2000) {
quaggaParallelStarted = true; quaggaParallelStarted = true;
scanLog('Native: 2s elapsed, spawning Quagga in parallel'); scanLog('Native: 2s elapsed, spawning Quagga in parallel');
_setScanStatus(t('scan.status_parallel'), 'retry', 'Native + Quagga');
quaggaRunning = false; // temporarily release so Quagga can start quaggaRunning = false; // temporarily release so Quagga can start
startQuaggaScanner(videoEl); startQuaggaScanner(videoEl, false);
quaggaRunning = true; // re-take ownership (Quagga will share) quaggaRunning = true; // re-take ownership (Quagga will share)
} }
@@ -6512,6 +6532,7 @@ async function startNativeScanner(videoEl) {
scanLog(`Native detect #${partialCount} [f${frameCount}]: ${code} (${format})`); scanLog(`Native detect #${partialCount} [f${frameCount}]: ${code} (${format})`);
updateFeedback('detecting'); updateFeedback('detecting');
_showScanLiveCode(code); _showScanLiveCode(code);
_setScanStatus(t('scan.status_partial').replace('{code}', code), 'partial');
if (!detectionHistory[code]) detectionHistory[code] = { count: 0 }; if (!detectionHistory[code]) detectionHistory[code] = { count: 0 };
detectionHistory[code].count++; detectionHistory[code].count++;
@@ -6531,6 +6552,7 @@ async function startNativeScanner(videoEl) {
quaggaRunning = false; quaggaRunning = false;
updateFeedback(null); updateFeedback(null);
scanLog(`CONFIRMED: ${code} after ${frameCount} frames (${format})`); scanLog(`CONFIRMED: ${code} after ${frameCount} frames (${format})`);
_setScanStatus(t('scan.status_confirmed'), 'confirmed');
onBarcodeDetected(code); onBarcodeDetected(code);
return; return;
} }
@@ -6553,7 +6575,7 @@ async function startNativeScanner(videoEl) {
} }
// ===== QUAGGA FALLBACK SCANNER ===== // ===== QUAGGA FALLBACK SCANNER =====
function startQuaggaScanner(videoEl) { function startQuaggaScanner(videoEl, isPrimary = true) {
if (quaggaRunning) return; if (quaggaRunning) return;
const canvas = document.getElementById('scanner-canvas'); const canvas = document.getElementById('scanner-canvas');
@@ -6619,6 +6641,7 @@ function startQuaggaScanner(videoEl) {
if (frameCount === 1) { if (frameCount === 1) {
scanLog(`Frame #1 — video: ${videoEl.videoWidth}x${videoEl.videoHeight}`); scanLog(`Frame #1 — video: ${videoEl.videoWidth}x${videoEl.videoHeight}`);
updateScannerFeedback('scanning'); updateScannerFeedback('scanning');
if (isPrimary) _setScanStatus(t('scan.status_scanning'), '', 'Quagga');
} }
let callbackCalled = false; let callbackCalled = false;
@@ -6675,15 +6698,26 @@ function startQuaggaScanner(videoEl) {
// EAN/UPC: confirm on first hit (checksum validated) // EAN/UPC: confirm on first hit (checksum validated)
const highConf = ['ean_reader','ean_8_reader','upc_reader','upc_e_reader'].includes(format); const highConf = ['ean_reader','ean_8_reader','upc_reader','upc_e_reader'].includes(format);
if (highConf || detectCount >= 2 || dominated.count >= 2) { if (highConf || detectCount >= 2 || dominated.count >= 2) {
// Validate EAN/UPC checksum — Quagga can occasionally return false positives
if (highConf && !validateEANChecksum(code)) {
_invalidBarcodeCount++;
scanLog(`Invalid EAN checksum: ${code} (retry #${_invalidBarcodeCount})`);
_setScanStatus(t('scan.status_invalid').replace('{code}', code), 'invalid', 'Quagga');
lastDetected = ''; detectCount = 0; // reset confidence
if (scanning) setTimeout(scanFrame, 60);
return;
}
scanning = false; scanning = false;
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(); _hideScanLiveCode();
_setScanStatus(t('scan.status_confirmed'), 'confirmed');
onBarcodeDetected(code); onBarcodeDetected(code);
return; return;
} }
_showScanLiveCode(code); _showScanLiveCode(code);
_setScanStatus(t('scan.status_partial').replace('{code}', code), 'partial');
} else { } else {
updateScannerFeedback('scanning'); updateScannerFeedback('scanning');
} }
+5
View File
@@ -251,6 +251,11 @@
</div> </div>
<!-- Live partial code preview --> <!-- Live partial code preview -->
<div class="scan-live-code" id="scan-live-code" style="display:none"></div> <div class="scan-live-code" id="scan-live-code" style="display:none"></div>
<!-- Scan status bar -->
<div class="scan-status-bar" id="scan-status-bar">
<span id="scan-status-method" class="scan-status-method"></span>
<span id="scan-status-msg" class="scan-status-msg" data-i18n="scan.status_ready"></span>
</div>
<!-- Success flash overlay --> <!-- Success flash overlay -->
<div class="scan-confirm-overlay" id="scan-confirm-overlay" style="display:none"> <div class="scan-confirm-overlay" id="scan-confirm-overlay" style="display:none">
<div class="scan-confirm-check"></div> <div class="scan-confirm-check"></div>
+7 -1
View File
@@ -214,7 +214,13 @@
"scan_barcode": "🔖 Barcode scannen", "scan_barcode": "🔖 Barcode scannen",
"create_named": "{name} erstellen", "create_named": "{name} erstellen",
"new_without_barcode": "Neues Produkt ohne Barcode", "new_without_barcode": "Neues Produkt ohne Barcode",
"stock_in_pantry": "Bereits im Vorrat:" "stock_in_pantry": "Bereits im Vorrat:",
"status_ready": "Kamera auf Barcode richten",
"status_scanning": "Scanne...",
"status_partial": "Erkannt: {code} — prüfe...",
"status_invalid": "Ungültig: {code} — versuche erneut",
"status_confirmed": "Bestätigt!",
"status_parallel": "Kombinierter Scan aktiv..."
}, },
"action": { "action": {
"title": "Was möchtest du tun?", "title": "Was möchtest du tun?",
+7 -1
View File
@@ -214,7 +214,13 @@
"scan_barcode": "🔖 Scan Barcode", "scan_barcode": "🔖 Scan Barcode",
"create_named": "Create {name}", "create_named": "Create {name}",
"new_without_barcode": "New product without barcode", "new_without_barcode": "New product without barcode",
"stock_in_pantry": "Already in pantry:" "stock_in_pantry": "Already in pantry:",
"status_ready": "Point camera at barcode",
"status_scanning": "Scanning...",
"status_partial": "Detected: {code} — verifying...",
"status_invalid": "Invalid: {code} — retrying",
"status_confirmed": "Confirmed!",
"status_parallel": "Using combined scan methods..."
}, },
"action": { "action": {
"title": "What do you want to do?", "title": "What do you want to do?",
+7 -1
View File
@@ -211,7 +211,13 @@
"barcode_acquired": "🔖 Código de barras escaneado: {code}", "barcode_acquired": "🔖 Código de barras escaneado: {code}",
"scan_barcode": "🔖 Escanear código de barras", "scan_barcode": "🔖 Escanear código de barras",
"create_named": "Crear {name}", "create_named": "Crear {name}",
"new_without_barcode": "Nuevo producto sin código de barras" "new_without_barcode": "Nuevo producto sin código de barras",
"status_ready": "Apunta la cámara al código de barras",
"status_scanning": "Escaneando...",
"status_partial": "Detectado: {code} — verificando...",
"status_invalid": "Inválido: {code} — reintentando",
"status_confirmed": "Confirmado!",
"status_parallel": "Escaneo combinado activo..."
}, },
"action": { "action": {
"title": "¿Qué quieres hacer?", "title": "¿Qué quieres hacer?",
+7 -1
View File
@@ -211,7 +211,13 @@
"barcode_acquired": "🔖 Code-barres scanné : {code}", "barcode_acquired": "🔖 Code-barres scanné : {code}",
"scan_barcode": "🔖 Scanner le code-barres", "scan_barcode": "🔖 Scanner le code-barres",
"create_named": "Créer {name}", "create_named": "Créer {name}",
"new_without_barcode": "Nouveau produit sans code-barres" "new_without_barcode": "Nouveau produit sans code-barres",
"status_ready": "Pointez la caméra sur le code-barres",
"status_scanning": "Scan en cours...",
"status_partial": "Lu : {code} — vérification...",
"status_invalid": "Invalide : {code} — nouvel essai",
"status_confirmed": "Confirmé !",
"status_parallel": "Scan combiné actif..."
}, },
"action": { "action": {
"title": "Que voulez-vous faire ?", "title": "Que voulez-vous faire ?",
+7 -1
View File
@@ -214,7 +214,13 @@
"scan_barcode": "🔖 Scansiona Barcode", "scan_barcode": "🔖 Scansiona Barcode",
"create_named": "Crea {name}", "create_named": "Crea {name}",
"new_without_barcode": "Nuovo prodotto senza barcode", "new_without_barcode": "Nuovo prodotto senza barcode",
"stock_in_pantry": "Hai gia in dispensa:" "stock_in_pantry": "Hai gia in dispensa:",
"status_ready": "Inquadra il codice a barre",
"status_scanning": "Scansione in corso...",
"status_partial": "Letto: {code} — verifico...",
"status_invalid": "Non valido: {code} — riprovo",
"status_confirmed": "Confermato!",
"status_parallel": "Doppia scansione attiva..."
}, },
"action": { "action": {
"title": "Cosa vuoi fare?", "title": "Cosa vuoi fare?",