release: v1.7.11 — scan redesign, AI OCR, anomaly fixes
This commit is contained in:
@@ -5,6 +5,26 @@ All notable changes to EverShelf will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.7.11] - 2026-05-12
|
||||
|
||||
### Added
|
||||
- **Scan page redesign** — La pagina di scansione è stata completamente ridisegnata per tablet e mobile:
|
||||
- **2× zoom fisso** — zoom hardware se disponibile, altrimenti CSS `scale(2)` automatico.
|
||||
- **Torcia** — bottone nel viewport con feedback toast e stato visivo.
|
||||
- **Flip fotocamera** — switch front/back con persistenza in settings.
|
||||
- **3 tab input** — Barcode / Nome / AI per un accesso rapido a ciascuna modalità.
|
||||
- **Prodotti recenti** — chip degli ultimi 6 prodotti scansionati (localStorage), con icona categoria.
|
||||
- **Live code overlay** — codice barcode rilevato parzialmente mostrato in sovrimpressione nel viewport.
|
||||
- **Confirm overlay** — checkmark + nome prodotto per 900ms al riconoscimento avvenuto.
|
||||
- **Angoli guida** — frame visivo per inquadrare il barcode.
|
||||
- **AI Number OCR** — dopo 4s senza scansione, compare il bottone "Leggi numeri con AI": Gemini analizza l'immagine e legge le cifre del barcode anche se non viene letto otticamente.
|
||||
- **PHP `gemini_number_ocr`** — Nuovo endpoint POST; accetta un'immagine JPEG base64, chiede a Gemini di individuare il codice EAN-13 / EAN-8 stampato sul prodotto, e restituisce le cifre o `not_found`.
|
||||
|
||||
### Fixed
|
||||
- **Falsi positivi anomalia consumo "Mozzarella 3 pezzi"** — Rimossa la direzione `untracked` (consumo maggiore degli acquisti registrati) che generava banner su ogni prodotto con acquisti non tracciati. Ora vengono segnalate solo le anomalie `phantom` e `missing`.
|
||||
- **Predizione "~0g/settimana"** — Il modello richiedeva ora min 5 transazioni (era 3) e un arco temporale di almeno 7 giorni; se il consumo predetto è < 15% della baseline viene saltato, eliminando i falsi positivi su prodotti con poche transazioni ravvicinate.
|
||||
- **Menu a tendina suggerimenti sul campo Nome (scan)** — Rimosso `list="common-products"` dal campo di input, il datalist non viene più aperto su tablet.
|
||||
|
||||
## [1.7.10] - 2026-05-11
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -25,11 +25,17 @@
|
||||
[](https://www.sqlite.org/)
|
||||
[](Dockerfile)
|
||||
[](translations/)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
|
||||
---
|
||||
|
||||
## 🌍 Recent Updates (v1.7.10)
|
||||
## 🌍 Recent Updates (v1.7.11)
|
||||
|
||||
- **Scan page redesign** — La pagina di scansione è stata completamente ridisegnata: **2× zoom fisso** (hardware o CSS), **torcia** con feedback visivo, **flip fotocamera** (front/back), **3 tab input** (Barcode / Nome / AI), **prodotti recenti** (ultimi 6 in localStorage), **live code overlay** durante la scansione parziale, **confirm overlay** al successo, **angoli guida** nel viewport.
|
||||
- **AI Number OCR** — Dopo 4 secondi senza scansione compare il bottone "Leggi numeri con AI": Gemini analizza il frame video e restituisce le cifre del barcode anche quando lo scanner ottico non riesce a leggerlo.
|
||||
- **Fix falsi positivi anomalie** — Rimossa la direzione `untracked` dal rilevatore di anomalie; le predizioni di consumo richiedono ora min 5 transazioni e 7 giorni di storico.
|
||||
- **Fix menu suggerimenti scan** — Rimosso il datalist dal campo Nome nella pagina scansione (non più aperto su tablet).
|
||||
- **Fix falsi positivi anomalie consumo** — `getConsumptionPredictions` richiedeva solo 3 transazioni, potendo generare rate esplose su dati ravvicinati. Ora: min 5 txn, min 7gg span, skip se consumo predetto < 15% baseline.
|
||||
|
||||
- **Banner "Imposta scadenza" ora funziona** — Il pulsante sul banner "nessuna scadenza" apriva una funzione inesistente. Corretto, ora apre correttamente la modal di modifica.
|
||||
- **Banner aperto vs scaduto** — I prodotti con `opened_at` mostrano "Aperto da N giorni in [posizione]" invece di "Scaduto!", con la posizione (frigo/dispensa/freezer) esplicitamente indicata.
|
||||
|
||||
+58
-7
@@ -118,7 +118,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'];
|
||||
$aiActions = ['gemini_readExpiry', 'gemini_chat', 'gemini_identify', 'gemini_suggest_shopping', 'chat_to_recipe', 'recipe_from_ingredient', 'gemini_number_ocr'];
|
||||
$loginActions = [];
|
||||
$recipeActions = ['generate_recipe', 'generate_recipe_stream'];
|
||||
$errorActions = ['report_error', 'check_update'];
|
||||
@@ -454,6 +454,10 @@ try {
|
||||
geminiAnomalyExplain();
|
||||
break;
|
||||
|
||||
case 'gemini_number_ocr':
|
||||
geminiNumberOCR();
|
||||
break;
|
||||
|
||||
case 'get_shopping_price':
|
||||
getShoppingPrice($db);
|
||||
break;
|
||||
@@ -1807,11 +1811,12 @@ function getInventoryAnomalies(PDO $db): void {
|
||||
// so it stays dismissed until the user explicitly resets or the direction changes.
|
||||
// An inventory correction (bringing qty closer to expected) will flip the direction
|
||||
// or drop below threshold — naturally clearing the dismissed state.
|
||||
// If expected <= 0 it means more consumption recorded than purchases — the
|
||||
// transaction history is simply incomplete (very common: users track consumption
|
||||
// but not always purchases). Showing an anomaly here is just noise, skip it.
|
||||
if ($expected <= 0) continue;
|
||||
|
||||
$direction = $diff > 0 ? 'phantom' : 'missing';
|
||||
// Special case: expected is negative — more consumption recorded than entries.
|
||||
// The real qty vs tx comparison is meaningless; what we actually know is that
|
||||
// "initial stock was never formally registered as an 'in' transaction".
|
||||
if ($expected <= 0) $direction = 'untracked';
|
||||
$key = 'a_' . $r['product_id'] . '_' . $direction;
|
||||
if (!empty($dismissed[$key])) continue;
|
||||
$anomalies[] = [
|
||||
@@ -2116,7 +2121,7 @@ function getConsumptionPredictions(PDO $db): void {
|
||||
$txns->execute([$pid, $loc]);
|
||||
$rows = $txns->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (count($rows) < 3) continue; // Need at least 3 data points
|
||||
if (count($rows) < 5) continue; // Need at least 5 data points for a reliable rate
|
||||
|
||||
// Calculate average daily consumption
|
||||
$totalUsed = 0;
|
||||
@@ -2124,7 +2129,9 @@ function getConsumptionPredictions(PDO $db): void {
|
||||
|
||||
$firstDate = strtotime($rows[0]['created_at']);
|
||||
$lastDate = strtotime($rows[count($rows) - 1]['created_at']);
|
||||
$daySpan = max(1, ($lastDate - $firstDate) / 86400);
|
||||
$daySpan = ($lastDate - $firstDate) / 86400;
|
||||
// If all transactions are clustered within a week, the rate is unreliable
|
||||
if ($daySpan < 7) continue;
|
||||
$dailyRate = $totalUsed / $daySpan;
|
||||
|
||||
if ($dailyRate < 0.01) continue; // negligible consumption
|
||||
@@ -2159,6 +2166,12 @@ function getConsumptionPredictions(PDO $db): void {
|
||||
$baselineQty = floatval($item['quantity']) + $usedSinceRestock;
|
||||
$daysSinceRestock = max(1, (time() - $restockDate) / 86400);
|
||||
|
||||
// If the model predicts you should have consumed less than 15% of baseline
|
||||
// in this period, the daily rate is too low to make reliable predictions:
|
||||
// any single normal use will look like an anomaly. Skip it.
|
||||
$predictedConsumption = $dailyRate * $daysSinceRestock;
|
||||
if ($baselineQty > 0 && $predictedConsumption < $baselineQty * 0.15) continue;
|
||||
|
||||
// Predicted remaining qty = baseline - (daily rate * days since restock)
|
||||
$expectedQty = max(0, $baselineQty - ($dailyRate * $daysSinceRestock));
|
||||
$actualQty = floatval($item['quantity']);
|
||||
@@ -7278,6 +7291,44 @@ function geminiShoppingEnrich(PDO $db): void {
|
||||
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 =======================================
|
||||
// =============================================================================
|
||||
|
||||
+242
-100
@@ -1596,39 +1596,35 @@ body.server-offline .bottom-nav {
|
||||
.scan-container {
|
||||
display: flex;
|
||||
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 {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 4/3;
|
||||
aspect-ratio: 16/9;
|
||||
background: #000;
|
||||
border-radius: var(--radius);
|
||||
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 {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -1637,6 +1633,26 @@ body.server-offline .bottom-nav {
|
||||
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 {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
@@ -1647,7 +1663,6 @@ body.server-offline .bottom-nav {
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.scanner-line {
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
@@ -1656,37 +1671,127 @@ body.server-offline .bottom-nav {
|
||||
animation: scanLine 2s ease-in-out infinite;
|
||||
transition: background 0.2s, box-shadow 0.2s, height 0.2s;
|
||||
}
|
||||
|
||||
/* While Quagga is actively scanning frames */
|
||||
.scanner-line.scanning {
|
||||
background: #00c853;
|
||||
box-shadow: 0 0 12px #00c853;
|
||||
animation: scanLineActive 0.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Barcode partially detected — strong pulse */
|
||||
.scanner-line.detecting {
|
||||
background: #ffd600;
|
||||
box-shadow: 0 0 20px #ffd600, 0 0 40px rgba(255,214,0,0.4);
|
||||
height: 5px;
|
||||
animation: scanLineDetect 0.3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes scanLine {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
@keyframes scanLineActive {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
@keyframes scanLineDetect {
|
||||
0%, 100% { opacity: 1; transform: scaleY(1); }
|
||||
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 {
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius);
|
||||
@@ -1694,64 +1799,126 @@ body.server-offline .bottom-nav {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.scan-actions {
|
||||
/* — Recent scans — */
|
||||
.scan-recents {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.barcode-manual-entry {
|
||||
margin-bottom: 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.scan-recents-label {
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.barcode-input-row .form-input {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.barcode-input-row .btn {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.quick-name-entry {
|
||||
margin-bottom: 12px;
|
||||
/* — AI tab buttons — */
|
||||
.scan-ai-tab-btns {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.scan-ai-tab-btns .btn { width: 100%; }
|
||||
|
||||
.quick-name-divider {
|
||||
text-align: center;
|
||||
margin: 10px 0 8px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.quick-name-divider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
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;
|
||||
/* — AI number OCR fallback button — */
|
||||
.scan-num-ocr-btn {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
background: rgba(124,58,237,0.08);
|
||||
border: 1.5px dashed var(--accent);
|
||||
color: var(--accent);
|
||||
font-size: 0.88rem;
|
||||
padding: 9px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.scan-num-ocr-btn:active { background: rgba(124,58,237,0.18); }
|
||||
|
||||
/* — Quick name results (dropdown inside name tab) — */
|
||||
.quick-name-results {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
@@ -1760,56 +1927,31 @@ body.server-offline .bottom-nav {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.quick-name-result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
background: var(--bg-main);
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
border: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
|
||||
.quick-name-result-item:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.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:active { transform: scale(0.98); }
|
||||
.quick-name-result-item .qnr-icon { font-size: 1.4rem; flex-shrink: 0; }
|
||||
.quick-name-result-item .qnr-info { flex: 1; min-width: 0; }
|
||||
.quick-name-result-item .qnr-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
font-size: 0.92rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.quick-name-result-item .qnr-detail {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.quick-name-result-item .qnr-detail { font-size: 0.78rem; color: var(--text-muted); }
|
||||
.quick-name-result-item.qnr-new {
|
||||
border: 1px dashed var(--accent);
|
||||
background: rgba(124, 58, 237, 0.06);
|
||||
}
|
||||
|
||||
.scan-hint {
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
background: rgba(124,58,237,0.05);
|
||||
}
|
||||
|
||||
/* ===== SHOPPING LIST (BRING!) ===== */
|
||||
|
||||
+200
-34
@@ -1774,28 +1774,176 @@ let currentLocation = '';
|
||||
let scannerStream = null;
|
||||
let quaggaRunning = false;
|
||||
let aiStream = null;
|
||||
let _scanZoomLevel = 1; // 1 or 2
|
||||
let _scanZoomLevel = 2; // always 2x
|
||||
let _torchActive = false;
|
||||
|
||||
async function toggleScanZoom() {
|
||||
_scanZoomLevel = _scanZoomLevel === 1 ? 2 : 1;
|
||||
const btn = document.getElementById('scan-zoom-btn');
|
||||
if (btn) btn.textContent = `x${_scanZoomLevel}`;
|
||||
if (scannerStream) {
|
||||
const track = scannerStream.getVideoTracks()[0];
|
||||
if (track) {
|
||||
const caps = track.getCapabilities ? track.getCapabilities() : {};
|
||||
if (caps.zoom) {
|
||||
// Hardware zoom (Android Chrome)
|
||||
const z = _scanZoomLevel === 2
|
||||
? Math.min(caps.zoom.max, caps.zoom.min * 2 || 2)
|
||||
: caps.zoom.min;
|
||||
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');
|
||||
if (video) video.style.transform = _scanZoomLevel === 2 ? 'scale(2)' : 'scale(1)';
|
||||
// Apply fixed 2x zoom (hardware if available, CSS fallback)
|
||||
async function _applyFixedZoom() {
|
||||
if (!scannerStream) return;
|
||||
const track = scannerStream.getVideoTracks()[0];
|
||||
if (!track) return;
|
||||
const caps = track.getCapabilities ? track.getCapabilities() : {};
|
||||
if (caps.zoom && caps.zoom.max >= 2) {
|
||||
const z = Math.min(caps.zoom.max, caps.zoom.min * 2);
|
||||
try { await track.applyConstraints({ advanced: [{ zoom: z }] }); scanLog(`HW zoom: ${z}`); } catch(e) {}
|
||||
} else {
|
||||
const video = document.getElementById('scanner-video');
|
||||
if (video) video.style.transform = 'scale(2)';
|
||||
scanLog('SW zoom: scale(2)');
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleTorch() {
|
||||
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();
|
||||
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
|
||||
if (typeof window._getCategoryPipeline === 'function' && !window._categoryPipelineReady) {
|
||||
window._getCategoryPipeline(); // fire-and-forget
|
||||
@@ -3897,14 +4045,9 @@ function renderBannerItem() {
|
||||
} else if (entry.type === 'anomaly') {
|
||||
const an = entry.data;
|
||||
const isPhantom = an.direction === 'phantom';
|
||||
const isUntracked = an.direction === 'untracked';
|
||||
banner.className = 'alert-banner banner-anomaly';
|
||||
iconEl.textContent = '🔍';
|
||||
if (isUntracked) {
|
||||
// More consumption recorded than entries — initial stock was never registered
|
||||
titleEl.textContent = `${an.name} — ${t('dashboard.banner_anomaly_untracked_title')}`;
|
||||
detailEl.innerHTML = t('dashboard.banner_anomaly_untracked_detail', { inv_qty: an.inv_qty, unit: an.unit });
|
||||
} else if (isPhantom) {
|
||||
if (isPhantom) {
|
||||
titleEl.textContent = `${an.name} — ${t('dashboard.banner_anomaly_phantom_title')}`;
|
||||
detailEl.innerHTML = t('dashboard.banner_anomaly_phantom_detail', { inv_qty: an.inv_qty, unit: an.unit, expected_qty: an.expected_qty });
|
||||
} else {
|
||||
@@ -4947,12 +5090,25 @@ async function initScanner() {
|
||||
video.srcObject = stream;
|
||||
await video.play();
|
||||
scanLog(`Video playing — videoWidth: ${video.videoWidth}, videoHeight: ${video.videoHeight}`);
|
||||
|
||||
|
||||
// Apply fixed 2x zoom
|
||||
await _applyFixedZoom();
|
||||
|
||||
if (_useBarcodeDetector) {
|
||||
startNativeScanner(video);
|
||||
} else {
|
||||
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) {
|
||||
scanLog(`CAMERA ERROR: ${err.name}: ${err.message}`);
|
||||
@@ -5029,6 +5185,7 @@ async function startNativeScanner(videoEl) {
|
||||
partialCount++;
|
||||
scanLog(`Native detect #${partialCount} [f${frameCount}]: ${code} (${format})`);
|
||||
updateFeedback('detecting');
|
||||
_showScanLiveCode(code);
|
||||
|
||||
if (!detectionHistory[code]) detectionHistory[code] = { count: 0 };
|
||||
detectionHistory[code].count++;
|
||||
@@ -5196,9 +5353,11 @@ function startQuaggaScanner(videoEl) {
|
||||
quaggaRunning = false;
|
||||
updateScannerFeedback(null);
|
||||
scanLog(`CONFIRMED: ${code} [${passName2}] f${frameCount} consec:${detectCount} total:${dominated.count}`);
|
||||
_hideScanLiveCode();
|
||||
onBarcodeDetected(code);
|
||||
return;
|
||||
}
|
||||
_showScanLiveCode(code);
|
||||
} else {
|
||||
updateScannerFeedback('scanning');
|
||||
}
|
||||
@@ -5240,16 +5399,19 @@ function enhanceCanvasForBarcode(ctx, w, h) {
|
||||
|
||||
function stopScanner() {
|
||||
quaggaRunning = false;
|
||||
_scanZoomLevel = 1;
|
||||
_scanZoomLevel = 2; // always 2x on next start
|
||||
_torchActive = false;
|
||||
if (scannerStream) {
|
||||
scannerStream.getTracks().forEach(t => t.stop());
|
||||
scannerStream = null;
|
||||
}
|
||||
const video = document.getElementById('scanner-video');
|
||||
if (video) video.srcObject = null;
|
||||
const zoomBtn = document.getElementById('scan-zoom-btn');
|
||||
if (zoomBtn) zoomBtn.textContent = 'x1';
|
||||
|
||||
if (video) { video.srcObject = null; video.style.transform = ''; }
|
||||
// Reset torch button
|
||||
const tb = document.getElementById('scan-torch-btn');
|
||||
if (tb) tb.classList.remove('torch-on');
|
||||
// Hide live code
|
||||
_hideScanLiveCode();
|
||||
// Also stop AI camera
|
||||
if (aiStream) {
|
||||
aiStream.getTracks().forEach(t => t.stop());
|
||||
@@ -5309,8 +5471,10 @@ async function onBarcodeDetected(barcode) {
|
||||
if (detected.confCount) currentProduct._confCount = detected.confCount;
|
||||
}
|
||||
showLoading(false);
|
||||
addToScanRecents(currentProduct);
|
||||
_showScanConfirm(currentProduct.name);
|
||||
stopScanner();
|
||||
showProductAction();
|
||||
setTimeout(() => showProductAction(), 300);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5367,8 +5531,10 @@ async function onBarcodeDetected(barcode) {
|
||||
stores: p.stores || '',
|
||||
};
|
||||
showLoading(false);
|
||||
addToScanRecents(currentProduct);
|
||||
_showScanConfirm(currentProduct.name);
|
||||
stopScanner();
|
||||
showProductAction();
|
||||
setTimeout(() => showProductAction(), 300);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
+78
-25
@@ -11,7 +11,7 @@
|
||||
<title>EverShelf</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<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 -->
|
||||
<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 -->
|
||||
@@ -213,7 +213,8 @@
|
||||
<section class="page" id="page-scan">
|
||||
<div class="page-header">
|
||||
<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 class="spesa-mode-banner" id="spesa-mode-banner" style="display:none">
|
||||
<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>
|
||||
</div>
|
||||
<div class="scan-container">
|
||||
<!-- Camera 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-line"></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>
|
||||
<canvas id="scanner-canvas" style="display:none"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- Scan errors -->
|
||||
<div class="scan-result" id="scan-result" style="display:none"></div>
|
||||
<div class="barcode-manual-entry">
|
||||
<div class="barcode-input-row">
|
||||
<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">
|
||||
<button class="btn btn-primary" onclick="submitManualBarcode()" data-i18n="btn.search">🔍 Cerca</button>
|
||||
|
||||
<!-- Recent scans -->
|
||||
<div class="scan-recents" id="scan-recents" style="display:none">
|
||||
<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"
|
||||
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 class="quick-name-entry">
|
||||
<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>
|
||||
<!-- Hidden debug log (accessible via Settings) -->
|
||||
<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>
|
||||
</section>
|
||||
|
||||
@@ -1469,6 +1522,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="assets/js/app.js?v=20260511j"></script>
|
||||
<script src="assets/js/app.js?v=20260512a"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+17
-1
@@ -170,10 +170,26 @@
|
||||
"qty_trace": "< 1"
|
||||
},
|
||||
"scan": {
|
||||
"title": "Produkt scannen",
|
||||
"title": "Scannen",
|
||||
"mode_shopping": "🛒 Einkaufsmodus",
|
||||
"mode_shopping_end": "✅ Einkauf beenden",
|
||||
"spesa_btn": "🛒 Einkauf",
|
||||
"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...",
|
||||
"quick_name_divider": "oder Name eingeben",
|
||||
"quick_name_placeholder": "z.B.: Äpfel, Zucchini, Brot...",
|
||||
|
||||
+17
-1
@@ -170,10 +170,26 @@
|
||||
"qty_trace": "< 1"
|
||||
},
|
||||
"scan": {
|
||||
"title": "Scan Product",
|
||||
"title": "Scan",
|
||||
"mode_shopping": "🛒 Shopping Mode",
|
||||
"mode_shopping_end": "✅ End shopping",
|
||||
"spesa_btn": "🛒 Shopping",
|
||||
"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...",
|
||||
"quick_name_divider": "or type the name",
|
||||
"quick_name_placeholder": "E.g.: Apples, Zucchini, Bread...",
|
||||
|
||||
+17
-1
@@ -170,10 +170,26 @@
|
||||
"qty_trace": "< 1"
|
||||
},
|
||||
"scan": {
|
||||
"title": "Scansiona Prodotto",
|
||||
"title": "Scansiona",
|
||||
"mode_shopping": "🛒 Modalità Spesa",
|
||||
"mode_shopping_end": "✅ Fine spesa",
|
||||
"spesa_btn": "🛒 Spesa",
|
||||
"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...",
|
||||
"quick_name_divider": "oppure scrivi il nome",
|
||||
"quick_name_placeholder": "Es: Mele, Zucchine, Pane...",
|
||||
|
||||
Reference in New Issue
Block a user