diff --git a/api/index.php b/api/index.php index 51bd69d..3f331d4 100644 --- a/api/index.php +++ b/api/index.php @@ -189,6 +189,10 @@ try { getStats($db); break; + case 'consumption_predictions': + getConsumptionPredictions($db); + break; + // ===== AI ===== case 'gemini_expiry': geminiReadExpiry(); @@ -936,6 +940,8 @@ function useFromInventory(PDO $db): void { } $newQty = max(0, $existing['quantity'] - $quantity); + // Cap actual deducted quantity to what was available (prevent phantom over-deduction) + $actualDeducted = min($quantity, $existing['quantity']); if ($newQty <= 0) { $stmt = $db->prepare("DELETE FROM inventory WHERE id = ?"); @@ -974,10 +980,10 @@ function useFromInventory(PDO $db): void { } } - // Log transaction + // Log transaction (actual amount removed, not requested) $type = ($notes === 'Buttato') ? 'waste' : 'out'; $stmt = $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, ?, ?, ?, ?)"); - $stmt->execute([$productId, $type, $quantity, $location, $notes]); + $stmt->execute([$productId, $type, $actualDeducted, $location, $notes]); $remaining = $newQty; @@ -1269,10 +1275,115 @@ function getStats(PDO $db): void { ]); } +// ===== CONSUMPTION PREDICTIONS ===== + +/** + * Analyze transaction history to predict expected quantity of each product + * and flag items whose current quantity deviates significantly from the prediction. + */ +function getConsumptionPredictions(PDO $db): void { + // Get all current inventory items with their consumption history + $items = $db->query(" + SELECT i.id AS inventory_id, i.product_id, i.quantity, i.location, + p.name, p.brand, p.unit, p.default_quantity, p.package_unit, + i.updated_at + FROM inventory i + JOIN products p ON p.id = i.product_id + WHERE i.quantity > 0 + ")->fetchAll(PDO::FETCH_ASSOC); + + $predictions = []; + + foreach ($items as $item) { + $pid = $item['product_id']; + $loc = $item['location']; + + // Get last 90 days of 'out' transactions for this product+location + $txns = $db->prepare(" + SELECT quantity, created_at + FROM transactions + WHERE product_id = ? AND location = ? AND type = 'out' + AND created_at >= datetime('now', '-90 days') + ORDER BY created_at ASC + "); + $txns->execute([$pid, $loc]); + $rows = $txns->fetchAll(PDO::FETCH_ASSOC); + + if (count($rows) < 3) continue; // Need at least 3 data points + + // Calculate average daily consumption + $totalUsed = 0; + foreach ($rows as $r) $totalUsed += abs(floatval($r['quantity'])); + + $firstDate = strtotime($rows[0]['created_at']); + $lastDate = strtotime($rows[count($rows) - 1]['created_at']); + $daySpan = max(1, ($lastDate - $firstDate) / 86400); + $dailyRate = $totalUsed / $daySpan; + + if ($dailyRate < 0.01) continue; // negligible consumption + + // Get the most recent restock (last 'in' transaction) + $lastIn = $db->prepare(" + SELECT quantity, created_at + FROM transactions + WHERE product_id = ? AND location = ? AND type = 'in' + ORDER BY created_at DESC + LIMIT 1 + "); + $lastIn->execute([$pid, $loc]); + $restock = $lastIn->fetch(PDO::FETCH_ASSOC); + + if (!$restock) continue; + + $restockDate = strtotime($restock['created_at']); + $restockQty = floatval($restock['quantity']); + $daysSinceRestock = max(1, (time() - $restockDate) / 86400); + + // Predicted remaining qty = restock qty - (daily rate * days since restock) + $expectedQty = max(0, $restockQty - ($dailyRate * $daysSinceRestock)); + $actualQty = floatval($item['quantity']); + + // Flag if deviation > 30% and absolute diff > meaningful threshold + $deviation = abs($actualQty - $expectedQty); + $threshold = max($dailyRate * 3, 0.5); // at least 3 days worth or 0.5 units + $pctDev = $expectedQty > 0 ? ($deviation / $expectedQty) : ($actualQty > 0 ? 1 : 0); + + if ($pctDev > 0.30 && $deviation > $threshold) { + $unit = $item['unit']; + // Format expected/actual in human units + if ($unit === 'conf' && $item['default_quantity'] > 0 && $item['package_unit']) { + $pu = $item['package_unit']; + $sz = floatval($item['default_quantity']); + $expDisplay = round($expectedQty * $sz); + $actDisplay = round($actualQty * $sz); + $displayUnit = $pu; + } else { + $expDisplay = round($expectedQty, 1); + $actDisplay = round($actualQty, 1); + $displayUnit = $unit; + } + + $predictions[] = [ + 'inventory_id' => (int)$item['inventory_id'], + 'product_id' => (int)$item['product_id'], + 'name' => $item['name'], + 'brand' => $item['brand'], + 'location' => $item['location'], + 'unit' => $displayUnit, + 'expected_qty' => $expDisplay, + 'actual_qty' => $actDisplay, + 'daily_rate' => round($dailyRate, 3), + 'deviation_pct'=> round($pctDev * 100), + ]; + } + } + + echo json_encode(['success' => true, 'predictions' => $predictions]); +} + // ===== SETTINGS ===== function getServerSettings(): void { - // Return values for client — passwords are never exposed $geminiKey = env('GEMINI_API_KEY'); $bringEmail = env('BRING_EMAIL'); @@ -1288,6 +1399,22 @@ function getServerSettings(): void { 'tts_content_type' => env('TTS_CONTENT_TYPE', 'application/json'), 'tts_payload_key' => env('TTS_PAYLOAD_KEY', 'message'), 'tts_enabled' => env('TTS_ENABLED', 'false') === 'true', + // User preferences (now server-side) + 'default_persons' => intval(env('DEFAULT_PERSONS', '1')), + 'pref_veloce' => env('PREF_VELOCE', 'false') === 'true', + 'pref_pocafame' => env('PREF_POCAFAME', 'false') === 'true', + 'pref_scadenze' => env('PREF_SCADENZE', 'false') === 'true', + 'pref_healthy' => env('PREF_HEALTHY', 'false') === 'true', + 'pref_opened' => env('PREF_OPENED', 'false') === 'true', + 'pref_zerowaste' => env('PREF_ZEROWASTE', 'false') === 'true', + 'dietary' => env('DIETARY', ''), + 'appliances' => env('APPLIANCES', '') ? explode(',', env('APPLIANCES', '')) : [], + 'camera_facing' => env('CAMERA_FACING', 'environment'), + 'scale_enabled' => env('SCALE_ENABLED', 'false') === 'true', + 'scale_gateway_url' => env('SCALE_GATEWAY_URL', ''), + 'spesa_provider' => env('SPESA_PROVIDER', 'bring'), + 'spesa_ai_prompt' => env('SPESA_AI_PROMPT', ''), + 'meal_plan_enabled' => env('MEAL_PLAN_ENABLED', 'false') === 'true', ]); } @@ -1296,15 +1423,58 @@ function saveSettings(): void { $envFile = __DIR__ . '/../.env'; $envVars = loadEnv(); - // Update values from input — only overwrite if new value is non-empty - if (!empty($input['gemini_key'])) { - $envVars['GEMINI_API_KEY'] = $input['gemini_key']; + // Map of input key → .env key — only update if present in input + $keyMap = [ + 'gemini_key' => 'GEMINI_API_KEY', + 'bring_email' => 'BRING_EMAIL', + 'bring_password' => 'BRING_PASSWORD', + 'tts_url' => 'TTS_URL', + 'tts_token' => 'TTS_TOKEN', + 'tts_method' => 'TTS_METHOD', + 'tts_auth_type' => 'TTS_AUTH_TYPE', + 'tts_content_type'=> 'TTS_CONTENT_TYPE', + 'tts_payload_key' => 'TTS_PAYLOAD_KEY', + 'camera_facing' => 'CAMERA_FACING', + 'dietary' => 'DIETARY', + 'scale_gateway_url' => 'SCALE_GATEWAY_URL', + 'spesa_provider' => 'SPESA_PROVIDER', + 'spesa_ai_prompt' => 'SPESA_AI_PROMPT', + ]; + // Boolean keys + $boolMap = [ + 'tts_enabled' => 'TTS_ENABLED', + 'pref_veloce' => 'PREF_VELOCE', + 'pref_pocafame' => 'PREF_POCAFAME', + 'pref_scadenze' => 'PREF_SCADENZE', + 'pref_healthy' => 'PREF_HEALTHY', + 'pref_opened' => 'PREF_OPENED', + 'pref_zerowaste' => 'PREF_ZEROWASTE', + 'scale_enabled' => 'SCALE_ENABLED', + 'meal_plan_enabled' => 'MEAL_PLAN_ENABLED', + ]; + // Integer keys + $intMap = [ + 'default_persons' => 'DEFAULT_PERSONS', + ]; + + foreach ($keyMap as $inKey => $envKey) { + if (array_key_exists($inKey, $input)) { + $envVars[$envKey] = (string)$input[$inKey]; + } } - if (!empty($input['bring_email'])) { - $envVars['BRING_EMAIL'] = $input['bring_email']; + foreach ($boolMap as $inKey => $envKey) { + if (array_key_exists($inKey, $input)) { + $envVars[$envKey] = $input[$inKey] ? 'true' : 'false'; + } } - if (!empty($input['bring_password'])) { - $envVars['BRING_PASSWORD'] = $input['bring_password']; + foreach ($intMap as $inKey => $envKey) { + if (array_key_exists($inKey, $input)) { + $envVars[$envKey] = (string)intval($input[$inKey]); + } + } + // Arrays stored as comma-separated + if (array_key_exists('appliances', $input)) { + $envVars['APPLIANCES'] = is_array($input['appliances']) ? implode(',', $input['appliances']) : (string)$input['appliances']; } // Write .env file @@ -1314,6 +1484,10 @@ function saveSettings(): void { } $result = file_put_contents($envFile, implode("\n", $lines) . "\n"); + // Clear cached env + static $cache = null; + $cache = null; + if ($result !== false) { echo json_encode(['success' => true]); } else { @@ -1579,6 +1753,7 @@ function generateRecipe(PDO $db): void { $todayRecipes = $input['today_recipes'] ?? []; $mealPlanType = $input['meal_plan_type'] ?? ''; // e.g. 'pasta', 'pesce', 'legumi', ... $variation = max(0, intval($input['variation'] ?? 0)); // 0=first attempt, 1+=re-generation + $rejectedIngredients = $input['rejected_ingredients'] ?? []; // ingredient names from previous rejected recipes // Fetch all inventory items with expiry info $stmt = $db->query(" @@ -1689,14 +1864,16 @@ function generateRecipe(PDO $db): void { $label = $item['name'] . ($item['brand'] ? " ({$item['brand']})" : '') . $openNote . $expiryNote; if ($wantsExpiryPriority) { - if ($g === 1 || ($g === 2 && $daysLeft <= 1)) { + // Expired or expiring within 3 days → mandatory + if ($g === 1 || $g === 2) { $mandatoryItems[] = $label; - } elseif ($g === 2) { + // Expiring within 7 days → strongly recommended + } elseif ($g === 3) { $recommendedItems[] = $label; } } - if (($wantsOpenedPriority || $wantsExpiryPriority) && $isOpen && $daysLeft <= 5 && $daysLeft >= 0) { - // Opened items expiring within 5 days but not already in mandatory/recommended + if (($wantsOpenedPriority || $wantsExpiryPriority) && $isOpen && $daysLeft <= 7 && $daysLeft >= 0) { + // Opened items expiring within 7 days if (!in_array($label, $mandatoryItems) && !in_array($label, $recommendedItems)) { $recommendedItems[] = $label; } @@ -1870,6 +2047,13 @@ function generateRecipe(PDO $db): void { "Devi proporre qualcosa di COMPLETAMENTE DIVERSO: stile di cucina diverso, ingrediente principale diverso, " . "tecnica di cottura diversa, piatto di un'altra tradizione culinaria o di un'altra categoria. " . "Non basta cambiare il nome della stessa idea. Sorprendi! Sii creativo!"; + if (!empty($rejectedIngredients)) { + $rejList = implode(', ', array_map(fn($n) => '"' . $n . '"', $rejectedIngredients)); + $regenText .= "\n\n🚫 INGREDIENTI PRINCIPALI GIÀ RIFIUTATI DALL'UTENTE: {$rejList}\n" . + "NON usare NESSUNO di questi come ingrediente PRINCIPALE della nuova ricetta. " . + "Puoi usarli come ingrediente secondario solo se indispensabile. " . + "Scegli ingredienti principali completamente diversi dalla lista della dispensa!"; + } } $prompt = << $item, + 'lower' => mb_strtolower(trim($item['name']), 'UTF-8'), + 'words' => preg_split('/[\s,.\-\/]+/', mb_strtolower(trim($item['name']), 'UTF-8')), + 'cat' => mb_strtolower($item['category'] ?? '', 'UTF-8'), + ]; + } + + // Common Italian food name aliases for better matching + $aliases = [ + 'uovo' => ['uova','uovo','egg'], + 'uova' => ['uovo','uova','egg'], + 'latte' => ['latte','milk'], + 'formaggio' => ['formaggio','cheese','philadelphia','mozzarella','parmigiano','grana','pecorino','ricotta','mascarpone','stracchino','gorgonzola'], + 'pasta' => ['pasta','spaghetti','penne','fusilli','rigatoni','farfalle','tagliatelle','linguine','bucatini','orecchiette','paccheri','maccheroni'], + 'pomodoro' => ['pomodoro','pomodori','tomato','passata','pelati','polpa'], + 'cipolla' => ['cipolla','cipolle','onion'], + 'aglio' => ['aglio','garlic'], + 'burro' => ['burro','butter'], + 'panna' => ['panna','cream','crema'], + 'zucchero' => ['zucchero','sugar'], + 'farina' => ['farina','flour'], + 'olio' => ['olio','oil'], + 'patata' => ['patata','patate','potato'], + 'carota' => ['carota','carote','carrot'], + 'sedano' => ['sedano','celery'], + 'prezzemolo' => ['prezzemolo','parsley'], + 'basilico' => ['basilico','basil'], + ]; + foreach ($recipe['ingredients'] as &$ing) { if (!empty($ing['from_pantry'])) { $ingNameLower = mb_strtolower(trim($ing['name']), 'UTF-8'); + $ingWords = preg_split('/[\s,.\-\/]+/', $ingNameLower); $bestMatch = null; $bestScore = 0; - foreach ($items as $item) { - $itemNameLower = mb_strtolower(trim($item['name']), 'UTF-8'); + foreach ($itemsLookup as $entry) { + $itemNameLower = $entry['lower']; + $itemWords = $entry['words']; $score = 0; // Exact match @@ -1988,19 +2209,51 @@ PROMPT; elseif (mb_strpos($ingNameLower, $itemNameLower) !== false) { $score = 70; } - // Word-level matching: check if key words overlap else { - $ingWords = preg_split('/\s+/', $ingNameLower); - $itemWords = preg_split('/\s+/', $itemNameLower); - $common = array_intersect($ingWords, $itemWords); - if (count($common) > 0) { - $score = (count($common) / max(count($ingWords), 1)) * 60; + // Word-level matching with alias expansion + $expandedIngWords = $ingWords; + foreach ($ingWords as $w) { + foreach ($aliases as $key => $group) { + if (in_array($w, $group) || mb_strpos($w, $key) === 0 || mb_strpos($key, $w) === 0) { + $expandedIngWords = array_merge($expandedIngWords, $group); + } + } + } + $expandedIngWords = array_unique($expandedIngWords); + + $common = 0; + foreach ($expandedIngWords as $ew) { + foreach ($itemWords as $iw) { + // Partial stem match (min 4 chars shared prefix) + $minLen = min(mb_strlen($ew), mb_strlen($iw)); + if ($minLen >= 3) { + $prefixLen = 0; + for ($c = 0; $c < $minLen; $c++) { + if (mb_substr($ew, $c, 1) === mb_substr($iw, $c, 1)) $prefixLen++; + else break; + } + if ($prefixLen >= min(4, $minLen)) { $common++; break; } + } + if ($ew === $iw) { $common++; break; } + } + } + if ($common > 0) { + $score = ($common / max(count($ingWords), 1)) * 65; + // Bonus: if the main/first ingredient word matches + if (count($ingWords) > 0 && $common > 0) { + foreach ($itemWords as $iw) { + if (mb_strpos($iw, $ingWords[0]) === 0 || mb_strpos($ingWords[0], $iw) === 0) { + $score += 10; + break; + } + } + } } } if ($score > $bestScore) { $bestScore = $score; - $bestMatch = $item; + $bestMatch = $entry['item']; } } diff --git a/assets/css/style.css b/assets/css/style.css index 2ad690f..42aac8e 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -879,24 +879,19 @@ body { } .scale-live-box.scale-low-weight { border-color: #dc2626; - background: #fef2f2; - animation: scaleLowWeightBlink 0.8s ease-in-out infinite alternate; -} -@media (prefers-color-scheme: dark) { - .scale-live-box.scale-low-weight { - background: #3b0000; - } } .scale-low-weight .scale-live-val { color: #dc2626 !important; + animation: scaleLowTextBlink 0.65s ease-in-out infinite alternate; } .scale-low-weight .scale-live-label { color: #dc2626 !important; font-weight: 600; + animation: scaleLowTextBlink 0.65s ease-in-out infinite alternate; } -@keyframes scaleLowWeightBlink { - from { border-color: #dc2626; box-shadow: none; } - to { border-color: #dc2626; box-shadow: 0 0 0 3px rgba(220,38,38,0.25); } +@keyframes scaleLowTextBlink { + from { opacity: 1; } + to { opacity: 0.2; } } .btn-accent { @@ -4421,6 +4416,110 @@ body { } /* ===== REVIEW SECTION ===== */ +/* ===== ALERT TOP BANNER ===== */ +.alert-banner { + background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); + border: 1.5px solid #f59e0b; + border-radius: var(--radius); + margin-bottom: 12px; + overflow: hidden; + animation: bannerSlideIn 0.35s ease-out; +} +@keyframes bannerSlideIn { + from { opacity: 0; transform: translateY(-12px); } + to { opacity: 1; transform: translateY(0); } +} +.alert-banner.banner-prediction { + background: linear-gradient(135deg, #ede9fe 0%, #ddd6fe 100%); + border-color: #8b5cf6; +} +.alert-banner-inner { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 12px 12px 8px; +} +.alert-banner-icon { + font-size: 1.5rem; + flex-shrink: 0; + line-height: 1; +} +.alert-banner-body { + flex: 1; + min-width: 0; +} +.alert-banner-title { + font-weight: 700; + font-size: 0.95rem; + color: #92400e; + line-height: 1.3; +} +.banner-prediction .alert-banner-title { + color: #5b21b6; +} +.alert-banner-detail { + font-size: 0.82rem; + color: #78716c; + margin-top: 2px; + line-height: 1.4; +} +.alert-banner-close { + flex-shrink: 0; + width: 28px; + height: 28px; + border-radius: 50%; + border: none; + background: rgba(0,0,0,0.08); + font-size: 0.9rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: #78716c; +} +.alert-banner-actions { + display: flex; + gap: 8px; + padding: 0 12px 10px; + flex-wrap: wrap; +} +.alert-banner-actions .btn-banner { + flex: 1; + min-width: 80px; + padding: 8px 12px; + border-radius: 8px; + border: none; + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; + text-align: center; +} +.btn-banner-ok { + background: #d1fae5; + color: #059669; +} +.btn-banner-edit { + background: #e0e7ff; + color: #4338ca; +} +.btn-banner-weigh { + background: #f3e8ff; + color: #7c3aed; +} +.btn-banner-confirm { + background: #d1fae5; + color: #059669; +} +.alert-banner-counter { + font-size: 0.72rem; + color: #a1977a; + text-align: center; + padding: 0 12px 8px; +} +.banner-prediction .alert-banner-counter { + color: #7c6cb0; +} + .alert-review { background: #fffbeb; border-color: #f59e0b; diff --git a/assets/js/app.js b/assets/js/app.js index 75d9658..5d0eaf0 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -70,7 +70,7 @@ let _scaleWeightCallback = null; // pending on-demand weight request callback let _scaleLatestWeight = null; // last received weight message let _scaleAutoConfirmTimer = null; // countdown timer for auto-confirm after stable weight let _scaleAutoConfirmRAF = null; // rAF handle for auto-confirm progress bar animation -let _scaleStabilityTimer = null; // setTimeout: wait 5 s stable before starting confirm bar +let _scaleStabilityTimer = null; // setTimeout: wait 10 s stable before starting confirm bar let _scaleStabilityRAF = null; // rAF handle for stability progress bar in the live box let _scaleStabilityVal = null; // value we are currently timing for stability let _scaleUserDismissed = false; // user tapped or edited → don't retrigger for same value @@ -120,6 +120,9 @@ function _scaleOnMessage(msg) { // Update live reading modal overlay if visible (scale-read modal) const live = document.getElementById('scale-reading-live'); if (live) live.textContent = `${msg.value} ${msg.unit || 'kg'}${msg.stable ? ' ✓' : ' …'}`; + // Also update edit-form inline scale reading if visible + const editLive = document.getElementById('edit-scale-reading'); + if (editLive) editLive.textContent = `${msg.value} ${msg.unit || 'kg'}${msg.stable ? ' ✓' : ' …'}`; // Always update the persistent live box on the use page (every message, stable or not) _scaleUpdateLiveBox(msg); // If weight is NOT stable: stop any running timer/bar but keep the sentinel value. @@ -204,7 +207,7 @@ function _scaleUpdateLiveBox(msg) { // Weight too low — show red flashing warning box.classList.add('scale-low-weight'); if (valEl) valEl.textContent = `${raw} ${msg.unit || 'kg'}`; - if (lblEl) lblEl.textContent = '< 10 g · inserisci manualmente'; + if (lblEl) lblEl.textContent = t('scale.low_weight'); } else { box.classList.remove('scale-low-weight'); const stIcon = msg.stable ? ' ✓' : ' …'; @@ -436,12 +439,12 @@ function _cancelScaleStabilityWait() { } /** - * Start a 5-second stability wait with an animated progress bar in the live box. - * Calls onStable() when weight unchanged for 5 s. + * Start a 10-second stability wait with an animated progress bar in the live box. + * Calls onStable() when weight unchanged for 10 s. */ function _startScaleStabilityWait(onStable) { _cancelScaleStabilityWait(); - const duration = 5000; + const duration = 10000; const start = performance.now(); const bar = document.getElementById('scale-live-progress-bar'); @@ -535,6 +538,50 @@ function readScaleWeight(targetInputId, getUnit) { // Weight data streams continuously via SSE; _scaleWeightCallback fires on the next stable reading } +/** + * Inline scale reading for the edit-inventory modal. + * Shows a live weight display inside the form and fills edit-qty on stable reading. + */ +function readScaleForEdit() { + if (!_scaleConnected) { showToast('⚖️ ' + t('scale.not_connected'), 'error'); return; } + const section = document.getElementById('edit-scale-section'); + const btn = document.getElementById('btn-scale-edit'); + if (section) section.style.display = ''; + if (btn) btn.style.display = 'none'; + + _scaleWeightCallback = (msg) => { + const editQty = document.getElementById('edit-qty'); + const editUnit = document.getElementById('edit-unit'); + if (!editQty || !editUnit) return; + + let unit = editUnit.value; + const isConf = unit === 'conf'; + let confSize = 0; + if (isConf) confSize = parseFloat(document.getElementById('edit-conf-size')?.value) || 0; + + let raw = parseFloat(msg.value); + const srcUnit = (msg.unit || 'kg').toLowerCase(); + let grams; + if (srcUnit === 'kg') grams = raw * 1000; + else if (srcUnit === 'lbs' || srcUnit === 'lb') grams = raw * 453.592; + else if (srcUnit === 'oz') grams = raw * 28.3495; + else grams = raw; // g or ml + + let val; + if (isConf && confSize > 0) { + val = Math.round((grams / confSize) * 100) / 100; + } else { + val = Math.round(grams); + } + + editQty.value = val; + editQty.dispatchEvent(new Event('input')); + if (section) section.style.display = 'none'; + if (btn) btn.style.display = ''; + showToast(`⚖️ ${val} ${unit}`, 'success'); + }; +} + function _scaleShowReadingModal(targetInputId, unit) { document.getElementById('modal-content').innerHTML = ` + ${scaleEditReady ? ` + + + ` : ''}
@@ -4723,11 +4927,14 @@ async function submitAdd(e) { } // ===== USE FROM INVENTORY ===== +let _useSubmitting = false; // double-submit guard function showUseForm() { renderUsePreview(); _useConfMode = null; // reset + _useSubmitting = false; _scaleUserDismissed = false; _scaleStabilityVal = null; + _scaleLatestWeight = null; // clear stale weight from previous product _cancelScaleAutoConfirm(false); document.getElementById('use-quantity').value = 1; document.getElementById('use-location').value = 'dispensa'; @@ -5294,6 +5501,9 @@ async function submitUseAll() { async function submitUse(e) { e.preventDefault(); + if (_useSubmitting) return; // prevent double-submit from scale auto-confirm + _useSubmitting = true; + _cancelScaleAutoConfirm(false); // stop any running auto-confirm showLoading(true); try { let qty = parseFloat(document.getElementById('use-quantity').value) || 1; @@ -5314,6 +5524,7 @@ async function submitUse(e) { location: document.getElementById('use-location').value, }); showLoading(false); + _useSubmitting = false; if (result.success) { const usedText = displayUnit ? `${displayQty}${displayUnit}` : displayQty; showToast(`📤 Usato ${usedText} di ${currentProduct.name}`, 'success'); @@ -5332,6 +5543,7 @@ async function submitUse(e) { } } catch (err) { showLoading(false); + _useSubmitting = false; showToast(t('error.connection'), 'error'); } } @@ -7660,6 +7872,7 @@ function viewArchivedRecipe(idx) { let _cachedRecipe = null; let _generatedTodayTitles = []; // client-side list, robust vs race conditions let _recipeVariationCount = {}; // { 'pranzo': 0, 'cena': 1, ... } +let _rejectedRecipeIngredients = []; // ingredient names from previously rejected recipes function openRecipeDialog() { const meal = getMealType(); @@ -8701,14 +8914,18 @@ function _renderMealPlanHint(mealSlot) { } function regenerateRecipe() { + // Collect main ingredients from the rejected recipe to exclude them + if (_cachedRecipe && _cachedRecipe.recipe && _cachedRecipe.recipe.ingredients) { + const mainIngs = _cachedRecipe.recipe.ingredients + .filter(i => i.from_pantry) + .map(i => i.name); + _rejectedRecipeIngredients = [...new Set([..._rejectedRecipeIngredients, ...mainIngs])]; + } _cachedRecipe = null; - // Use the meal the user currently has selected (not the auto-detected one) const meal = getSelectedMealType(); - // increment variation counter for this meal slot _recipeVariationCount[meal] = (_recipeVariationCount[meal] || 0) + 1; document.getElementById('recipe-result').style.display = 'none'; document.getElementById('recipe-loading').style.display = 'none'; - // Keep all existing form settings (persons, chips, meal) — just show the form again document.getElementById('recipe-ask').style.display = ''; } @@ -8717,6 +8934,11 @@ async function generateRecipe() { const persons = parseInt(document.getElementById('recipe-persons').value) || 1; const settings = getSettings(); + // Reset rejected ingredients on first generation (not regeneration) + if ((_recipeVariationCount[meal] || 0) === 0) { + _rejectedRecipeIngredients = []; + } + // Determine meal plan type for today's selected slot, // but only if the user has NOT unchecked the meal-plan chip const mealPlanChipWrap = document.getElementById('recipe-opt-mealplan-wrap'); @@ -8755,6 +8977,7 @@ async function generateRecipe() { today_recipes: [...new Set([...await getTodayRecipeTitles(), ..._generatedTodayTitles])], meal_plan_type: mealPlanType, variation: _recipeVariationCount[meal] || 0, + rejected_ingredients: _rejectedRecipeIngredients, }); if (!result.success) { diff --git a/evershelf-kiosk/README.md b/evershelf-kiosk/README.md new file mode 100644 index 0000000..85e097f --- /dev/null +++ b/evershelf-kiosk/README.md @@ -0,0 +1,46 @@ +# EverShelf Kiosk + +Android kiosk app that displays the EverShelf web interface in full-screen mode while running the Smart Scale BLE Gateway as a background service. + +## Features + +- **Full-screen WebView** — displays EverShelf in immersive kiosk mode (no status bar, no navigation) +- **Built-in Scale Gateway** — BLE connection to smart scales with WebSocket server on port 8765 +- **Auto-reconnect** — automatically reconnects to the last connected scale +- **Foreground service** — gateway runs even when the screen is off +- **Camera pass-through** — allows barcode scanning from within the WebView +- **Error recovery** — shows retry page when the server is unreachable + +## Setup + +1. Install the APK on your Android tablet/phone +2. On first launch, grant Bluetooth and Location permissions +3. Tap the subtle ⚙️ icon in the top-right corner to configure the EverShelf server URL +4. In EverShelf settings, set the Scale Gateway URL to `ws://localhost:8765` + +## Architecture + +``` +KioskActivity (WebView — full-screen EverShelf) + ↕ binds to +ScaleGatewayService (foreground service) + ├── BleScaleManager (BLE scanning + connection) + │ └── ScaleProtocol (multi-protocol weight parser) + └── GatewayWebSocketServer (port 8765) + ↕ WebSocket + WebView (EverShelf JavaScript connects to ws://localhost:8765) +``` + +## Building + +```bash +cd evershelf-kiosk +./gradlew assembleDebug +# APK at app/build/outputs/apk/debug/app-debug.apk +``` + +## Requirements + +- Android 7.0+ (API 24) +- Bluetooth Low Energy support +- Network access to EverShelf server diff --git a/evershelf-kiosk/app/build.gradle.kts b/evershelf-kiosk/app/build.gradle.kts new file mode 100644 index 0000000..10d007f --- /dev/null +++ b/evershelf-kiosk/app/build.gradle.kts @@ -0,0 +1,48 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "it.dadaloop.evershelf.kiosk" + compileSdk = 34 + + defaultConfig { + applicationId = "it.dadaloop.evershelf.kiosk" + minSdk = 24 + targetSdk = 34 + versionCode = 1 + versionName = "1.0.0" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt")) + } + } + + buildFeatures { + viewBinding = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.appcompat:appcompat:1.6.1") + implementation("com.google.android.material:material:1.11.0") + implementation("androidx.constraintlayout:constraintlayout:2.1.4") + implementation("androidx.webkit:webkit:1.10.0") + // WebSocket server (for scale gateway) + implementation("org.java-websocket:Java-WebSocket:1.5.5") + // Coroutines + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") +} diff --git a/evershelf-kiosk/app/src/main/AndroidManifest.xml b/evershelf-kiosk/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..85e6f44 --- /dev/null +++ b/evershelf-kiosk/app/src/main/AndroidManifest.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/BleScaleManager.kt b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/BleScaleManager.kt new file mode 100644 index 0000000..6bc4967 --- /dev/null +++ b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/BleScaleManager.kt @@ -0,0 +1,320 @@ +package it.dadaloop.evershelf.kiosk + +import android.Manifest +import android.bluetooth.* +import android.bluetooth.le.* +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.core.content.ContextCompat + +private const val TAG = "BleScaleManager" +private const val SCAN_PERIOD_MS = 15_000L +private const val PREFS_NAME = "evershelf_kiosk" +private const val PREF_LAST_DEVICE = "last_device_address" + +data class BleDeviceInfo( + val device: BluetoothDevice, + val name: String, + val rssi: Int, + val proximity: String, + val scaleScore: Int, +) + +interface BleScaleListener { + fun onDeviceFound(info: BleDeviceInfo) + fun onConnecting(device: BluetoothDevice) + fun onConnected(deviceName: String) + fun onDisconnected() + fun onWeightReceived(reading: WeightReading) + fun onBatteryReceived(level: Int) + fun onError(message: String) + fun onScanStopped() + fun onDebugEvent(message: String) +} + +class BleScaleManager( + private val context: Context, + private val listener: BleScaleListener, +) { + private val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager + private val bluetoothAdapter: BluetoothAdapter? get() = bluetoothManager.adapter + private val mainHandler = Handler(Looper.getMainLooper()) + + private var leScanner: BluetoothLeScanner? = null + private var gatt: BluetoothGatt? = null + private var isScanning = false + private var connectedDeviceName: String = "" + private var autoConnectAddress: String? = null + private val pendingSubscriptions = ArrayDeque() + + val isConnected: Boolean get() = gatt != null && connectedDeviceName.isNotEmpty() + + fun getSavedDeviceAddress(): String? { + return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .getString(PREF_LAST_DEVICE, null) + } + + private fun saveDeviceAddress(address: String) { + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit().putString(PREF_LAST_DEVICE, address).apply() + } + + fun enableAutoConnect() { + autoConnectAddress = getSavedDeviceAddress() + } + + fun hasRequiredPermissions(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED && + ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED + } else { + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED + } + } + + fun startScan() { + val adapter = bluetoothAdapter ?: run { + listener.onError("Bluetooth not available.") + return + } + if (!adapter.isEnabled) { + listener.onError("Bluetooth is off.") + return + } + if (isScanning) stopScan() + + leScanner = adapter.bluetoothLeScanner + val settings = ScanSettings.Builder() + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + .build() + + isScanning = true + try { + leScanner?.startScan(null, settings, scanCallback) + } catch (e: Exception) { + leScanner?.startScan(scanCallback) + } + + mainHandler.postDelayed({ + stopScan() + listener.onScanStopped() + }, SCAN_PERIOD_MS) + } + + fun stopScan() { + if (!isScanning) return + isScanning = false + try { leScanner?.stopScan(scanCallback) } catch (_: Exception) {} + leScanner = null + } + + private val scanCallback = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult) { + val device = result.device + val name = result.scanRecord?.deviceName?.takeIf { it.isNotBlank() } + ?: getDeviceName(device) + val proximity = rssiToProximity(result.rssi) + val score = scoreLikelyScale(name, result.scanRecord) + val info = BleDeviceInfo(device, name, result.rssi, proximity, score) + mainHandler.post { listener.onDeviceFound(info) } + + if (autoConnectAddress != null && device.address == autoConnectAddress && !isConnected) { + autoConnectAddress = null + mainHandler.post { connect(device) } + } + } + + override fun onScanFailed(errorCode: Int) { + isScanning = false + mainHandler.post { listener.onError("BLE scan failed (code: $errorCode)") } + } + } + + private fun getDeviceName(device: BluetoothDevice): String { + return try { device.name?.takeIf { it.isNotBlank() } ?: "Unnamed" } catch (_: SecurityException) { "Unnamed" } + } + + private fun rssiToProximity(rssi: Int) = when { + rssi >= -60 -> "Near"; rssi >= -80 -> "Medium"; else -> "Far" + } + + private fun scoreLikelyScale(name: String, scanRecord: android.bluetooth.le.ScanRecord?): Int { + var score = 0 + val lower = name.lowercase() + val foodKeywords = listOf("scale", "bilancia", "kitchen", "food", "cucina", "coffee", "caffe", + "balance", "weight", "waage", "arboleaf", "ck10", "ck20", "ek-", "acaia", "felicita", + "decent", "skale", "timemore", "brewista", "hario", "greater goods", "ozeri", "etekcity", + "nutri", "nicewell", "koios", "renpho", "eatsmart") + if (foodKeywords.any { lower.contains(it) }) score += 10 + val bodyKeywords = listOf("body", "fat", "bmi", "composition", "fitness", "mi body", "lepulse", "qardio", "garmin", "withings") + if (bodyKeywords.any { lower.contains(it) }) score -= 5 + scanRecord?.serviceUuids?.let { uuids -> + val us = uuids.map { it.uuid.toString().lowercase() } + if (us.any { it.startsWith("0000181d") }) score += 15 + if (us.any { it.startsWith("0000ffe0") || it.startsWith("0000fff0") }) score += 10 + if (us.any { it.startsWith("49535343") }) score += 20 + if (us.any { it.startsWith("0000181b") }) score -= 10 + } + return score + } + + fun connect(device: BluetoothDevice) { + stopScan() + disconnect() + connectedDeviceName = "" + ScaleProtocol.resetState() + mainHandler.post { listener.onConnecting(device) } + try { + gatt = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE) + } else { + device.connectGatt(context, false, gattCallback) + } + } catch (e: SecurityException) { + mainHandler.post { listener.onError("Missing permission: ${e.message}") } + } + } + + fun disconnect() { + pendingSubscriptions.clear() + try { gatt?.disconnect(); gatt?.close() } catch (_: Exception) {} + gatt = null + connectedDeviceName = "" + } + + private val gattCallback = object : BluetoothGattCallback() { + override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { + when (newState) { + BluetoothProfile.STATE_CONNECTED -> { + mainHandler.postDelayed({ gatt.discoverServices() }, 500) + } + BluetoothProfile.STATE_DISCONNECTED -> { + this@BleScaleManager.gatt?.close() + this@BleScaleManager.gatt = null + connectedDeviceName = "" + mainHandler.post { listener.onDisconnected() } + } + } + } + + override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { + if (status != BluetoothGatt.GATT_SUCCESS) { + mainHandler.post { listener.onError("GATT services not found (status=$status)") } + return + } + + val targetChars = mutableListOf() + + gatt.getService(BleUuids.WEIGHT_SCALE_SERVICE) + ?.getCharacteristic(BleUuids.WEIGHT_MEASUREMENT_CHAR)?.let { targetChars.add(it) } + gatt.getService(BleUuids.FFE0)?.let { svc -> + svc.getCharacteristic(BleUuids.FFE1)?.let { targetChars.add(it) } + } + gatt.getService(BleUuids.FFF0)?.let { svc -> + svc.getCharacteristic(BleUuids.FFF4)?.let { targetChars.add(it) } + ?: svc.getCharacteristic(BleUuids.FFF1)?.let { targetChars.add(it) } + } + gatt.getService(BleUuids.ACAIA_SERVICE)?.let { svc -> + svc.getCharacteristic(BleUuids.ACAIA_CHAR)?.let { targetChars.add(it) } + } + + if (targetChars.isEmpty()) { + for (service in gatt.services) { + if (service.uuid.toString().startsWith("00001800") || + service.uuid.toString().startsWith("00001801")) continue + for (char in service.characteristics) { + val props = char.properties + if ((props and BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0 || + (props and BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0) { + if (!targetChars.contains(char)) targetChars.add(char) + } + } + } + } + + if (targetChars.isEmpty()) { + mainHandler.post { listener.onError("No weight characteristic found.") } + return + } + + gatt.getService(BleUuids.BATTERY_SERVICE) + ?.getCharacteristic(BleUuids.BATTERY_LEVEL_CHAR)?.let { targetChars.add(it) } + + try { gatt.device?.address?.let { saveDeviceAddress(it) } } catch (_: SecurityException) {} + + pendingSubscriptions.clear() + pendingSubscriptions.addAll(targetChars) + + val deviceName = try { gatt.device?.name ?: "Scale" } catch (_: SecurityException) { "Scale" } + connectedDeviceName = deviceName + mainHandler.post { listener.onConnected(deviceName) } + subscribeNext(gatt) + } + + override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) { + subscribeNext(gatt) + } + + @Suppress("DEPRECATION") + override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { + val data = characteristic.value ?: return + processCharacteristicData(characteristic, data) + } + + override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) { + processCharacteristicData(characteristic, value) + } + + @Suppress("DEPRECATION") + override fun onCharacteristicRead(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) { + if (status == BluetoothGatt.GATT_SUCCESS && characteristic.uuid == BleUuids.BATTERY_LEVEL_CHAR) { + val level = characteristic.value?.firstOrNull()?.toInt()?.and(0xFF) + if (level != null) mainHandler.post { listener.onBatteryReceived(level) } + } + } + } + + private fun subscribeNext(gatt: BluetoothGatt) { + val char = pendingSubscriptions.removeFirstOrNull() ?: return + if (char.uuid == BleUuids.BATTERY_LEVEL_CHAR) { + try { gatt.readCharacteristic(char) } catch (_: SecurityException) {} + return + } + val props = char.properties + val notifyType = when { + (props and BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0 -> + BluetoothGattDescriptor.ENABLE_INDICATION_VALUE + else -> BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + } + try { + gatt.setCharacteristicNotification(char, true) + val descriptor = char.getDescriptor(CCCD_UUID) ?: run { subscribeNext(gatt); return } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + gatt.writeDescriptor(descriptor, notifyType) + } else { + @Suppress("DEPRECATION") + descriptor.value = notifyType + @Suppress("DEPRECATION") + gatt.writeDescriptor(descriptor) + } + } catch (e: SecurityException) { + Log.e(TAG, "SecurityException enabling notification", e) + } + } + + private fun processCharacteristicData(char: BluetoothGattCharacteristic, data: ByteArray) { + if (char.uuid == BleUuids.BATTERY_LEVEL_CHAR && data.isNotEmpty()) { + val level = data[0].toInt() and 0xFF + mainHandler.post { listener.onBatteryReceived(level) } + return + } + val reading = ScaleProtocol.parse(char, data) + if (reading != null && reading.value > 0f) { + mainHandler.post { listener.onWeightReceived(reading) } + } + } +} diff --git a/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/GatewayWebSocketServer.kt b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/GatewayWebSocketServer.kt new file mode 100644 index 0000000..e158809 --- /dev/null +++ b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/GatewayWebSocketServer.kt @@ -0,0 +1,100 @@ +package it.dadaloop.evershelf.kiosk + +import android.util.Log +import org.java_websocket.WebSocket +import org.java_websocket.handshake.ClientHandshake +import org.java_websocket.server.WebSocketServer +import org.json.JSONObject +import java.net.InetSocketAddress +import java.util.Collections + +private const val TAG = "GatewayWsServer" + +interface ServerEventListener { + fun onClientConnected(address: String) + fun onClientDisconnected(address: String) + fun onClientRequestedWeight() +} + +class GatewayWebSocketServer( + port: Int, + private val eventListener: ServerEventListener?, +) : WebSocketServer(InetSocketAddress(port)) { + + private val pendingWeightRequests: MutableSet = + Collections.synchronizedSet(mutableSetOf()) + + @Volatile private var lastStatusJson: String = buildStatusJson("disconnected", null, null) + @Volatile private var lastWeightJson: String? = null + + override fun onStart() { + Log.i(TAG, "WebSocket server started on port ${address.port}") + connectionLostTimeout = 30 + } + + override fun onOpen(conn: WebSocket, handshake: ClientHandshake) { + val addr = conn.remoteSocketAddress?.toString() ?: "?" + conn.send(lastStatusJson) + lastWeightJson?.let { conn.send(it) } + eventListener?.onClientConnected(addr) + } + + override fun onClose(conn: WebSocket, code: Int, reason: String, remote: Boolean) { + val addr = conn.remoteSocketAddress?.toString() ?: "?" + pendingWeightRequests.remove(conn) + eventListener?.onClientDisconnected(addr) + } + + override fun onMessage(conn: WebSocket, message: String) { + try { + val json = JSONObject(message) + when (json.optString("type")) { + "ping" -> conn.send("""{"type":"pong"}""") + "get_status" -> conn.send(lastStatusJson) + "get_weight" -> { + pendingWeightRequests.add(conn) + eventListener?.onClientRequestedWeight() + lastWeightJson?.let { conn.send(it) } + } + } + } catch (_: Exception) {} + } + + override fun onError(conn: WebSocket?, ex: Exception) { + Log.e(TAG, "WebSocket error", ex) + } + + fun publishStatus(state: String, deviceName: String?, battery: Int?) { + lastStatusJson = buildStatusJson(state, deviceName, battery) + broadcast(lastStatusJson) + } + + fun publishWeight(value: Float, unit: String, stable: Boolean, battery: Int? = null) { + val json = buildWeightJson(value, unit, stable) + lastWeightJson = json + broadcast(json) + if (stable) { + synchronized(pendingWeightRequests) { pendingWeightRequests.clear() } + } + } + + private fun buildStatusJson(state: String, device: String?, battery: Int?): String { + val obj = JSONObject() + obj.put("type", "status") + obj.put("state", state) + if (device != null) obj.put("device", device) + if (battery != null) obj.put("battery", battery) + return obj.toString() + } + + private fun buildWeightJson(value: Float, unit: String, stable: Boolean): String { + val obj = JSONObject() + obj.put("type", "weight") + val rounded = Math.round(value * 10f) / 10.0 + obj.put("value", rounded) + obj.put("unit", unit) + obj.put("stable", stable) + obj.put("timestamp", System.currentTimeMillis()) + return obj.toString() + } +} diff --git a/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/KioskActivity.kt b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/KioskActivity.kt new file mode 100644 index 0000000..d84d9da --- /dev/null +++ b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/KioskActivity.kt @@ -0,0 +1,241 @@ +package it.dadaloop.evershelf.kiosk + +import android.Manifest +import android.annotation.SuppressLint +import android.bluetooth.BluetoothAdapter +import android.content.* +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.IBinder +import android.view.View +import android.view.WindowInsets +import android.view.WindowInsetsController +import android.view.WindowManager +import android.webkit.* +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import it.dadaloop.evershelf.kiosk.databinding.ActivityKioskBinding + +private const val PREFS_NAME = "evershelf_kiosk" +private const val PREF_URL = "evershelf_url" +private const val DEFAULT_URL = "http://evershelf.local" + +class KioskActivity : AppCompatActivity() { + + private lateinit var binding: ActivityKioskBinding + private var gatewayService: ScaleGatewayService? = null + private var bound = false + + private val serviceConnection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName, service: IBinder) { + val binder = service as ScaleGatewayService.LocalBinder + gatewayService = binder.getService() + bound = true + } + + override fun onServiceDisconnected(name: ComponentName) { + gatewayService = null + bound = false + } + } + + // Permission request launcher + private val permissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { results -> + val allGranted = results.all { it.value } + if (allGranted) { + startGatewayService() + } else { + Toast.makeText(this, "BLE permissions required for scale gateway", Toast.LENGTH_LONG).show() + // Start anyway without BLE + startGatewayService() + } + } + + @SuppressLint("SetJavaScriptEnabled") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityKioskBinding.inflate(layoutInflater) + setContentView(binding.root) + + // Full-screen immersive mode + enterKioskMode() + + // Keep screen on + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + + // Setup WebView + setupWebView() + + // Settings button (long press corner area) + binding.btnSettings.setOnClickListener { + startActivity(Intent(this, SettingsActivity::class.java)) + } + + // Request permissions and start gateway + requestPermissionsAndStart() + + // Load the EverShelf URL + loadEverShelfUrl() + } + + override fun onResume() { + super.onResume() + enterKioskMode() + // Reload URL in case it was changed in settings + val currentUrl = binding.webView.url ?: "" + val savedUrl = getSavedUrl() + if (currentUrl.isNotEmpty() && !currentUrl.startsWith(savedUrl)) { + loadEverShelfUrl() + } + } + + override fun onDestroy() { + if (bound) { + unbindService(serviceConnection) + bound = false + } + super.onDestroy() + } + + private fun enterKioskMode() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + window.insetsController?.let { + it.hide(WindowInsets.Type.statusBars() or WindowInsets.Type.navigationBars()) + it.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + } else { + @Suppress("DEPRECATION") + window.decorView.systemUiVisibility = ( + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + or View.SYSTEM_UI_FLAG_FULLSCREEN + or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_LAYOUT_STABLE + ) + } + } + + @SuppressLint("SetJavaScriptEnabled") + private fun setupWebView() { + binding.webView.apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.databaseEnabled = true + settings.mediaPlaybackRequiresUserGesture = false + settings.allowFileAccess = false + settings.allowContentAccess = false + settings.mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW + settings.cacheMode = WebSettings.LOAD_DEFAULT + settings.useWideViewPort = true + settings.loadWithOverviewMode = true + settings.setSupportZoom(false) + settings.builtInZoomControls = false + + // Allow camera access for barcode scanning + webChromeClient = object : WebChromeClient() { + override fun onPermissionRequest(request: PermissionRequest) { + runOnUiThread { + request.grant(request.resources) + } + } + + override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { + return true + } + } + + webViewClient = object : WebViewClient() { + override fun onReceivedError( + view: WebView?, + request: WebResourceRequest?, + error: WebResourceError? + ) { + // Show retry page on load error + if (request?.isForMainFrame == true) { + view?.loadData( + """ + +

⚠️ Connection Error

+

Cannot reach EverShelf server

+

${getSavedUrl()}

+ +

+ + + """.trimIndent(), + "text/html", "utf-8" + ) + } + } + + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { + val url = request.url.toString() + if (url.startsWith("evershelf://settings")) { + startActivity(Intent(this@KioskActivity, SettingsActivity::class.java)) + return true + } + // Keep navigation within the WebView for same-origin + return false + } + } + } + } + + private fun loadEverShelfUrl() { + val url = getSavedUrl() + binding.webView.loadUrl(url) + } + + private fun getSavedUrl(): String { + return getSharedPreferences(PREFS_NAME, MODE_PRIVATE) + .getString(PREF_URL, DEFAULT_URL) ?: DEFAULT_URL + } + + private fun requestPermissionsAndStart() { + val needed = mutableListOf() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_SCAN) + != PackageManager.PERMISSION_GRANTED) needed.add(Manifest.permission.BLUETOOTH_SCAN) + if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) + != PackageManager.PERMISSION_GRANTED) needed.add(Manifest.permission.BLUETOOTH_CONNECT) + } else { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) + != PackageManager.PERMISSION_GRANTED) needed.add(Manifest.permission.ACCESS_FINE_LOCATION) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED) needed.add(Manifest.permission.POST_NOTIFICATIONS) + } + + if (needed.isNotEmpty()) { + permissionLauncher.launch(needed.toTypedArray()) + } else { + startGatewayService() + } + } + + private fun startGatewayService() { + val intent = Intent(this, ScaleGatewayService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(intent) + } else { + startService(intent) + } + bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE) + } + + @Deprecated("Deprecated in Java") + override fun onBackPressed() { + if (binding.webView.canGoBack()) { + binding.webView.goBack() + } + // Don't call super — prevent exiting kiosk mode + } +} diff --git a/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/ScaleGatewayService.kt b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/ScaleGatewayService.kt new file mode 100644 index 0000000..9721337 --- /dev/null +++ b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/ScaleGatewayService.kt @@ -0,0 +1,194 @@ +package it.dadaloop.evershelf.kiosk + +import android.app.* +import android.bluetooth.BluetoothDevice +import android.content.Context +import android.content.Intent +import android.os.Binder +import android.os.Build +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.util.Log +import androidx.core.app.NotificationCompat + +private const val TAG = "ScaleGtwService" +private const val CHANNEL_ID = "scale_gateway" +private const val NOTIFICATION_ID = 1 +private const val WS_PORT = 8765 +private const val RECONNECT_DELAY_MS = 5000L + +class ScaleGatewayService : Service(), BleScaleListener, ServerEventListener { + + private var bleManager: BleScaleManager? = null + private var wsServer: GatewayWebSocketServer? = null + private var lastBattery: Int? = null + private var connectedDeviceName: String? = null + private val mainHandler = Handler(Looper.getMainLooper()) + + // Binder so KioskActivity can get status updates + inner class LocalBinder : Binder() { + fun getService(): ScaleGatewayService = this@ScaleGatewayService + } + private val binder = LocalBinder() + + // Callbacks for the activity + var statusCallback: ((String, String?, Int?) -> Unit)? = null // state, device, battery + var weightCallback: ((Float, String, Boolean) -> Unit)? = null // value, unit, stable + + override fun onBind(intent: Intent?): IBinder = binder + + override fun onCreate() { + super.onCreate() + createNotificationChannel() + startForeground(NOTIFICATION_ID, buildNotification("Starting...")) + + // Start WebSocket server + wsServer = GatewayWebSocketServer(WS_PORT, this).also { + try { it.start() } catch (e: Exception) { + Log.e(TAG, "Failed to start WS server", e) + } + } + + // Start BLE manager + bleManager = BleScaleManager(this, this).also { + if (it.hasRequiredPermissions()) { + it.enableAutoConnect() + it.startScan() + } + } + } + + override fun onDestroy() { + bleManager?.disconnect() + bleManager?.stopScan() + try { wsServer?.stop(1000) } catch (_: Exception) {} + super.onDestroy() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + return START_STICKY + } + + fun startScaleScan() { + bleManager?.let { + if (it.hasRequiredPermissions()) { + it.enableAutoConnect() + it.startScan() + } + } + } + + fun disconnectScale() { + bleManager?.disconnect() + connectedDeviceName = null + wsServer?.publishStatus("disconnected", null, null) + updateNotification("Gateway active — no scale") + statusCallback?.invoke("disconnected", null, null) + } + + fun connectDevice(device: BluetoothDevice) { + bleManager?.connect(device) + } + + val isScaleConnected: Boolean get() = bleManager?.isConnected == true + + // ─── BleScaleListener ────────────────────────────────────────────────── + + override fun onDeviceFound(info: BleDeviceInfo) {} + override fun onConnecting(device: BluetoothDevice) { + updateNotification("Connecting...") + statusCallback?.invoke("connecting", null, null) + } + + override fun onConnected(deviceName: String) { + connectedDeviceName = deviceName + wsServer?.publishStatus("connected", deviceName, lastBattery) + updateNotification("Connected: $deviceName") + statusCallback?.invoke("connected", deviceName, lastBattery) + } + + override fun onDisconnected() { + connectedDeviceName = null + wsServer?.publishStatus("disconnected", null, null) + updateNotification("Scale disconnected — reconnecting...") + statusCallback?.invoke("disconnected", null, null) + // Auto-reconnect + mainHandler.postDelayed({ + bleManager?.let { + if (!it.isConnected && it.hasRequiredPermissions()) { + it.enableAutoConnect() + it.startScan() + } + } + }, RECONNECT_DELAY_MS) + } + + override fun onWeightReceived(reading: WeightReading) { + wsServer?.publishWeight(reading.value, reading.unit, reading.stable, lastBattery) + weightCallback?.invoke(reading.value, reading.unit, reading.stable) + } + + override fun onBatteryReceived(level: Int) { + lastBattery = level + wsServer?.publishStatus("connected", connectedDeviceName, level) + } + + override fun onError(message: String) { + Log.w(TAG, "BLE error: $message") + } + + override fun onScanStopped() {} + override fun onDebugEvent(message: String) {} + + // ─── ServerEventListener ─────────────────────────────────────────────── + + override fun onClientConnected(address: String) { + Log.d(TAG, "WS client connected: $address") + } + + override fun onClientDisconnected(address: String) { + Log.d(TAG, "WS client disconnected: $address") + } + + override fun onClientRequestedWeight() {} + + // ─── Notification ────────────────────────────────────────────────────── + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + "Scale Gateway", + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "EverShelf Scale Gateway running" + setShowBadge(false) + } + (getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager) + .createNotificationChannel(channel) + } + } + + private fun buildNotification(text: String): Notification { + val intent = Intent(this, KioskActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP + } + val pendingIntent = PendingIntent.getActivity( + this, 0, intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("EverShelf Gateway") + .setContentText(text) + .setSmallIcon(android.R.drawable.stat_sys_data_bluetooth) + .setContentIntent(pendingIntent) + .setOngoing(true) + .build() + } + + private fun updateNotification(text: String) { + val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + nm.notify(NOTIFICATION_ID, buildNotification(text)) + } +} diff --git a/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/ScaleProtocol.kt b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/ScaleProtocol.kt new file mode 100644 index 0000000..3052247 --- /dev/null +++ b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/ScaleProtocol.kt @@ -0,0 +1,113 @@ +package it.dadaloop.evershelf.kiosk + +import android.bluetooth.BluetoothGattCharacteristic +import java.util.UUID + +data class WeightReading( + val value: Float, + val unit: String, + val stable: Boolean, +) + +val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + +object BleUuids { + val WEIGHT_SCALE_SERVICE = UUID.fromString("0000181d-0000-1000-8000-00805f9b34fb") + val WEIGHT_MEASUREMENT_CHAR = UUID.fromString("00002a9d-0000-1000-8000-00805f9b34fb") + val BATTERY_SERVICE = UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb") + val BATTERY_LEVEL_CHAR = UUID.fromString("00002a19-0000-1000-8000-00805f9b34fb") + val FFE0 = UUID.fromString("0000ffe0-0000-1000-8000-00805f9b34fb") + val FFE1 = UUID.fromString("0000ffe1-0000-1000-8000-00805f9b34fb") + val FFF0 = UUID.fromString("0000fff0-0000-1000-8000-00805f9b34fb") + val FFF1 = UUID.fromString("0000fff1-0000-1000-8000-00805f9b34fb") + val FFF4 = UUID.fromString("0000fff4-0000-1000-8000-00805f9b34fb") + val ACAIA_SERVICE = UUID.fromString("49535343-fe7d-4ae5-8fa9-9fafd205e455") + val ACAIA_CHAR = UUID.fromString("49535343-8841-43f4-a8d4-ecbe34729bb3") + val QN_AE00 = UUID.fromString("0000ae00-0000-1000-8000-00805f9b34fb") + val QN_AE02 = UUID.fromString("0000ae02-0000-1000-8000-00805f9b34fb") +} + +object ScaleProtocol { + private const val MAX_GRAMS = 15000f + private const val MIN_GRAMS = 0.5f + + fun resetState() {} + + fun parse( + char: BluetoothGattCharacteristic, + data: ByteArray, + debug: ((String) -> Unit)? = null, + ): WeightReading? { + if (data.size < 2) return null + + when (char.uuid) { + BleUuids.WEIGHT_MEASUREMENT_CHAR -> return parseSigWeight(data, debug) + } + + if (data.size == 18 + && (data[0].toInt() and 0xFF) == 0x10 + && (data[1].toInt() and 0xFF) == 0x12) { + return parseQNFood(data, debug) + } + + return parseGeneric(data, debug) + } + + private fun parseSigWeight(data: ByteArray, debug: ((String) -> Unit)? = null): WeightReading? { + if (data.size < 3) return null + val flags = data[0].toInt() and 0xFF + val isImperial = (flags and 0x01) != 0 + val raw = u16le(data, 1) + return if (isImperial) { + val lb = raw * 0.01f + if (lb < 0.01f || lb > 33f) null else WeightReading(lb, "lb", stable = true) + } else { + val g = raw * 5f + if (g < MIN_GRAMS || g > MAX_GRAMS) null else WeightReading(g, "g", stable = true) + } + } + + private fun parseQNFood(data: ByteArray, debug: ((String) -> Unit)? = null): WeightReading? { + val calc = data.take(17).sumOf { it.toInt() and 0xFF } and 0xFF + if (calc != (data[17].toInt() and 0xFF)) return null + val rawValue = u16be(data, 9) + val stable = (data[8].toInt() and 0x08) != 0 + val unit = when (data[4].toInt() and 0xFF) { + 0x01 -> "g"; 0x02 -> "oz"; 0x03 -> "ml"; 0x04 -> "ml"; else -> "g" + } + val value = rawValue / 10f + if (rawValue == 0) return null + val valueG = if (unit == "oz") value * 28.3495f else value + if (valueG < MIN_GRAMS || valueG > MAX_GRAMS) return null + return WeightReading(value, unit, stable) + } + + private fun parseGeneric(data: ByteArray, debug: ((String) -> Unit)? = null): WeightReading? { + if (data.size < 3) return null + data class C(val pos: Int, val be: Boolean, val div: Float, val label: String) + val candidates = listOf( + C(1, false, 1f, "p1LEg"), C(1, true, 1f, "p1BEg"), + C(2, false, 1f, "p2LEg"), C(2, true, 1f, "p2BEg"), + C(3, false, 1f, "p3LEg"), C(3, true, 1f, "p3BEg"), + C(1, false, 10f, "p1LE.1g"), C(1, true, 10f, "p1BE.1g"), + C(2, false, 10f, "p2LE.1g"), C(2, true, 10f, "p2BE.1g"), + C(3, false, 10f, "p3LE.1g"), C(3, true, 10f, "p3BE.1g"), + C(1, false, 2f, "p1LE.5g"), C(1, true, 2f, "p1BE.5g"), + C(1, false, 0.1f, "p1LEcg"), C(1, true, 0.1f, "p1BEcg"), + C(3, false, 0.1f, "p3LEcg"), C(3, true, 0.1f, "p3BEcg"), + ) + for (c in candidates) { + if (c.pos + 1 >= data.size) continue + val raw = if (c.be) u16be(data, c.pos) else u16le(data, c.pos) + if (raw == 0) continue + val g = raw / c.div + if (g in MIN_GRAMS..MAX_GRAMS) return WeightReading(g, "g", stable = false) + } + return null + } + + private fun u16le(b: ByteArray, off: Int): Int = + (b[off].toInt() and 0xFF) or ((b[off + 1].toInt() and 0xFF) shl 8) + private fun u16be(b: ByteArray, off: Int): Int = + ((b[off].toInt() and 0xFF) shl 8) or (b[off + 1].toInt() and 0xFF) +} diff --git a/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/SettingsActivity.kt b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/SettingsActivity.kt new file mode 100644 index 0000000..42e401d --- /dev/null +++ b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/SettingsActivity.kt @@ -0,0 +1,39 @@ +package it.dadaloop.evershelf.kiosk + +import android.os.Bundle +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import it.dadaloop.evershelf.kiosk.databinding.ActivitySettingsBinding + +private const val PREFS_NAME = "evershelf_kiosk" +private const val PREF_URL = "evershelf_url" +private const val DEFAULT_URL = "http://evershelf.local" + +class SettingsActivity : AppCompatActivity() { + + private lateinit var binding: ActivitySettingsBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivitySettingsBinding.inflate(layoutInflater) + setContentView(binding.root) + + val prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE) + binding.editUrl.setText(prefs.getString(PREF_URL, DEFAULT_URL)) + + binding.btnSave.setOnClickListener { + val url = binding.editUrl.text.toString().trim() + if (url.isEmpty()) { + Toast.makeText(this, "URL cannot be empty", Toast.LENGTH_SHORT).show() + return@setOnClickListener + } + prefs.edit().putString(PREF_URL, url).apply() + Toast.makeText(this, "Saved! Returning to kiosk...", Toast.LENGTH_SHORT).show() + finish() + } + + binding.btnBack.setOnClickListener { + finish() + } + } +} diff --git a/evershelf-kiosk/app/src/main/res/layout/activity_kiosk.xml b/evershelf-kiosk/app/src/main/res/layout/activity_kiosk.xml new file mode 100644 index 0000000..e6bb1c5 --- /dev/null +++ b/evershelf-kiosk/app/src/main/res/layout/activity_kiosk.xml @@ -0,0 +1,26 @@ + + + + + + + + + diff --git a/evershelf-kiosk/app/src/main/res/layout/activity_settings.xml b/evershelf-kiosk/app/src/main/res/layout/activity_settings.xml new file mode 100644 index 0000000..d0fd0b0 --- /dev/null +++ b/evershelf-kiosk/app/src/main/res/layout/activity_settings.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + diff --git a/evershelf-kiosk/app/src/main/res/values/colors.xml b/evershelf-kiosk/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..c7e4f5f --- /dev/null +++ b/evershelf-kiosk/app/src/main/res/values/colors.xml @@ -0,0 +1,7 @@ + + + #7C3AED + #059669 + #EF4444 + #1D4ED8 + diff --git a/evershelf-kiosk/app/src/main/res/values/strings.xml b/evershelf-kiosk/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..596aacb --- /dev/null +++ b/evershelf-kiosk/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + EverShelf Kiosk + diff --git a/evershelf-kiosk/build.gradle.kts b/evershelf-kiosk/build.gradle.kts new file mode 100644 index 0000000..0f3a5db --- /dev/null +++ b/evershelf-kiosk/build.gradle.kts @@ -0,0 +1,5 @@ +// Top-level build file +plugins { + id("com.android.application") version "8.2.2" apply false + id("org.jetbrains.kotlin.android") version "1.9.22" apply false +} diff --git a/evershelf-kiosk/gradle.properties b/evershelf-kiosk/gradle.properties new file mode 100644 index 0000000..646c51b --- /dev/null +++ b/evershelf-kiosk/gradle.properties @@ -0,0 +1,2 @@ +android.useAndroidX=true +android.enableJetifier=true diff --git a/evershelf-kiosk/gradle/wrapper/gradle-wrapper.properties b/evershelf-kiosk/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..744c64d --- /dev/null +++ b/evershelf-kiosk/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip +networkTimeout=10000 +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/evershelf-kiosk/settings.gradle.kts b/evershelf-kiosk/settings.gradle.kts new file mode 100644 index 0000000..92e4a2b --- /dev/null +++ b/evershelf-kiosk/settings.gradle.kts @@ -0,0 +1,17 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "EverShelf Kiosk" +include(":app") diff --git a/index.html b/index.html index affedaa..7a5e908 100644 --- a/index.html +++ b/index.html @@ -20,7 +20,7 @@
-

🏠 EverShelfv1.3.0

+

🏠 EverShelfv1.4.0

+ + + - - - @@ -1247,6 +1254,6 @@
- + diff --git a/manifest.json b/manifest.json index 655587a..87f1761 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "name": "EverShelf", "short_name": "EverShelf", "description": "Gestione completa della dispensa di casa con scansione barcode", - "version": "1.2.0", + "version": "1.4.0", "start_url": "/evershelf/", "display": "standalone", "background_color": "#f0f4e8", diff --git a/translations/de.json b/translations/de.json index 1059cbb..f7895fb 100644 --- a/translations/de.json +++ b/translations/de.json @@ -1,462 +1,478 @@ { - "app": { - "name": "EverShelf", - "loading": "Laden..." - }, - "nav": { - "title": "🏠 EverShelf", - "home": "Home", - "inventory": "Vorrat", - "recipes": "Rezepte", - "shopping": "Einkauf", - "log": "Log" - }, - "btn": { - "back": "← Zurück", - "save": "💾 Speichern", - "cancel": "✕ Abbrechen", - "close": "Schließen", - "add": "✅ Hinzufügen", - "delete": "Löschen", - "edit": "✏️ Bearbeiten", - "search": "🔍 Suchen", - "go": "✅ Los", - "toggle_password": "👁️ Anzeigen/Ausblenden", - "load_more": "Mehr laden...", - "save_config": "💾 Konfiguration speichern", - "save_product": "💾 Produkt speichern", - "restart": "↺ Neustart", - "reset_default": "↺ Standard wiederherstellen" - }, - "locations": { - "dispensa": "Vorratskammer", - "frigo": "Kühlschrank", - "freezer": "Gefrierschrank", - "altro": "Sonstiges" - }, - "categories": { - "latticini": "Milchprodukte", - "carne": "Fleisch", - "pesce": "Fisch", - "frutta": "Obst", - "verdura": "Gemüse", - "pasta": "Pasta & Reis", - "pane": "Brot & Backwaren", - "surgelati": "Tiefkühl", - "bevande": "Getränke", - "condimenti": "Gewürze", - "snack": "Snacks & Süßes", - "conserve": "Konserven", - "cereali": "Getreide & Hülsenfrüchte", - "igiene": "Hygiene", - "pulizia": "Reinigung", - "altro": "Sonstiges", - "select": "-- Auswählen --" - }, - "units": { - "pz": "Stk", - "conf": "Pkg", - "g": "g", - "ml": "ml", - "pieces": "Stück", - "grams": "Gramm", - "box": "Packung", - "boxes": "Packungen" - }, - "shopping_sections": { - "frutta_verdura": "Obst & Gemüse", - "carne_pesce": "Fleisch & Fisch", - "latticini": "Milchprodukte & Frisches", - "pane_dolci": "Brot & Süßes", - "pasta": "Pasta & Getreide", - "conserve": "Konserven & Soßen", - "surgelati": "Tiefkühl", - "bevande": "Getränke", - "pulizia_igiene": "Reinigung & Hygiene", - "altro": "Sonstiges" - }, - "dashboard": { - "expired_title": "🚫 Abgelaufen", - "expiring_title": "⏰ Bald ablaufend", - "stats_period": "📊 Letzte 30 Tage", - "opened_title": "📦 Geöffnete Produkte", - "review_title": "🔍 Zu prüfen", - "review_hint": "Mengen, die ungewöhnlich erscheinen. Bestätigen oder ändern.", - "quick_recipe": "🍳 Schnelles Rezept mit ablaufenden Produkten" - }, - "inventory": { - "title": "Vorrat", - "filter_all": "Alle", - "search_placeholder": "🔍 Produkt suchen...", - "empty": "Keine Produkte hier.\nScanne ein Produkt, um es hinzuzufügen!", - "no_items_found": "Keine Bestandseinträge gefunden" - }, - "scan": { - "title": "Produkt scannen", - "mode_shopping": "🛒 Einkaufsmodus", - "mode_shopping_end": "✅ Einkauf beenden", - "zoom": "Zoom", - "barcode_placeholder": "Barcode eingeben...", - "quick_name_divider": "oder Name eingeben", - "quick_name_placeholder": "z.B.: Äpfel, Zucchini, Brot...", - "manual_entry": "✏️ Manuelle Eingabe", - "ai_identify": "🤖 Mit KI identifizieren", - "hint": "Barcode scannen, Produktname eingeben oder KI zur Identifikation nutzen", - "debug_toggle": "🐛 Debug Log", - "barcode_acquired": "🔖 Barcode gescannt: {code}", - "scan_barcode": "🔖 Barcode scannen" - }, - "action": { - "title": "Was möchtest du tun?", - "add_btn": "📥 HINZUFÜGEN", - "add_sub": "in Vorrat/Kühlschrank", - "use_btn": "📤 VERWENDEN / VERBRAUCHEN", - "use_sub": "aus Vorrat/Kühlschrank" - }, - "add": { - "title": "Zum Vorrat hinzufügen", - "location_label": "📍 Wohin?", - "quantity_label": "📦 Menge", - "conf_size_label": "📦 Jede Packung enthält:", - "conf_size_placeholder": "z.B. 300", - "vacuum_label": "🫙 Vakuumiert", - "vacuum_hint": "Ablaufdatum wird automatisch verlängert", - "submit": "✅ Hinzufügen" - }, - "use": { - "title": "Verwenden / Verbrauchen", - "location_label": "📍 Woher?", - "quantity_label": "Wie viel hast du benutzt?", - "partial_hint": "Oder genaue Menge angeben:", - "use_all": "🗑️ ALLES verwendet / Aufgebraucht", - "submit": "📤 Diese Menge verwenden", - "available": "📦 Verfügbar:", - "not_in_inventory": "⚠️ Produkt nicht im Bestand.", - "expiry_warning": "⚠️ Verwende zuerst die{loc}, die am {date} abläuft — {when}!" - }, - "product": { - "title_new": "Neues Produkt", - "title_edit": "Produkt bearbeiten", - "ai_fill": "📷 Foto machen und mit KI identifizieren", - "ai_fill_hint": "KI füllt die Produktfelder automatisch aus", - "name_label": "🏷️ Produktname *", - "name_placeholder": "z.B.: Vollmilch, Penne Nudeln...", - "brand_label": "🏢 Marke", - "brand_placeholder": "z.B.: Barilla, Müller, Knorr...", - "category_label": "📂 Kategorie", - "unit_label": "📏 Maßeinheit", - "default_qty_label": "🔢 Standardmenge", - "conf_size_label": "📦 Jede Packung enthält:", - "conf_size_placeholder": "z.B. 300", - "notes_label": "📝 Notizen", - "notes_placeholder": "z.B.: laktosefrei, bio, nach dem Öffnen im Kühlschrank aufbewahren...", - "barcode_label": "🔖 Barcode", - "barcode_placeholder": "Barcode (falls vorhanden)", - "barcode_hint": "⚠️ Barcode hinzufügen, damit du beim nächsten Einkauf nur scannen musst!", - "submit": "💾 Produkt speichern", - "name_required": "Produktname eingeben", - "conf_size_required": "Packungsinhalt angeben", - "expiry_estimated": "Geschätztes Ablaufdatum:", - "scan_expiry": "Ablaufdatum scannen", - "expiry_hint": "📝 Du kannst das Datum ändern oder mit der Kamera scannen", - "add_batch": "📦 + Charge mit anderem Ablaufdatum", - "package_info": "📦 Packung: {info}", - "edit_catalog": "⚙️ Produktinfo bearbeiten (Name, Marke, Kategorie…)", - "not_recognized": "⚠️ Produkt nicht erkannt", - "edit_info": "✏️ Informationen bearbeiten", - "modify_details": "BEARBEITEN\nAblauf, Ort…" - }, - "products": { - "title": "📦 Alle Produkte", - "search_placeholder": "🔍 Produkt suchen...", - "empty": "Keine Produkte in der Datenbank.\nScanne ein Produkt zum Starten!", - "no_category": "Keine Produkte in dieser Kategorie" - }, - "recipes": { - "title": "🍳 Rezepte", - "generate": "✨ Neues Rezept generieren" - }, - "shopping": { - "title": "🛒 Einkaufsliste", - "bring_loading": "Verbindung zu Bring!...", - "tab_to_buy": "🛍️ Zu kaufen", - "tab_forecast": "🧠 Vorhersage", - "total_label": "💰 Geschätzter Gesamtbetrag", - "section_to_buy": "🛍️ Zu kaufen", - "suggestions_title": "💡 KI-Vorschläge", - "suggestions_add": "✅ Ausgewählte zu Bring! hinzufügen", - "search_prices": "🔍 Alle Preise suchen", - "suggest_btn": "🤖 Einkaufsvorschläge", - "smart_title": "🧠 Intelligente Vorhersagen", - "smart_empty": "Keine Vorhersagen verfügbar.
Füge Produkte zur Vorratskammer hinzu, um intelligente Vorhersagen zu erhalten.", - "smart_filter_all": "Alle", - "smart_filter_critical": "🔴 Dringend", - "smart_filter_high": "🟠 Bald", - "smart_filter_medium": "🟡 Planen", - "smart_filter_low": "🟢 Vorhersage", - "smart_add": "🛒 Ausgewählte zu Bring! hinzufügen", - "empty": "Einkaufsliste leer!\nNutze den Button unten, um Vorschläge zu generieren.", - "already_in_list": "🛒 \"{name}\" ist bereits in der Einkaufsliste", - "already_in_list_short": "ℹ️ Bereits in der Einkaufsliste", - "add_prompt": "Möchtest du es zur Einkaufsliste hinzufügen?", - "smart_already": "📊 Intelligenter Einkauf sagt bereits {name} voraus", - "all_searched": "Alle Produkte wurden bereits gesucht. Nutze 🔄 für einzelne Suchen.", - "search_complete": "Suche abgeschlossen: {count} Produkte", - "removed_sufficient": "🧹 {removed} Produkt(e) mit ausreichendem Bestand von der Liste entfernt" - }, - "ai": { - "title": "🤖 KI-Identifikation", - "capture": "📸 Foto aufnehmen", - "retake": "🔄 Neu aufnehmen", - "hint": "Mache ein Foto des Produkts und die KI versucht es zu identifizieren", - "identifying": "🤖 Identifiziere Produkt...", - "no_api_key": "⚠️ Gemini API-Schlüssel nicht konfiguriert.\nFüge GEMINI_API_KEY in der .env Datei auf dem Server hinzu.", - "fields_filled": "✅ Felder von KI ausgefüllt" - }, - "log": { - "title": "📒 Operationslog" - }, - "chat": { - "title": "Gemini Chef", - "welcome": "Hallo! Ich bin dein Küchenassistent", - "welcome_desc": "Frag mich, dir einen Saft, einen Snack, ein schnelles Gericht zu machen... Ich kenne deinen Vorrat, deine Geräte und deine Vorlieben!", - "suggestion_snack": "🍿 Schneller Snack", - "suggestion_juice": "🥤 Saft/Smoothie", - "suggestion_light": "🥗 Etwas Leichtes", - "suggestion_expiry": "⏰ Ablaufende nutzen", - "clear": "Neues Gespräch", - "placeholder": "Frag etwas..." - }, - "cooking": { - "close": "Schließen", - "tts_btn": "Vorlesen", - "restart": "↺ Neustart", - "replay": "🔊 Nochmal", - "timer": "⏱️ {time} · Timer", - "prev": "◀ Zurück", - "next": "Weiter ▶" - }, - "settings": { - "title": "⚙️ Einstellungen", - "tab_api": "API Keys", - "tab_bring": "Bring!", - "tab_recipe": "Rezepte", - "tab_mealplan": "Wochenplan", - "tab_appliances": "Geräte", - "tab_spesa": "Online-Einkauf", - "tab_camera": "Kamera", - "tab_security": "Sicherheit", - "tab_tts": "Sprache (TTS)", - "tab_language": "Sprache", - "tab_scale": "Smart-Waage", - "gemini": { - "title": "🤖 Google Gemini AI", - "hint": "API-Schlüssel für Produkterkennung, Ablaufdaten und Rezepte.", - "key_label": "Gemini API Key" + "app": { + "name": "EverShelf", + "loading": "Laden..." }, - "bring": { - "title": "🛒 Bring! Einkaufsliste", - "hint": "Zugangsdaten für die Bring! Einkaufslisten-Integration.", - "email_label": "📧 Bring! E-Mail", - "password_label": "🔒 Bring! Passwort" + "nav": { + "title": "🏠 EverShelf", + "home": "Home", + "inventory": "Vorrat", + "recipes": "Rezepte", + "shopping": "Einkauf", + "log": "Log" }, - "recipe": { - "title": "🍳 Rezept-Einstellungen", - "hint": "Konfiguriere die Standardoptionen für die Rezeptgenerierung.", - "persons_label": "👥 Standard-Portionen", - "options_label": "🎯 Standard-Rezeptoptionen", - "fast": "⚡ Schnelles Gericht", - "light": "🥗 Leichte Mahlzeit", - "expiry": "⏰ Ablauf-Priorität", - "healthy": "💚 Extra Gesund", - "opened": "📦 Offene Produkte zuerst", - "zerowaste": "♻️ Keine Verschwendung", - "dietary_label": "🚫 Unverträglichkeiten / Einschränkungen", - "dietary_placeholder": "z.B.: glutenfrei, laktosefrei, vegetarisch..." + "btn": { + "back": "← Zurück", + "save": "💾 Speichern", + "cancel": "✕ Abbrechen", + "close": "Schließen", + "add": "✅ Hinzufügen", + "delete": "Löschen", + "edit": "✏️ Bearbeiten", + "search": "🔍 Suchen", + "go": "✅ Los", + "toggle_password": "👁️ Anzeigen/Ausblenden", + "load_more": "Mehr laden...", + "save_config": "💾 Konfiguration speichern", + "save_product": "💾 Produkt speichern", + "restart": "↺ Neustart", + "reset_default": "↺ Standard wiederherstellen" }, - "mealplan": { - "title": "📅 Wöchentlicher Essensplan", - "hint": "Lege die Mahlzeitenart für jeden Tag fest. Wird als Leitfaden bei der Rezeptgenerierung verwendet.", - "enabled": "✅ Wöchentlichen Essensplan aktivieren", - "legend": "🌤️ = Mittagessen · 🌙 = Abendessen · Tippe auf ein Badge, um es zu ändern.", - "types_title": "📋 Verfügbare Typen" + "locations": { + "dispensa": "Vorratskammer", + "frigo": "Kühlschrank", + "freezer": "Gefrierschrank", + "altro": "Sonstiges" }, - "appliances": { - "title": "🔌 Verfügbare Geräte", - "hint": "Gib an, welche Geräte du hast. Sie werden bei der Rezeptgenerierung berücksichtigt.", - "new_placeholder": "z.B.: Brotbackmaschine, Thermomix, Heißluftfritteuse...", - "quick_title": "Schnell hinzufügen:", - "oven": "🔥 Backofen", - "microwave": "📡 Mikrowelle", - "air_fryer": "🍟 Heißluftfritteuse", - "bread_maker": "🍞 Brotbackmaschine", - "bimby": "🤖 Thermomix/Cookeo", - "mixer": "🌀 Küchenmaschine", - "steamer": "♨️ Dampfgarer", - "pressure_cooker": "🫕 Schnellkochtopf", - "toaster": "🍞 Toaster", - "blender": "🍹 Mixer", - "empty": "Keine Geräte hinzugefügt" + "categories": { + "latticini": "Milchprodukte", + "carne": "Fleisch", + "pesce": "Fisch", + "frutta": "Obst", + "verdura": "Gemüse", + "pasta": "Pasta & Reis", + "pane": "Brot & Backwaren", + "surgelati": "Tiefkühl", + "bevande": "Getränke", + "condimenti": "Gewürze", + "snack": "Snacks & Süßes", + "conserve": "Konserven", + "cereali": "Getreide & Hülsenfrüchte", + "igiene": "Hygiene", + "pulizia": "Reinigung", + "altro": "Sonstiges", + "select": "-- Auswählen --" }, - "spesa": { - "title": "🛍️ Online-Einkauf", - "hint": "Online-Einkaufsanbieter konfigurieren.", - "provider_label": "🏪 Anbieter", - "email_label": "📧 E-Mail", - "password_label": "🔒 Passwort", - "login_btn": "🔐 Anmelden", - "ai_prompt_label": "🤖 KI-Produktauswahl Prompt", - "ai_prompt_placeholder": "Anweisungen für die KI bei der Auswahl zwischen mehreren Produkten...", - "ai_prompt_hint": "Die KI verwendet diesen Prompt zur Auswahl des passendsten Produkts. Leer lassen für Standardverhalten.", - "configure_first": "Konfiguriere zuerst den Online-Einkauf in den Einstellungen" + "units": { + "pz": "Stk", + "conf": "Pkg", + "g": "g", + "ml": "ml", + "pieces": "Stück", + "grams": "Gramm", + "box": "Packung", + "boxes": "Packungen" }, - "camera": { - "title": "📷 Kamera", - "hint": "Wähle die Kamera für Barcode-Scanning und KI-Identifikation.", - "device_label": "📸 Standardkamera", - "back": "📱 Rückkamera (Standard)", - "front": "🤳 Frontkamera", - "devices_hint": "Bei mehreren Kameras kannst du nach Freigabe der Berechtigungen eine bestimmte aus der Liste oben wählen.", - "detect_btn": "🔄 Kameras erkennen" + "shopping_sections": { + "frutta_verdura": "Obst & Gemüse", + "carne_pesce": "Fleisch & Fisch", + "latticini": "Milchprodukte & Frisches", + "pane_dolci": "Brot & Süßes", + "pasta": "Pasta & Getreide", + "conserve": "Konserven & Soßen", + "surgelati": "Tiefkühl", + "bevande": "Getränke", + "pulizia_igiene": "Reinigung & Hygiene", + "altro": "Sonstiges" }, - "security": { - "title": "🔒 HTTPS-Zertifikat", - "hint": "Wenn der Browser den Fehler \"Verbindung nicht sicher\" (ERR_CERT_AUTHORITY_INVALID) zeigt, installiere das CA-Zertifikat auf dem Gerät.", - "download_btn": "📥 CA-Zertifikat herunterladen" + "dashboard": { + "expired_title": "🚫 Abgelaufen", + "expiring_title": "⏰ Bald ablaufend", + "stats_period": "📊 Letzte 30 Tage", + "opened_title": "📦 Geöffnete Produkte", + "review_title": "🔍 Zu prüfen", + "review_hint": "Mengen, die ungewöhnlich erscheinen. Bestätigen oder ändern.", + "quick_recipe": "🍳 Schnelles Rezept mit ablaufenden Produkten", + "banner_review_title": "Ungewöhnliche Menge", + "banner_review_action_ok": "Ist korrekt", + "banner_review_action_edit": "Bearbeiten", + "banner_review_action_weigh": "Wiegen", + "banner_review_dismiss": "Ignorieren", + "banner_prediction_title": "Ungewöhnlicher Verbrauch", + "banner_prediction_hint": "Laut Vorhersage stimmt diese Menge nicht mit dem erwarteten Verbrauch überein.", + "banner_prediction_action_confirm": "Menge bestätigen", + "banner_prediction_action_weigh": "Mit Waage wiegen", + "banner_prediction_action_edit": "Korrigieren" }, - "tts": { - "title": "🔊 Sprache & TTS", - "hint": "Sprachsynthese über externe REST-API konfigurieren. Rezeptschritte und abgelaufene Timer werden an den Endpunkt gesendet.", - "enabled": "✅ TTS aktivieren", - "url_label": "🌐 Endpunkt-URL", - "method_label": "📡 HTTP-Methode", - "auth_label": "🔐 Authentifizierung", - "auth_bearer": "Bearer Token", - "auth_custom": "Benutzerdefinierter Header", - "auth_none": "Keine", - "token_label": "🔑 Bearer Token", - "custom_header_name": "📋 Header-Name", - "custom_header_value": "📋 Header-Wert", - "content_type_label": "📄 Content-Type", - "payload_key_label": "🗝️ Textfeld im Payload", - "payload_key_hint": "Name des JSON-Feldes für den zu lesenden Text (z.B.: message, text).", - "extra_fields_label": "➕ Zusätzliche Felder (JSON)", - "extra_fields_placeholder": "{\"entity_id\": \"media_player.living_room\"}", - "extra_fields_hint": "Zusätzliche Felder im Payload, im JSON-Format. Leer lassen wenn nicht benötigt.", - "test_btn": "🔊 Testansage senden" + "inventory": { + "title": "Vorrat", + "filter_all": "Alle", + "search_placeholder": "🔍 Produkt suchen...", + "empty": "Keine Produkte hier.\nScanne ein Produkt, um es hinzuzufügen!", + "no_items_found": "Keine Bestandseinträge gefunden" }, - "language": { - "title": "🌐 Sprache", - "hint": "Wähle die Sprache der Benutzeroberfläche.", - "label": "🌐 Sprache", - "restart_notice": "Die Seite wird neu geladen, um die neue Sprache anzuwenden." + "scan": { + "title": "Produkt scannen", + "mode_shopping": "🛒 Einkaufsmodus", + "mode_shopping_end": "✅ Einkauf beenden", + "zoom": "Zoom", + "barcode_placeholder": "Barcode eingeben...", + "quick_name_divider": "oder Name eingeben", + "quick_name_placeholder": "z.B.: Äpfel, Zucchini, Brot...", + "manual_entry": "✏️ Manuelle Eingabe", + "ai_identify": "🤖 Mit KI identifizieren", + "hint": "Barcode scannen, Produktname eingeben oder KI zur Identifikation nutzen", + "debug_toggle": "🐛 Debug Log", + "barcode_acquired": "🔖 Barcode gescannt: {code}", + "scan_barcode": "🔖 Barcode scannen" + }, + "action": { + "title": "Was möchtest du tun?", + "add_btn": "📥 HINZUFÜGEN", + "add_sub": "in Vorrat/Kühlschrank", + "use_btn": "📤 VERWENDEN / VERBRAUCHEN", + "use_sub": "aus Vorrat/Kühlschrank" + }, + "add": { + "title": "Zum Vorrat hinzufügen", + "location_label": "📍 Wohin?", + "quantity_label": "📦 Menge", + "conf_size_label": "📦 Jede Packung enthält:", + "conf_size_placeholder": "z.B. 300", + "vacuum_label": "🫙 Vakuumiert", + "vacuum_hint": "Ablaufdatum wird automatisch verlängert", + "submit": "✅ Hinzufügen" + }, + "use": { + "title": "Verwenden / Verbrauchen", + "location_label": "📍 Woher?", + "quantity_label": "Wie viel hast du benutzt?", + "partial_hint": "Oder genaue Menge angeben:", + "use_all": "🗑️ ALLES verwendet / Aufgebraucht", + "submit": "📤 Diese Menge verwenden", + "available": "📦 Verfügbar:", + "not_in_inventory": "⚠️ Produkt nicht im Bestand.", + "expiry_warning": "⚠️ Verwende zuerst die{loc}, die am {date} abläuft — {when}!" + }, + "product": { + "title_new": "Neues Produkt", + "title_edit": "Produkt bearbeiten", + "ai_fill": "📷 Foto machen und mit KI identifizieren", + "ai_fill_hint": "KI füllt die Produktfelder automatisch aus", + "name_label": "🏷️ Produktname *", + "name_placeholder": "z.B.: Vollmilch, Penne Nudeln...", + "brand_label": "🏢 Marke", + "brand_placeholder": "z.B.: Barilla, Müller, Knorr...", + "category_label": "📂 Kategorie", + "unit_label": "📏 Maßeinheit", + "default_qty_label": "🔢 Standardmenge", + "conf_size_label": "📦 Jede Packung enthält:", + "conf_size_placeholder": "z.B. 300", + "notes_label": "📝 Notizen", + "notes_placeholder": "z.B.: laktosefrei, bio, nach dem Öffnen im Kühlschrank aufbewahren...", + "barcode_label": "🔖 Barcode", + "barcode_placeholder": "Barcode (falls vorhanden)", + "barcode_hint": "⚠️ Barcode hinzufügen, damit du beim nächsten Einkauf nur scannen musst!", + "submit": "💾 Produkt speichern", + "name_required": "Produktname eingeben", + "conf_size_required": "Packungsinhalt angeben", + "expiry_estimated": "Geschätztes Ablaufdatum:", + "scan_expiry": "Ablaufdatum scannen", + "expiry_hint": "📝 Du kannst das Datum ändern oder mit der Kamera scannen", + "add_batch": "📦 + Charge mit anderem Ablaufdatum", + "package_info": "📦 Packung: {info}", + "edit_catalog": "⚙️ Produktinfo bearbeiten (Name, Marke, Kategorie…)", + "not_recognized": "⚠️ Produkt nicht erkannt", + "edit_info": "✏️ Informationen bearbeiten", + "modify_details": "BEARBEITEN\nAblauf, Ort…" + }, + "products": { + "title": "📦 Alle Produkte", + "search_placeholder": "🔍 Produkt suchen...", + "empty": "Keine Produkte in der Datenbank.\nScanne ein Produkt zum Starten!", + "no_category": "Keine Produkte in dieser Kategorie" + }, + "recipes": { + "title": "🍳 Rezepte", + "generate": "✨ Neues Rezept generieren" + }, + "shopping": { + "title": "🛒 Einkaufsliste", + "bring_loading": "Verbindung zu Bring!...", + "tab_to_buy": "🛍️ Zu kaufen", + "tab_forecast": "🧠 Vorhersage", + "total_label": "💰 Geschätzter Gesamtbetrag", + "section_to_buy": "🛍️ Zu kaufen", + "suggestions_title": "💡 KI-Vorschläge", + "suggestions_add": "✅ Ausgewählte zu Bring! hinzufügen", + "search_prices": "🔍 Alle Preise suchen", + "suggest_btn": "🤖 Einkaufsvorschläge", + "smart_title": "🧠 Intelligente Vorhersagen", + "smart_empty": "Keine Vorhersagen verfügbar.
Füge Produkte zur Vorratskammer hinzu, um intelligente Vorhersagen zu erhalten.", + "smart_filter_all": "Alle", + "smart_filter_critical": "🔴 Dringend", + "smart_filter_high": "🟠 Bald", + "smart_filter_medium": "🟡 Planen", + "smart_filter_low": "🟢 Vorhersage", + "smart_add": "🛒 Ausgewählte zu Bring! hinzufügen", + "empty": "Einkaufsliste leer!\nNutze den Button unten, um Vorschläge zu generieren.", + "already_in_list": "🛒 \"{name}\" ist bereits in der Einkaufsliste", + "already_in_list_short": "ℹ️ Bereits in der Einkaufsliste", + "add_prompt": "Möchtest du es zur Einkaufsliste hinzufügen?", + "smart_already": "📊 Intelligenter Einkauf sagt bereits {name} voraus", + "all_searched": "Alle Produkte wurden bereits gesucht. Nutze 🔄 für einzelne Suchen.", + "search_complete": "Suche abgeschlossen: {count} Produkte", + "removed_sufficient": "🧹 {removed} Produkt(e) mit ausreichendem Bestand von der Liste entfernt" + }, + "ai": { + "title": "🤖 KI-Identifikation", + "capture": "📸 Foto aufnehmen", + "retake": "🔄 Neu aufnehmen", + "hint": "Mache ein Foto des Produkts und die KI versucht es zu identifizieren", + "identifying": "🤖 Identifiziere Produkt...", + "no_api_key": "⚠️ Gemini API-Schlüssel nicht konfiguriert.\nFüge GEMINI_API_KEY in der .env Datei auf dem Server hinzu.", + "fields_filled": "✅ Felder von KI ausgefüllt" + }, + "log": { + "title": "📒 Operationslog" + }, + "chat": { + "title": "Gemini Chef", + "welcome": "Hallo! Ich bin dein Küchenassistent", + "welcome_desc": "Frag mich, dir einen Saft, einen Snack, ein schnelles Gericht zu machen... Ich kenne deinen Vorrat, deine Geräte und deine Vorlieben!", + "suggestion_snack": "🍿 Schneller Snack", + "suggestion_juice": "🥤 Saft/Smoothie", + "suggestion_light": "🥗 Etwas Leichtes", + "suggestion_expiry": "⏰ Ablaufende nutzen", + "clear": "Neues Gespräch", + "placeholder": "Frag etwas..." + }, + "cooking": { + "close": "Schließen", + "tts_btn": "Vorlesen", + "restart": "↺ Neustart", + "replay": "🔊 Nochmal", + "timer": "⏱️ {time} · Timer", + "prev": "◀ Zurück", + "next": "Weiter ▶" + }, + "settings": { + "title": "⚙️ Einstellungen", + "tab_api": "API Keys", + "tab_bring": "Bring!", + "tab_recipe": "Rezepte", + "tab_mealplan": "Wochenplan", + "tab_appliances": "Geräte", + "tab_spesa": "Online-Einkauf", + "tab_camera": "Kamera", + "tab_security": "Sicherheit", + "tab_tts": "Sprache (TTS)", + "tab_language": "Sprache", + "tab_scale": "Smart-Waage", + "gemini": { + "title": "🤖 Google Gemini AI", + "hint": "API-Schlüssel für Produkterkennung, Ablaufdaten und Rezepte.", + "key_label": "Gemini API Key" + }, + "bring": { + "title": "🛒 Bring! Einkaufsliste", + "hint": "Zugangsdaten für die Bring! Einkaufslisten-Integration.", + "email_label": "📧 Bring! E-Mail", + "password_label": "🔒 Bring! Passwort" + }, + "recipe": { + "title": "🍳 Rezept-Einstellungen", + "hint": "Konfiguriere die Standardoptionen für die Rezeptgenerierung.", + "persons_label": "👥 Standard-Portionen", + "options_label": "🎯 Standard-Rezeptoptionen", + "fast": "⚡ Schnelles Gericht", + "light": "🥗 Leichte Mahlzeit", + "expiry": "⏰ Ablauf-Priorität", + "healthy": "💚 Extra Gesund", + "opened": "📦 Offene Produkte zuerst", + "zerowaste": "♻️ Keine Verschwendung", + "dietary_label": "🚫 Unverträglichkeiten / Einschränkungen", + "dietary_placeholder": "z.B.: glutenfrei, laktosefrei, vegetarisch..." + }, + "mealplan": { + "title": "📅 Wöchentlicher Essensplan", + "hint": "Lege die Mahlzeitenart für jeden Tag fest. Wird als Leitfaden bei der Rezeptgenerierung verwendet.", + "enabled": "✅ Wöchentlichen Essensplan aktivieren", + "legend": "🌤️ = Mittagessen · 🌙 = Abendessen · Tippe auf ein Badge, um es zu ändern.", + "types_title": "📋 Verfügbare Typen" + }, + "appliances": { + "title": "🔌 Verfügbare Geräte", + "hint": "Gib an, welche Geräte du hast. Sie werden bei der Rezeptgenerierung berücksichtigt.", + "new_placeholder": "z.B.: Brotbackmaschine, Thermomix, Heißluftfritteuse...", + "quick_title": "Schnell hinzufügen:", + "oven": "🔥 Backofen", + "microwave": "📡 Mikrowelle", + "air_fryer": "🍟 Heißluftfritteuse", + "bread_maker": "🍞 Brotbackmaschine", + "bimby": "🤖 Thermomix/Cookeo", + "mixer": "🌀 Küchenmaschine", + "steamer": "♨️ Dampfgarer", + "pressure_cooker": "🫕 Schnellkochtopf", + "toaster": "🍞 Toaster", + "blender": "🍹 Mixer", + "empty": "Keine Geräte hinzugefügt" + }, + "spesa": { + "title": "🛍️ Online-Einkauf", + "hint": "Online-Einkaufsanbieter konfigurieren.", + "provider_label": "🏪 Anbieter", + "email_label": "📧 E-Mail", + "password_label": "🔒 Passwort", + "login_btn": "🔐 Anmelden", + "ai_prompt_label": "🤖 KI-Produktauswahl Prompt", + "ai_prompt_placeholder": "Anweisungen für die KI bei der Auswahl zwischen mehreren Produkten...", + "ai_prompt_hint": "Die KI verwendet diesen Prompt zur Auswahl des passendsten Produkts. Leer lassen für Standardverhalten.", + "configure_first": "Konfiguriere zuerst den Online-Einkauf in den Einstellungen" + }, + "camera": { + "title": "📷 Kamera", + "hint": "Wähle die Kamera für Barcode-Scanning und KI-Identifikation.", + "device_label": "📸 Standardkamera", + "back": "📱 Rückkamera (Standard)", + "front": "🤳 Frontkamera", + "devices_hint": "Bei mehreren Kameras kannst du nach Freigabe der Berechtigungen eine bestimmte aus der Liste oben wählen.", + "detect_btn": "🔄 Kameras erkennen" + }, + "security": { + "title": "🔒 HTTPS-Zertifikat", + "hint": "Wenn der Browser den Fehler \"Verbindung nicht sicher\" (ERR_CERT_AUTHORITY_INVALID) zeigt, installiere das CA-Zertifikat auf dem Gerät.", + "download_btn": "📥 CA-Zertifikat herunterladen" + }, + "tts": { + "title": "🔊 Sprache & TTS", + "hint": "Sprachsynthese über externe REST-API konfigurieren. Rezeptschritte und abgelaufene Timer werden an den Endpunkt gesendet.", + "enabled": "✅ TTS aktivieren", + "url_label": "🌐 Endpunkt-URL", + "method_label": "📡 HTTP-Methode", + "auth_label": "🔐 Authentifizierung", + "auth_bearer": "Bearer Token", + "auth_custom": "Benutzerdefinierter Header", + "auth_none": "Keine", + "token_label": "🔑 Bearer Token", + "custom_header_name": "📋 Header-Name", + "custom_header_value": "📋 Header-Wert", + "content_type_label": "📄 Content-Type", + "payload_key_label": "🗝️ Textfeld im Payload", + "payload_key_hint": "Name des JSON-Feldes für den zu lesenden Text (z.B.: message, text).", + "extra_fields_label": "➕ Zusätzliche Felder (JSON)", + "extra_fields_placeholder": "{\"entity_id\": \"media_player.living_room\"}", + "extra_fields_hint": "Zusätzliche Felder im Payload, im JSON-Format. Leer lassen wenn nicht benötigt.", + "test_btn": "🔊 Testansage senden" + }, + "language": { + "title": "🌐 Sprache", + "hint": "Wähle die Sprache der Benutzeroberfläche.", + "label": "🌐 Sprache", + "restart_notice": "Die Seite wird neu geladen, um die neue Sprache anzuwenden." + }, + "scale": { + "title": "⚖️ Smart-Waage", + "hint": "Verbinde eine Bluetooth-Waage über das Android-Gateway, um das Gewicht automatisch auszulesen.", + "tab": "Smart-Waage", + "enabled": "✅ Smart-Waage aktivieren", + "url_label": "🌐 WebSocket-Gateway-URL", + "url_placeholder": "ws://192.168.1.x:8765", + "url_hint": "URL der Android-App (gleiches WLAN). z.B.:", + "test_btn": "🔗 Verbindung testen", + "download_btn": "📥 Android-Gateway herunterladen (APK)", + "download_hint": "Android-App als Brücke zwischen BLE-Waage und EverShelf.", + "download_sub": "Quellcode: evershelf-scale-gateway/ im Projektstamm" + }, + "saved": "✅ Konfiguration gespeichert!", + "saved_local": "✅ Konfiguration lokal gespeichert", + "saved_local_error": "⚠️ Lokal gespeichert, Serverfehler: {error}" + }, + "expiry": { + "today": "HEUTE", + "tomorrow": "Morgen", + "days": "{days} Tage", + "expired_days": "Seit {days}T", + "expired_yesterday": "Seit gestern", + "expired_today": "Heute" + }, + "status": { + "ok": "OK", + "check": "Prüfen", + "discard": "Entsorgen" + }, + "toast": { + "product_saved": "Produkt gespeichert!", + "product_created": "Produkt erstellt!", + "product_updated": "✅ Produkt aktualisiert!", + "product_removed": "Produkt entfernt", + "updated": "Aktualisiert!", + "quantity_confirmed": "✓ Menge bestätigt", + "added_to_inventory": "✅ {name} hinzugefügt!", + "removed_from_list": "✅ {name} von der Liste entfernt!", + "removed_from_list_short": "Von der Liste entfernt", + "added_to_shopping": "🛒 Zur Einkaufsliste hinzugefügt!", + "removed_from_shopping": "🛒 Von der Einkaufsliste entfernt", + "finished_to_bring": "🛒 Produkt aufgebraucht → zu Bring! hinzugefügt", + "thrown_away": "🗑️ {name} weggeworfen!", + "thrown_away_partial": "🗑️ {qty} {unit} von {name} weggeworfen", + "appliance_added": "Gerät hinzugefügt", + "item_added": "{name} hinzugefügt" + }, + "error": { + "generic": "Fehler", + "loading": "Fehler beim Laden des Produkts", + "not_found": "Produkt nicht gefunden", + "not_found_manual": "Produkt nicht gefunden. Manuell eingeben.", + "search": "Suchfehler. Nochmal versuchen.", + "search_short": "Suchfehler", + "save": "Fehler beim Speichern", + "connection": "Verbindungsfehler", + "camera": "Kamera nicht verfügbar", + "bring_add": "Fehler beim Hinzufügen zu Bring!", + "bring_connection": "Bring! Verbindungsfehler", + "identification": "Identifikationsfehler", + "barcode_empty": "Barcode eingeben", + "barcode_format": "Barcode darf nur Zahlen enthalten (4-14 Ziffern)", + "min_chars": "Mindestens 2 Zeichen eingeben", + "not_in_inventory": "Produkt nicht im Bestand", + "appliance_exists": "Gerät bereits vorhanden", + "already_exists": "Bereits vorhanden" + }, + "confirm": { + "remove_item": "Möchtest du dieses Produkt wirklich aus dem Bestand entfernen?" + }, + "edit": { + "title": "{name} bearbeiten" + }, + "screensaver": { + "recipe_btn": "Rezepte", + "scan_btn": "Produkt scannen" + }, + "days": { + "mon": "Montag", + "tue": "Dienstag", + "wed": "Mittwoch", + "thu": "Donnerstag", + "fri": "Freitag", + "sat": "Samstag", + "sun": "Sonntag" + }, + "meal_types": { + "lunch": "Mittagessen", + "dinner": "Abendessen" }, "scale": { - "title": "⚖️ Smart-Waage", - "hint": "Verbinde eine Bluetooth-Waage über das Android-Gateway, um das Gewicht automatisch auszulesen.", - "tab": "Smart-Waage", - "enabled": "✅ Smart-Waage aktivieren", - "url_label": "🌐 WebSocket-Gateway-URL", - "url_placeholder": "ws://192.168.1.x:8765", - "url_hint": "URL der Android-App (gleiches WLAN). z.B.:", - "test_btn": "🔗 Verbindung testen", - "download_btn": "📥 Android-Gateway herunterladen (APK)", - "download_hint": "Android-App als Brücke zwischen BLE-Waage und EverShelf.", - "download_sub": "Quellcode: evershelf-scale-gateway/ im Projektstamm" + "status_connected": "Waage verbunden", + "status_searching": "Gateway verbunden, warte auf Waage…", + "status_disconnected": "Waagen-Gateway nicht erreichbar", + "status_error": "Verbindungsfehler zum Gateway", + "not_connected": "Waagen-Gateway nicht verbunden", + "read_btn": "⚖️ Von Waage lesen", + "reading_title": "Waage lesen", + "place_on_scale": "Produkt auf die Waage legen…", + "waiting_stable": "Das Gewicht wird automatisch erfasst, wenn die Messung stabil ist.", + "no_url": "Gateway-URL eingeben", + "testing": "⏳ Verbindung wird getestet…", + "connected_ok": "Gateway-Verbindung erfolgreich!", + "timeout": "Timeout: keine Antwort vom Gateway", + "error_connect": "Verbindung zum Gateway nicht möglich", + "tab": "Smart-Waage", + "low_weight": "Gewicht < 10 g · manuell eingeben\n(Auto-Erkennung erfordert mind. 10 g)" }, - "saved": "✅ Konfiguration gespeichert!", - "saved_local": "✅ Konfiguration lokal gespeichert", - "saved_local_error": "⚠️ Lokal gespeichert, Serverfehler: {error}" - }, - "expiry": { - "today": "HEUTE", - "tomorrow": "Morgen", - "days": "{days} Tage", - "expired_days": "Seit {days}T", - "expired_yesterday": "Seit gestern", - "expired_today": "Heute" - }, - "status": { - "ok": "OK", - "check": "Prüfen", - "discard": "Entsorgen" - }, - "toast": { - "product_saved": "Produkt gespeichert!", - "product_created": "Produkt erstellt!", - "product_updated": "✅ Produkt aktualisiert!", - "product_removed": "Produkt entfernt", - "updated": "Aktualisiert!", - "quantity_confirmed": "✓ Menge bestätigt", - "added_to_inventory": "✅ {name} hinzugefügt!", - "removed_from_list": "✅ {name} von der Liste entfernt!", - "removed_from_list_short": "Von der Liste entfernt", - "added_to_shopping": "🛒 Zur Einkaufsliste hinzugefügt!", - "removed_from_shopping": "🛒 Von der Einkaufsliste entfernt", - "finished_to_bring": "🛒 Produkt aufgebraucht → zu Bring! hinzugefügt", - "thrown_away": "🗑️ {name} weggeworfen!", - "thrown_away_partial": "🗑️ {qty} {unit} von {name} weggeworfen", - "appliance_added": "Gerät hinzugefügt", - "item_added": "{name} hinzugefügt" - }, - "error": { - "generic": "Fehler", - "loading": "Fehler beim Laden des Produkts", - "not_found": "Produkt nicht gefunden", - "not_found_manual": "Produkt nicht gefunden. Manuell eingeben.", - "search": "Suchfehler. Nochmal versuchen.", - "search_short": "Suchfehler", - "save": "Fehler beim Speichern", - "connection": "Verbindungsfehler", - "camera": "Kamera nicht verfügbar", - "bring_add": "Fehler beim Hinzufügen zu Bring!", - "bring_connection": "Bring! Verbindungsfehler", - "identification": "Identifikationsfehler", - "barcode_empty": "Barcode eingeben", - "barcode_format": "Barcode darf nur Zahlen enthalten (4-14 Ziffern)", - "min_chars": "Mindestens 2 Zeichen eingeben", - "not_in_inventory": "Produkt nicht im Bestand", - "appliance_exists": "Gerät bereits vorhanden", - "already_exists": "Bereits vorhanden" - }, - "confirm": { - "remove_item": "Möchtest du dieses Produkt wirklich aus dem Bestand entfernen?" - }, - "edit": { - "title": "{name} bearbeiten" - }, - "screensaver": { - "recipe_btn": "Rezepte", - "scan_btn": "Produkt scannen" - }, - "days": { - "mon": "Montag", - "tue": "Dienstag", - "wed": "Mittwoch", - "thu": "Donnerstag", - "fri": "Freitag", - "sat": "Samstag", - "sun": "Sonntag" - }, - "meal_types": { - "lunch": "Mittagessen", - "dinner": "Abendessen" - }, - "scale": { - "status_connected": "Waage verbunden", - "status_searching": "Gateway verbunden, warte auf Waage…", - "status_disconnected": "Waagen-Gateway nicht erreichbar", - "status_error": "Verbindungsfehler zum Gateway", - "not_connected": "Waagen-Gateway nicht verbunden", - "read_btn": "⚖️ Von Waage lesen", - "reading_title": "Waage lesen", - "place_on_scale": "Produkt auf die Waage legen…", - "waiting_stable": "Das Gewicht wird automatisch erfasst, wenn die Messung stabil ist.", - "no_url": "Gateway-URL eingeben", - "testing": "⏳ Verbindung wird getestet…", - "connected_ok": "Gateway-Verbindung erfolgreich!", - "timeout": "Timeout: keine Antwort vom Gateway", - "error_connect": "Verbindung zum Gateway nicht möglich", - "tab": "Smart-Waage" - } -} + "prediction": { + "expected_qty": "Erwartet: {expected} {unit}", + "actual_qty": "Aktuell: {actual} {unit}", + "check_suggestion": "Überprüfe oder wiege die Restmenge" + } +} \ No newline at end of file diff --git a/translations/en.json b/translations/en.json index 3acce0c..a75dc60 100644 --- a/translations/en.json +++ b/translations/en.json @@ -1,462 +1,478 @@ { - "app": { - "name": "EverShelf", - "loading": "Loading..." - }, - "nav": { - "title": "🏠 EverShelf", - "home": "Home", - "inventory": "Pantry", - "recipes": "Recipes", - "shopping": "Shopping", - "log": "Log" - }, - "btn": { - "back": "← Back", - "save": "💾 Save", - "cancel": "✕ Cancel", - "close": "Close", - "add": "✅ Add", - "delete": "Delete", - "edit": "✏️ Edit", - "search": "🔍 Search", - "go": "✅ Go", - "toggle_password": "👁️ Show/Hide", - "load_more": "Load more...", - "save_config": "💾 Save Configuration", - "save_product": "💾 Save Product", - "restart": "↺ Restart", - "reset_default": "↺ Reset to default" - }, - "locations": { - "dispensa": "Pantry", - "frigo": "Fridge", - "freezer": "Freezer", - "altro": "Other" - }, - "categories": { - "latticini": "Dairy", - "carne": "Meat", - "pesce": "Fish", - "frutta": "Fruit", - "verdura": "Vegetables", - "pasta": "Pasta & Rice", - "pane": "Bread & Bakery", - "surgelati": "Frozen", - "bevande": "Beverages", - "condimenti": "Condiments", - "snack": "Snacks & Sweets", - "conserve": "Canned Goods", - "cereali": "Cereals & Legumes", - "igiene": "Hygiene", - "pulizia": "Household", - "altro": "Other", - "select": "-- Select --" - }, - "units": { - "pz": "pcs", - "conf": "pkg", - "g": "g", - "ml": "ml", - "pieces": "Pieces", - "grams": "Grams", - "box": "Package", - "boxes": "Packages" - }, - "shopping_sections": { - "frutta_verdura": "Fruits & Vegetables", - "carne_pesce": "Meat & Fish", - "latticini": "Dairy & Fresh", - "pane_dolci": "Bread & Sweets", - "pasta": "Pasta & Cereals", - "conserve": "Canned & Sauces", - "surgelati": "Frozen", - "bevande": "Beverages", - "pulizia_igiene": "Cleaning & Hygiene", - "altro": "Other" - }, - "dashboard": { - "expired_title": "🚫 Expired", - "expiring_title": "⏰ Expiring Soon", - "stats_period": "📊 Last 30 days", - "opened_title": "📦 Opened Products", - "review_title": "🔍 To Review", - "review_hint": "Quantities that seem unusual. Confirm if correct or modify.", - "quick_recipe": "🍳 Quick recipe with expiring products" - }, - "inventory": { - "title": "Pantry", - "filter_all": "All", - "search_placeholder": "🔍 Search product...", - "empty": "No products here.\nScan a product to add it!", - "no_items_found": "No inventory items found" - }, - "scan": { - "title": "Scan Product", - "mode_shopping": "🛒 Shopping Mode", - "mode_shopping_end": "✅ End shopping", - "zoom": "Zoom", - "barcode_placeholder": "Enter barcode...", - "quick_name_divider": "or type the name", - "quick_name_placeholder": "E.g.: Apples, Zucchini, Bread...", - "manual_entry": "✏️ Manual Entry", - "ai_identify": "🤖 Identify with AI", - "hint": "Scan the barcode, type the product name, or use AI to identify it", - "debug_toggle": "🐛 Debug Log", - "barcode_acquired": "🔖 Barcode scanned: {code}", - "scan_barcode": "🔖 Scan Barcode" - }, - "action": { - "title": "What do you want to do?", - "add_btn": "📥 ADD", - "add_sub": "to pantry/fridge", - "use_btn": "📤 USE / CONSUME", - "use_sub": "from pantry/fridge" - }, - "add": { - "title": "Add to Pantry", - "location_label": "📍 Where do you put it?", - "quantity_label": "📦 Quantity", - "conf_size_label": "📦 Each package contains:", - "conf_size_placeholder": "e.g. 300", - "vacuum_label": "🫙 Vacuum sealed", - "vacuum_hint": "Expiry date will be extended automatically", - "submit": "✅ Add" - }, - "use": { - "title": "Use / Consume", - "location_label": "📍 From where?", - "quantity_label": "How much did you use?", - "partial_hint": "Or specify the quantity used:", - "use_all": "🗑️ Used ALL / Finished", - "submit": "📤 Use this quantity", - "available": "📦 Available:", - "not_in_inventory": "⚠️ Product not in inventory.", - "expiry_warning": "⚠️ Use first the one{loc} that expires on {date} — {when}!" - }, - "product": { - "title_new": "New Product", - "title_edit": "Edit Product", - "ai_fill": "📷 Take photo and identify with AI", - "ai_fill_hint": "AI will automatically fill in the product fields", - "name_label": "🏷️ Product Name *", - "name_placeholder": "E.g.: Whole milk, Penne pasta...", - "brand_label": "🏢 Brand", - "brand_placeholder": "E.g.: Barilla, Granarolo, Mutti...", - "category_label": "📂 Category", - "unit_label": "📏 Unit of measure", - "default_qty_label": "🔢 Default quantity", - "conf_size_label": "📦 Each package contains:", - "conf_size_placeholder": "e.g. 300", - "notes_label": "📝 Notes", - "notes_placeholder": "E.g.: lactose free, organic, store in fridge after opening...", - "barcode_label": "🔖 Barcode", - "barcode_placeholder": "Barcode (if available)", - "barcode_hint": "⚠️ Add the barcode so next time you just need to scan it!", - "submit": "💾 Save Product", - "name_required": "Enter the product name", - "conf_size_required": "Specify the package content", - "expiry_estimated": "Estimated expiry:", - "scan_expiry": "Scan expiry date", - "expiry_hint": "📝 You can edit the date or scan it with the camera", - "add_batch": "📦 + Batch with different expiry", - "package_info": "📦 Package: {info}", - "edit_catalog": "⚙️ Edit product info (name, brand, category…)", - "not_recognized": "⚠️ Product not recognized", - "edit_info": "✏️ Edit information", - "modify_details": "EDIT\nexpiry, location…" - }, - "products": { - "title": "📦 All Products", - "search_placeholder": "🔍 Search product...", - "empty": "No products in database.\nScan a product to get started!", - "no_category": "No products in this category" - }, - "recipes": { - "title": "🍳 Recipes", - "generate": "✨ Generate new recipe" - }, - "shopping": { - "title": "🛒 Shopping List", - "bring_loading": "Connecting to Bring!...", - "tab_to_buy": "🛍️ To buy", - "tab_forecast": "🧠 Forecast", - "total_label": "💰 Estimated total", - "section_to_buy": "🛍️ To buy", - "suggestions_title": "💡 AI Suggestions", - "suggestions_add": "✅ Add selected to Bring!", - "search_prices": "🔍 Search all prices", - "suggest_btn": "🤖 Suggest what to buy", - "smart_title": "🧠 Smart Predictions", - "smart_empty": "No predictions available.
Add products to your pantry to receive smart predictions.", - "smart_filter_all": "All", - "smart_filter_critical": "🔴 Urgent", - "smart_filter_high": "🟠 Soon", - "smart_filter_medium": "🟡 Plan", - "smart_filter_low": "🟢 Forecast", - "smart_add": "🛒 Add selected to Bring!", - "empty": "Shopping list empty!\nUse the button below to generate suggestions.", - "already_in_list": "🛒 \"{name}\" is already in the shopping list", - "already_in_list_short": "ℹ️ Already in the shopping list", - "add_prompt": "Do you want to add it to the shopping list?", - "smart_already": "📊 Smart shopping already predicts {name}", - "all_searched": "All products have already been searched. Use 🔄 to search individual ones.", - "search_complete": "Search complete: {count} products", - "removed_sufficient": "🧹 {removed} product(s) with sufficient stock removed from the list" - }, - "ai": { - "title": "🤖 AI Identification", - "capture": "📸 Take Photo", - "retake": "🔄 Retake", - "hint": "Take a photo of the product and AI will try to identify it", - "identifying": "🤖 Identifying product...", - "no_api_key": "⚠️ Gemini API key not configured.\nAdd GEMINI_API_KEY to the .env file on the server.", - "fields_filled": "✅ Fields filled by AI" - }, - "log": { - "title": "📒 Operations Log" - }, - "chat": { - "title": "Gemini Chef", - "welcome": "Hi! I'm your kitchen assistant", - "welcome_desc": "Ask me to make you a juice, a snack, a quick dish... I know your pantry, your appliances and your preferences!", - "suggestion_snack": "🍿 Quick snack", - "suggestion_juice": "🥤 Juice/Smoothie", - "suggestion_light": "🥗 Something light", - "suggestion_expiry": "⏰ Use expiring items", - "clear": "New conversation", - "placeholder": "Ask something..." - }, - "cooking": { - "close": "Close", - "tts_btn": "Read aloud", - "restart": "↺ Restart", - "replay": "🔊 Replay", - "timer": "⏱️ {time} · Timer", - "prev": "◀ Previous", - "next": "Next ▶" - }, - "settings": { - "title": "⚙️ Settings", - "tab_api": "API Keys", - "tab_bring": "Bring!", - "tab_recipe": "Recipes", - "tab_mealplan": "Weekly Plan", - "tab_appliances": "Appliances", - "tab_spesa": "Online Shopping", - "tab_camera": "Camera", - "tab_security": "Security", - "tab_tts": "Voice (TTS)", - "tab_language": "Language", - "tab_scale": "Smart Scale", - "gemini": { - "title": "🤖 Google Gemini AI", - "hint": "API key for product identification, expiry dates and recipes.", - "key_label": "Gemini API Key" + "app": { + "name": "EverShelf", + "loading": "Loading..." }, - "bring": { - "title": "🛒 Bring! Shopping List", - "hint": "Credentials for the Bring! shopping list integration.", - "email_label": "📧 Bring! Email", - "password_label": "🔒 Bring! Password" + "nav": { + "title": "🏠 EverShelf", + "home": "Home", + "inventory": "Pantry", + "recipes": "Recipes", + "shopping": "Shopping", + "log": "Log" }, - "recipe": { - "title": "🍳 Recipe Preferences", - "hint": "Configure the default options for recipe generation.", - "persons_label": "👥 Default servings", - "options_label": "🎯 Default recipe options", - "fast": "⚡ Quick Meal", - "light": "🥗 Light Meal", - "expiry": "⏰ Expiry Priority", - "healthy": "💚 Extra Healthy", - "opened": "📦 Open Items Priority", - "zerowaste": "♻️ Zero Waste", - "dietary_label": "🚫 Intolerances / Restrictions", - "dietary_placeholder": "E.g.: gluten free, lactose free, vegetarian..." + "btn": { + "back": "← Back", + "save": "💾 Save", + "cancel": "✕ Cancel", + "close": "Close", + "add": "✅ Add", + "delete": "Delete", + "edit": "✏️ Edit", + "search": "🔍 Search", + "go": "✅ Go", + "toggle_password": "👁️ Show/Hide", + "load_more": "Load more...", + "save_config": "💾 Save Configuration", + "save_product": "💾 Save Product", + "restart": "↺ Restart", + "reset_default": "↺ Reset to default" }, - "mealplan": { - "title": "📅 Weekly Meal Plan", - "hint": "Set the meal type for each day. It will be used as a guide in recipe generation.", - "enabled": "✅ Enable weekly meal plan", - "legend": "🌤️ = Lunch · 🌙 = Dinner · Tap a badge to change it.", - "types_title": "📋 Available types" + "locations": { + "dispensa": "Pantry", + "frigo": "Fridge", + "freezer": "Freezer", + "altro": "Other" }, - "appliances": { - "title": "🔌 Available Appliances", - "hint": "Indicate the appliances you have. They will be considered in recipe generation.", - "new_placeholder": "E.g.: Bread machine, Thermomix, Air fryer...", - "quick_title": "Quick add:", - "oven": "🔥 Oven", - "microwave": "📡 Microwave", - "air_fryer": "🍟 Air fryer", - "bread_maker": "🍞 Bread maker", - "bimby": "🤖 Thermomix/Cookeo", - "mixer": "🌀 Stand mixer", - "steamer": "♨️ Steamer", - "pressure_cooker": "🫕 Pressure cooker", - "toaster": "🍞 Toaster", - "blender": "🍹 Blender", - "empty": "No appliances added" + "categories": { + "latticini": "Dairy", + "carne": "Meat", + "pesce": "Fish", + "frutta": "Fruit", + "verdura": "Vegetables", + "pasta": "Pasta & Rice", + "pane": "Bread & Bakery", + "surgelati": "Frozen", + "bevande": "Beverages", + "condimenti": "Condiments", + "snack": "Snacks & Sweets", + "conserve": "Canned Goods", + "cereali": "Cereals & Legumes", + "igiene": "Hygiene", + "pulizia": "Household", + "altro": "Other", + "select": "-- Select --" }, - "spesa": { - "title": "🛍️ Online Shopping", - "hint": "Configure the online shopping provider.", - "provider_label": "🏪 Provider", - "email_label": "📧 Email", - "password_label": "🔒 Password", - "login_btn": "🔐 Login", - "ai_prompt_label": "🤖 AI product selection prompt", - "ai_prompt_placeholder": "Instructions for AI when choosing between multiple products...", - "ai_prompt_hint": "AI uses this prompt to choose the most appropriate product from results. Leave empty for default behavior.", - "configure_first": "Configure Online Shopping in settings first" + "units": { + "pz": "pcs", + "conf": "pkg", + "g": "g", + "ml": "ml", + "pieces": "Pieces", + "grams": "Grams", + "box": "Package", + "boxes": "Packages" }, - "camera": { - "title": "📷 Camera", - "hint": "Choose which camera to use for barcode scanning and AI identification.", - "device_label": "📸 Default camera", - "back": "📱 Rear (default)", - "front": "🤳 Front", - "devices_hint": "If you have multiple cameras, you can select a specific one from the list above after granting permissions.", - "detect_btn": "🔄 Detect cameras" + "shopping_sections": { + "frutta_verdura": "Fruits & Vegetables", + "carne_pesce": "Meat & Fish", + "latticini": "Dairy & Fresh", + "pane_dolci": "Bread & Sweets", + "pasta": "Pasta & Cereals", + "conserve": "Canned & Sauces", + "surgelati": "Frozen", + "bevande": "Beverages", + "pulizia_igiene": "Cleaning & Hygiene", + "altro": "Other" }, - "security": { - "title": "🔒 HTTPS Certificate", - "hint": "If the browser shows the error \"Your connection is not private\" (ERR_CERT_AUTHORITY_INVALID), you need to install the CA certificate on the device.", - "download_btn": "📥 Download CA Certificate" + "dashboard": { + "expired_title": "🚫 Expired", + "expiring_title": "⏰ Expiring Soon", + "stats_period": "📊 Last 30 days", + "opened_title": "📦 Opened Products", + "review_title": "🔍 To Review", + "review_hint": "Quantities that seem unusual. Confirm if correct or modify.", + "quick_recipe": "🍳 Quick recipe with expiring products", + "banner_review_title": "Anomalous quantity", + "banner_review_action_ok": "It's correct", + "banner_review_action_edit": "Edit", + "banner_review_action_weigh": "Weigh", + "banner_review_dismiss": "Dismiss", + "banner_prediction_title": "Anomalous consumption", + "banner_prediction_hint": "Based on predictions, this quantity doesn't match expected consumption.", + "banner_prediction_action_confirm": "Confirm quantity", + "banner_prediction_action_weigh": "Weigh with scale", + "banner_prediction_action_edit": "Correct" }, - "tts": { - "title": "🔊 Voice & TTS", - "hint": "Configure text-to-speech via any external REST API. Recipe steps and expired timers will be sent to the configured endpoint.", - "enabled": "✅ Enable TTS", - "url_label": "🌐 Endpoint URL", - "method_label": "📡 HTTP Method", - "auth_label": "🔐 Authentication", - "auth_bearer": "Bearer Token", - "auth_custom": "Custom Header", - "auth_none": "None", - "token_label": "🔑 Bearer Token", - "custom_header_name": "📋 Header name", - "custom_header_value": "📋 Header value", - "content_type_label": "📄 Content-Type", - "payload_key_label": "🗝️ Text field in payload", - "payload_key_hint": "Name of the JSON field that will contain the text to read (e.g.: message, text).", - "extra_fields_label": "➕ Extra fields (JSON)", - "extra_fields_placeholder": "{\"entity_id\": \"media_player.living_room\"}", - "extra_fields_hint": "Additional fields to include in the payload, in JSON format. Leave empty if not needed.", - "test_btn": "🔊 Send Test Voice" + "inventory": { + "title": "Pantry", + "filter_all": "All", + "search_placeholder": "🔍 Search product...", + "empty": "No products here.\nScan a product to add it!", + "no_items_found": "No inventory items found" }, - "language": { - "title": "🌐 Language", - "hint": "Select the interface language.", - "label": "🌐 Language", - "restart_notice": "The page will reload to apply the new language." + "scan": { + "title": "Scan Product", + "mode_shopping": "🛒 Shopping Mode", + "mode_shopping_end": "✅ End shopping", + "zoom": "Zoom", + "barcode_placeholder": "Enter barcode...", + "quick_name_divider": "or type the name", + "quick_name_placeholder": "E.g.: Apples, Zucchini, Bread...", + "manual_entry": "✏️ Manual Entry", + "ai_identify": "🤖 Identify with AI", + "hint": "Scan the barcode, type the product name, or use AI to identify it", + "debug_toggle": "🐛 Debug Log", + "barcode_acquired": "🔖 Barcode scanned: {code}", + "scan_barcode": "🔖 Scan Barcode" + }, + "action": { + "title": "What do you want to do?", + "add_btn": "📥 ADD", + "add_sub": "to pantry/fridge", + "use_btn": "📤 USE / CONSUME", + "use_sub": "from pantry/fridge" + }, + "add": { + "title": "Add to Pantry", + "location_label": "📍 Where do you put it?", + "quantity_label": "📦 Quantity", + "conf_size_label": "📦 Each package contains:", + "conf_size_placeholder": "e.g. 300", + "vacuum_label": "🫙 Vacuum sealed", + "vacuum_hint": "Expiry date will be extended automatically", + "submit": "✅ Add" + }, + "use": { + "title": "Use / Consume", + "location_label": "📍 From where?", + "quantity_label": "How much did you use?", + "partial_hint": "Or specify the quantity used:", + "use_all": "🗑️ Used ALL / Finished", + "submit": "📤 Use this quantity", + "available": "📦 Available:", + "not_in_inventory": "⚠️ Product not in inventory.", + "expiry_warning": "⚠️ Use first the one{loc} that expires on {date} — {when}!" + }, + "product": { + "title_new": "New Product", + "title_edit": "Edit Product", + "ai_fill": "📷 Take photo and identify with AI", + "ai_fill_hint": "AI will automatically fill in the product fields", + "name_label": "🏷️ Product Name *", + "name_placeholder": "E.g.: Whole milk, Penne pasta...", + "brand_label": "🏢 Brand", + "brand_placeholder": "E.g.: Barilla, Granarolo, Mutti...", + "category_label": "📂 Category", + "unit_label": "📏 Unit of measure", + "default_qty_label": "🔢 Default quantity", + "conf_size_label": "📦 Each package contains:", + "conf_size_placeholder": "e.g. 300", + "notes_label": "📝 Notes", + "notes_placeholder": "E.g.: lactose free, organic, store in fridge after opening...", + "barcode_label": "🔖 Barcode", + "barcode_placeholder": "Barcode (if available)", + "barcode_hint": "⚠️ Add the barcode so next time you just need to scan it!", + "submit": "💾 Save Product", + "name_required": "Enter the product name", + "conf_size_required": "Specify the package content", + "expiry_estimated": "Estimated expiry:", + "scan_expiry": "Scan expiry date", + "expiry_hint": "📝 You can edit the date or scan it with the camera", + "add_batch": "📦 + Batch with different expiry", + "package_info": "📦 Package: {info}", + "edit_catalog": "⚙️ Edit product info (name, brand, category…)", + "not_recognized": "⚠️ Product not recognized", + "edit_info": "✏️ Edit information", + "modify_details": "EDIT\nexpiry, location…" + }, + "products": { + "title": "📦 All Products", + "search_placeholder": "🔍 Search product...", + "empty": "No products in database.\nScan a product to get started!", + "no_category": "No products in this category" + }, + "recipes": { + "title": "🍳 Recipes", + "generate": "✨ Generate new recipe" + }, + "shopping": { + "title": "🛒 Shopping List", + "bring_loading": "Connecting to Bring!...", + "tab_to_buy": "🛍️ To buy", + "tab_forecast": "🧠 Forecast", + "total_label": "💰 Estimated total", + "section_to_buy": "🛍️ To buy", + "suggestions_title": "💡 AI Suggestions", + "suggestions_add": "✅ Add selected to Bring!", + "search_prices": "🔍 Search all prices", + "suggest_btn": "🤖 Suggest what to buy", + "smart_title": "🧠 Smart Predictions", + "smart_empty": "No predictions available.
Add products to your pantry to receive smart predictions.", + "smart_filter_all": "All", + "smart_filter_critical": "🔴 Urgent", + "smart_filter_high": "🟠 Soon", + "smart_filter_medium": "🟡 Plan", + "smart_filter_low": "🟢 Forecast", + "smart_add": "🛒 Add selected to Bring!", + "empty": "Shopping list empty!\nUse the button below to generate suggestions.", + "already_in_list": "🛒 \"{name}\" is already in the shopping list", + "already_in_list_short": "ℹ️ Already in the shopping list", + "add_prompt": "Do you want to add it to the shopping list?", + "smart_already": "📊 Smart shopping already predicts {name}", + "all_searched": "All products have already been searched. Use 🔄 to search individual ones.", + "search_complete": "Search complete: {count} products", + "removed_sufficient": "🧹 {removed} product(s) with sufficient stock removed from the list" + }, + "ai": { + "title": "🤖 AI Identification", + "capture": "📸 Take Photo", + "retake": "🔄 Retake", + "hint": "Take a photo of the product and AI will try to identify it", + "identifying": "🤖 Identifying product...", + "no_api_key": "⚠️ Gemini API key not configured.\nAdd GEMINI_API_KEY to the .env file on the server.", + "fields_filled": "✅ Fields filled by AI" + }, + "log": { + "title": "📒 Operations Log" + }, + "chat": { + "title": "Gemini Chef", + "welcome": "Hi! I'm your kitchen assistant", + "welcome_desc": "Ask me to make you a juice, a snack, a quick dish... I know your pantry, your appliances and your preferences!", + "suggestion_snack": "🍿 Quick snack", + "suggestion_juice": "🥤 Juice/Smoothie", + "suggestion_light": "🥗 Something light", + "suggestion_expiry": "⏰ Use expiring items", + "clear": "New conversation", + "placeholder": "Ask something..." + }, + "cooking": { + "close": "Close", + "tts_btn": "Read aloud", + "restart": "↺ Restart", + "replay": "🔊 Replay", + "timer": "⏱️ {time} · Timer", + "prev": "◀ Previous", + "next": "Next ▶" + }, + "settings": { + "title": "⚙️ Settings", + "tab_api": "API Keys", + "tab_bring": "Bring!", + "tab_recipe": "Recipes", + "tab_mealplan": "Weekly Plan", + "tab_appliances": "Appliances", + "tab_spesa": "Online Shopping", + "tab_camera": "Camera", + "tab_security": "Security", + "tab_tts": "Voice (TTS)", + "tab_language": "Language", + "tab_scale": "Smart Scale", + "gemini": { + "title": "🤖 Google Gemini AI", + "hint": "API key for product identification, expiry dates and recipes.", + "key_label": "Gemini API Key" + }, + "bring": { + "title": "🛒 Bring! Shopping List", + "hint": "Credentials for the Bring! shopping list integration.", + "email_label": "📧 Bring! Email", + "password_label": "🔒 Bring! Password" + }, + "recipe": { + "title": "🍳 Recipe Preferences", + "hint": "Configure the default options for recipe generation.", + "persons_label": "👥 Default servings", + "options_label": "🎯 Default recipe options", + "fast": "⚡ Quick Meal", + "light": "🥗 Light Meal", + "expiry": "⏰ Expiry Priority", + "healthy": "💚 Extra Healthy", + "opened": "📦 Open Items Priority", + "zerowaste": "♻️ Zero Waste", + "dietary_label": "🚫 Intolerances / Restrictions", + "dietary_placeholder": "E.g.: gluten free, lactose free, vegetarian..." + }, + "mealplan": { + "title": "📅 Weekly Meal Plan", + "hint": "Set the meal type for each day. It will be used as a guide in recipe generation.", + "enabled": "✅ Enable weekly meal plan", + "legend": "🌤️ = Lunch · 🌙 = Dinner · Tap a badge to change it.", + "types_title": "📋 Available types" + }, + "appliances": { + "title": "🔌 Available Appliances", + "hint": "Indicate the appliances you have. They will be considered in recipe generation.", + "new_placeholder": "E.g.: Bread machine, Thermomix, Air fryer...", + "quick_title": "Quick add:", + "oven": "🔥 Oven", + "microwave": "📡 Microwave", + "air_fryer": "🍟 Air fryer", + "bread_maker": "🍞 Bread maker", + "bimby": "🤖 Thermomix/Cookeo", + "mixer": "🌀 Stand mixer", + "steamer": "♨️ Steamer", + "pressure_cooker": "🫕 Pressure cooker", + "toaster": "🍞 Toaster", + "blender": "🍹 Blender", + "empty": "No appliances added" + }, + "spesa": { + "title": "🛍️ Online Shopping", + "hint": "Configure the online shopping provider.", + "provider_label": "🏪 Provider", + "email_label": "📧 Email", + "password_label": "🔒 Password", + "login_btn": "🔐 Login", + "ai_prompt_label": "🤖 AI product selection prompt", + "ai_prompt_placeholder": "Instructions for AI when choosing between multiple products...", + "ai_prompt_hint": "AI uses this prompt to choose the most appropriate product from results. Leave empty for default behavior.", + "configure_first": "Configure Online Shopping in settings first" + }, + "camera": { + "title": "📷 Camera", + "hint": "Choose which camera to use for barcode scanning and AI identification.", + "device_label": "📸 Default camera", + "back": "📱 Rear (default)", + "front": "🤳 Front", + "devices_hint": "If you have multiple cameras, you can select a specific one from the list above after granting permissions.", + "detect_btn": "🔄 Detect cameras" + }, + "security": { + "title": "🔒 HTTPS Certificate", + "hint": "If the browser shows the error \"Your connection is not private\" (ERR_CERT_AUTHORITY_INVALID), you need to install the CA certificate on the device.", + "download_btn": "📥 Download CA Certificate" + }, + "tts": { + "title": "🔊 Voice & TTS", + "hint": "Configure text-to-speech via any external REST API. Recipe steps and expired timers will be sent to the configured endpoint.", + "enabled": "✅ Enable TTS", + "url_label": "🌐 Endpoint URL", + "method_label": "📡 HTTP Method", + "auth_label": "🔐 Authentication", + "auth_bearer": "Bearer Token", + "auth_custom": "Custom Header", + "auth_none": "None", + "token_label": "🔑 Bearer Token", + "custom_header_name": "📋 Header name", + "custom_header_value": "📋 Header value", + "content_type_label": "📄 Content-Type", + "payload_key_label": "🗝️ Text field in payload", + "payload_key_hint": "Name of the JSON field that will contain the text to read (e.g.: message, text).", + "extra_fields_label": "➕ Extra fields (JSON)", + "extra_fields_placeholder": "{\"entity_id\": \"media_player.living_room\"}", + "extra_fields_hint": "Additional fields to include in the payload, in JSON format. Leave empty if not needed.", + "test_btn": "🔊 Send Test Voice" + }, + "language": { + "title": "🌐 Language", + "hint": "Select the interface language.", + "label": "🌐 Language", + "restart_notice": "The page will reload to apply the new language." + }, + "scale": { + "title": "⚖️ Smart Scale", + "hint": "Connect a Bluetooth scale via the Android gateway to automatically read weight.", + "tab": "Smart Scale", + "enabled": "✅ Enable smart scale", + "url_label": "🌐 WebSocket Gateway URL", + "url_placeholder": "ws://192.168.1.x:8765", + "url_hint": "URL shown by the Android app (same Wi-Fi network). E.g.:", + "test_btn": "🔗 Test connection", + "download_btn": "📥 Download Android Gateway (APK)", + "download_hint": "Android app that bridges your BLE scale and EverShelf.", + "download_sub": "Source: evershelf-scale-gateway/ in the project root" + }, + "saved": "✅ Configuration saved!", + "saved_local": "✅ Configuration saved locally", + "saved_local_error": "⚠️ Saved locally, server error: {error}" + }, + "expiry": { + "today": "TODAY", + "tomorrow": "Tomorrow", + "days": "{days} days", + "expired_days": "{days}d ago", + "expired_yesterday": "Yesterday", + "expired_today": "Today" + }, + "status": { + "ok": "OK", + "check": "Check", + "discard": "Discard" + }, + "toast": { + "product_saved": "Product saved!", + "product_created": "Product created!", + "product_updated": "✅ Product updated!", + "product_removed": "Product removed", + "updated": "Updated!", + "quantity_confirmed": "✓ Quantity confirmed", + "added_to_inventory": "✅ {name} added!", + "removed_from_list": "✅ {name} removed from the list!", + "removed_from_list_short": "Removed from the list", + "added_to_shopping": "🛒 Added to the shopping list!", + "removed_from_shopping": "🛒 Removed from the shopping list", + "finished_to_bring": "🛒 Product finished → added to Bring!", + "thrown_away": "🗑️ {name} thrown away!", + "thrown_away_partial": "🗑️ Thrown away {qty} {unit} of {name}", + "appliance_added": "Appliance added", + "item_added": "{name} added" + }, + "error": { + "generic": "Error", + "loading": "Error loading product", + "not_found": "Product not found", + "not_found_manual": "Product not found. Enter it manually.", + "search": "Search error. Try again.", + "search_short": "Search error", + "save": "Error saving", + "connection": "Connection error", + "camera": "Cannot access camera", + "bring_add": "Error adding to Bring!", + "bring_connection": "Bring! connection error", + "identification": "Identification error", + "barcode_empty": "Enter a barcode", + "barcode_format": "Barcode must contain only numbers (4-14 digits)", + "min_chars": "Type at least 2 characters", + "not_in_inventory": "Product not in inventory", + "appliance_exists": "Appliance already exists", + "already_exists": "Already exists" + }, + "confirm": { + "remove_item": "Do you really want to remove this product from inventory?" + }, + "edit": { + "title": "Edit {name}" + }, + "screensaver": { + "recipe_btn": "Recipes", + "scan_btn": "Scan product" + }, + "days": { + "mon": "Monday", + "tue": "Tuesday", + "wed": "Wednesday", + "thu": "Thursday", + "fri": "Friday", + "sat": "Saturday", + "sun": "Sunday" + }, + "meal_types": { + "lunch": "Lunch", + "dinner": "Dinner" }, "scale": { - "title": "⚖️ Smart Scale", - "hint": "Connect a Bluetooth scale via the Android gateway to automatically read weight.", - "tab": "Smart Scale", - "enabled": "✅ Enable smart scale", - "url_label": "🌐 WebSocket Gateway URL", - "url_placeholder": "ws://192.168.1.x:8765", - "url_hint": "URL shown by the Android app (same Wi-Fi network). E.g.:", - "test_btn": "🔗 Test connection", - "download_btn": "📥 Download Android Gateway (APK)", - "download_hint": "Android app that bridges your BLE scale and EverShelf.", - "download_sub": "Source: evershelf-scale-gateway/ in the project root" + "status_connected": "Scale connected", + "status_searching": "Gateway connected, waiting for scale…", + "status_disconnected": "Scale gateway unreachable", + "status_error": "Gateway connection error", + "not_connected": "Scale gateway not connected", + "read_btn": "⚖️ Read from scale", + "reading_title": "Scale reading", + "place_on_scale": "Place the product on the scale…", + "waiting_stable": "Weight will be captured automatically once the reading is stable.", + "no_url": "Enter the gateway URL", + "testing": "⏳ Testing connection…", + "connected_ok": "Gateway connection successful!", + "timeout": "Timeout: no response from gateway", + "error_connect": "Cannot connect to gateway", + "tab": "Smart Scale", + "low_weight": "Weight < 10 g · enter manually\n(auto-reading requires at least 10 g)" }, - "saved": "✅ Configuration saved!", - "saved_local": "✅ Configuration saved locally", - "saved_local_error": "⚠️ Saved locally, server error: {error}" - }, - "expiry": { - "today": "TODAY", - "tomorrow": "Tomorrow", - "days": "{days} days", - "expired_days": "{days}d ago", - "expired_yesterday": "Yesterday", - "expired_today": "Today" - }, - "status": { - "ok": "OK", - "check": "Check", - "discard": "Discard" - }, - "toast": { - "product_saved": "Product saved!", - "product_created": "Product created!", - "product_updated": "✅ Product updated!", - "product_removed": "Product removed", - "updated": "Updated!", - "quantity_confirmed": "✓ Quantity confirmed", - "added_to_inventory": "✅ {name} added!", - "removed_from_list": "✅ {name} removed from the list!", - "removed_from_list_short": "Removed from the list", - "added_to_shopping": "🛒 Added to the shopping list!", - "removed_from_shopping": "🛒 Removed from the shopping list", - "finished_to_bring": "🛒 Product finished → added to Bring!", - "thrown_away": "🗑️ {name} thrown away!", - "thrown_away_partial": "🗑️ Thrown away {qty} {unit} of {name}", - "appliance_added": "Appliance added", - "item_added": "{name} added" - }, - "error": { - "generic": "Error", - "loading": "Error loading product", - "not_found": "Product not found", - "not_found_manual": "Product not found. Enter it manually.", - "search": "Search error. Try again.", - "search_short": "Search error", - "save": "Error saving", - "connection": "Connection error", - "camera": "Cannot access camera", - "bring_add": "Error adding to Bring!", - "bring_connection": "Bring! connection error", - "identification": "Identification error", - "barcode_empty": "Enter a barcode", - "barcode_format": "Barcode must contain only numbers (4-14 digits)", - "min_chars": "Type at least 2 characters", - "not_in_inventory": "Product not in inventory", - "appliance_exists": "Appliance already exists", - "already_exists": "Already exists" - }, - "confirm": { - "remove_item": "Do you really want to remove this product from inventory?" - }, - "edit": { - "title": "Edit {name}" - }, - "screensaver": { - "recipe_btn": "Recipes", - "scan_btn": "Scan product" - }, - "days": { - "mon": "Monday", - "tue": "Tuesday", - "wed": "Wednesday", - "thu": "Thursday", - "fri": "Friday", - "sat": "Saturday", - "sun": "Sunday" - }, - "meal_types": { - "lunch": "Lunch", - "dinner": "Dinner" - }, - "scale": { - "status_connected": "Scale connected", - "status_searching": "Gateway connected, waiting for scale…", - "status_disconnected": "Scale gateway unreachable", - "status_error": "Gateway connection error", - "not_connected": "Scale gateway not connected", - "read_btn": "⚖️ Read from scale", - "reading_title": "Scale reading", - "place_on_scale": "Place the product on the scale…", - "waiting_stable": "Weight will be captured automatically once the reading is stable.", - "no_url": "Enter the gateway URL", - "testing": "⏳ Testing connection…", - "connected_ok": "Gateway connection successful!", - "timeout": "Timeout: no response from gateway", - "error_connect": "Cannot connect to gateway", - "tab": "Smart Scale" - } -} + "prediction": { + "expected_qty": "Expected: {expected} {unit}", + "actual_qty": "Current: {actual} {unit}", + "check_suggestion": "Check or weigh the remaining quantity" + } +} \ No newline at end of file diff --git a/translations/it.json b/translations/it.json index c2d502c..4e8719c 100644 --- a/translations/it.json +++ b/translations/it.json @@ -1,462 +1,478 @@ { - "app": { - "name": "EverShelf", - "loading": "Caricamento..." - }, - "nav": { - "title": "🏠 EverShelf", - "home": "Home", - "inventory": "Dispensa", - "recipes": "Ricette", - "shopping": "Spesa", - "log": "Log" - }, - "btn": { - "back": "← Indietro", - "save": "💾 Salva", - "cancel": "✕ Annulla", - "close": "Chiudi", - "add": "✅ Aggiungi", - "delete": "Elimina", - "edit": "✏️ Modifica", - "search": "🔍 Cerca", - "go": "✅ Vai", - "toggle_password": "👁️ Mostra/Nascondi", - "load_more": "Carica altri...", - "save_config": "💾 Salva Configurazione", - "save_product": "💾 Salva Prodotto", - "restart": "↺ Ricomincia", - "reset_default": "↺ Ripristina default" - }, - "locations": { - "dispensa": "Dispensa", - "frigo": "Frigo", - "freezer": "Freezer", - "altro": "Altro" - }, - "categories": { - "latticini": "Latticini", - "carne": "Carne", - "pesce": "Pesce", - "frutta": "Frutta", - "verdura": "Verdura", - "pasta": "Pasta & Riso", - "pane": "Pane & Forno", - "surgelati": "Surgelati", - "bevande": "Bevande", - "condimenti": "Condimenti", - "snack": "Snack & Dolci", - "conserve": "Conserve", - "cereali": "Cereali & Legumi", - "igiene": "Igiene", - "pulizia": "Pulizia Casa", - "altro": "Altro", - "select": "-- Seleziona --" - }, - "units": { - "pz": "pz", - "conf": "conf", - "g": "g", - "ml": "ml", - "pieces": "Pezzi", - "grams": "Grammi", - "box": "Confezione", - "boxes": "Confezioni" - }, - "shopping_sections": { - "frutta_verdura": "Frutta & Verdura", - "carne_pesce": "Carne & Pesce", - "latticini": "Latticini & Fresco", - "pane_dolci": "Pane & Dolci", - "pasta": "Pasta & Cereali", - "conserve": "Conserve & Salse", - "surgelati": "Surgelati", - "bevande": "Bevande", - "pulizia_igiene": "Pulizia & Igiene", - "altro": "Altro" - }, - "dashboard": { - "expired_title": "🚫 Scaduti", - "expiring_title": "⏰ Prossime Scadenze", - "stats_period": "📊 Ultimi 30 giorni", - "opened_title": "📦 Prodotti Aperti", - "review_title": "🔍 Da revisionare", - "review_hint": "Quantità che sembrano anomale. Conferma se corrette o modifica.", - "quick_recipe": "🍳 Ricetta veloce con prodotti in scadenza" - }, - "inventory": { - "title": "Dispensa", - "filter_all": "Tutti", - "search_placeholder": "🔍 Cerca prodotto...", - "empty": "Nessun prodotto qui.\nScansiona un prodotto per aggiungerlo!", - "no_items_found": "Nessuna voce di inventario trovata" - }, - "scan": { - "title": "Scansiona Prodotto", - "mode_shopping": "🛒 Modalità Spesa", - "mode_shopping_end": "✅ Fine spesa", - "zoom": "Zoom", - "barcode_placeholder": "Inserisci codice a barre...", - "quick_name_divider": "oppure scrivi il nome", - "quick_name_placeholder": "Es: Mele, Zucchine, Pane...", - "manual_entry": "✏️ Inserimento Manuale", - "ai_identify": "🤖 Identifica con AI", - "hint": "Scansiona il barcode, scrivi il nome del prodotto, oppure usa l'AI per identificarlo", - "debug_toggle": "🐛 Debug Log", - "barcode_acquired": "🔖 Barcode acquisito: {code}", - "scan_barcode": "🔖 Scansiona Barcode" - }, - "action": { - "title": "Cosa vuoi fare?", - "add_btn": "📥 AGGIUNGI", - "add_sub": "in dispensa/frigo", - "use_btn": "📤 USA / CONSUMA", - "use_sub": "dalla dispensa/frigo" - }, - "add": { - "title": "Aggiungi alla Dispensa", - "location_label": "📍 Dove lo metti?", - "quantity_label": "📦 Quantità", - "conf_size_label": "📦 Ogni confezione contiene:", - "conf_size_placeholder": "es. 300", - "vacuum_label": "🫙 Sotto vuoto", - "vacuum_hint": "La scadenza verrà estesa automaticamente", - "submit": "✅ Aggiungi" - }, - "use": { - "title": "Usa / Consuma", - "location_label": "📍 Da dove?", - "quantity_label": "Quanto hai usato?", - "partial_hint": "Oppure specifica la quantità usata:", - "use_all": "🗑️ Usato TUTTO / Finito", - "submit": "📤 Usa questa quantità", - "available": "📦 Disponibile:", - "not_in_inventory": "⚠️ Prodotto non presente nell'inventario.", - "expiry_warning": "⚠️ Usa prima quella{loc} che scade il {date} — {when}!" - }, - "product": { - "title_new": "Nuovo Prodotto", - "title_edit": "Modifica Prodotto", - "ai_fill": "📷 Scatta foto e identifica con AI", - "ai_fill_hint": "L'AI compilerà automaticamente i campi del prodotto", - "name_label": "🏷️ Nome Prodotto *", - "name_placeholder": "Es: Latte intero, Pasta penne rigate...", - "brand_label": "🏢 Marca", - "brand_placeholder": "Es: Barilla, Granarolo, Mutti...", - "category_label": "📂 Categoria", - "unit_label": "📏 Unità di misura", - "default_qty_label": "🔢 Quantità default", - "conf_size_label": "📦 Ogni confezione contiene:", - "conf_size_placeholder": "es. 300", - "notes_label": "📝 Note", - "notes_placeholder": "Es: senza lattosio, bio, conservare in frigo dopo apertura...", - "barcode_label": "🔖 Barcode", - "barcode_placeholder": "Codice a barre (se disponibile)", - "barcode_hint": "⚠️ Aggiungi il barcode così al prossimo acquisto basta scansionarlo!", - "submit": "💾 Salva Prodotto", - "name_required": "Inserisci il nome del prodotto", - "conf_size_required": "Specifica il contenuto di ogni confezione", - "expiry_estimated": "Scadenza stimata:", - "scan_expiry": "Scansiona data scadenza", - "expiry_hint": "📝 Puoi modificare la data o scansionarla con la fotocamera", - "add_batch": "📦 + Lotto con scadenza diversa", - "package_info": "📦 Confezione: {info}", - "edit_catalog": "⚙️ Modifica scheda prodotto (nome, marca, categoria…)", - "not_recognized": "⚠️ Prodotto non riconosciuto", - "edit_info": "✏️ Modifica informazioni", - "modify_details": "MODIFICA\nscadenza, luogo…" - }, - "products": { - "title": "📦 Tutti i Prodotti", - "search_placeholder": "🔍 Cerca prodotto...", - "empty": "Nessun prodotto nel database.\nScansiona un prodotto per iniziare!", - "no_category": "Nessun prodotto in questa categoria" - }, - "recipes": { - "title": "🍳 Ricette", - "generate": "✨ Genera nuova ricetta" - }, - "shopping": { - "title": "🛒 Lista della Spesa", - "bring_loading": "Connessione a Bring!...", - "tab_to_buy": "🛍️ Da comprare", - "tab_forecast": "🧠 In previsione", - "total_label": "💰 Totale stimato", - "section_to_buy": "🛍️ Da comprare", - "suggestions_title": "💡 Suggerimenti AI", - "suggestions_add": "✅ Aggiungi selezionati a Bring!", - "search_prices": "🔍 Cerca tutti i prezzi", - "suggest_btn": "🤖 Suggerisci cosa comprare", - "smart_title": "🧠 Previsioni intelligenti", - "smart_empty": "Nessuna previsione disponibile.
Aggiungi prodotti alla dispensa per ricevere previsioni intelligenti.", - "smart_filter_all": "Tutti", - "smart_filter_critical": "🔴 Urgenti", - "smart_filter_high": "🟠 Presto", - "smart_filter_medium": "🟡 Pianifica", - "smart_filter_low": "🟢 Previsione", - "smart_add": "🛒 Aggiungi selezionati a Bring!", - "empty": "Lista della spesa vuota!\nUsa il pulsante sotto per generare suggerimenti.", - "already_in_list": "🛒 \"{name}\" già nella lista della spesa", - "already_in_list_short": "ℹ️ Già nella lista della spesa", - "add_prompt": "Vuoi aggiungerlo alla lista della spesa?", - "smart_already": "📊 La spesa intelligente prevede già {name}", - "all_searched": "Tutti i prodotti sono già stati cercati. Usa 🔄 per ricercare singoli.", - "search_complete": "Ricerca completata: {count} prodotti", - "removed_sufficient": "🧹 {removed} prodotto/i con scorte sufficienti rimosso/i dalla lista" - }, - "ai": { - "title": "🤖 Identificazione AI", - "capture": "📸 Scatta Foto", - "retake": "🔄 Riscatta", - "hint": "Scatta una foto del prodotto e l'AI cercherà di identificarlo", - "identifying": "🤖 Identifico il prodotto...", - "no_api_key": "⚠️ Chiave API Gemini non configurata.\nAggiungi GEMINI_API_KEY nel file .env sul server.", - "fields_filled": "✅ Campi compilati dall'AI" - }, - "log": { - "title": "📒 Log Operazioni" - }, - "chat": { - "title": "Gemini Chef", - "welcome": "Ciao! Sono il tuo assistente cucina", - "welcome_desc": "Chiedimi di prepararti un succo, uno spuntino, un piatto veloce... Conosco la tua dispensa, i tuoi elettrodomestici e le tue preferenze!", - "suggestion_snack": "🍿 Spuntino veloce", - "suggestion_juice": "🥤 Succo/Frullato", - "suggestion_light": "🥗 Qualcosa di leggero", - "suggestion_expiry": "⏰ Usa le scadenze", - "clear": "Nuova conversazione", - "placeholder": "Chiedi qualcosa..." - }, - "cooking": { - "close": "Chiudi", - "tts_btn": "Leggi ad alta voce", - "restart": "↺ Ricomincia", - "replay": "🔊 Rileggi", - "timer": "⏱️ {time} · Timer", - "prev": "◀ Precedente", - "next": "Successivo ▶" - }, - "settings": { - "title": "⚙️ Configurazione", - "tab_api": "API Keys", - "tab_bring": "Bring!", - "tab_recipe": "Ricette", - "tab_mealplan": "Piano Settimanale", - "tab_appliances": "Elettrodomestici", - "tab_spesa": "Spesa Online", - "tab_camera": "Fotocamera", - "tab_security": "Sicurezza", - "tab_tts": "Voce (TTS)", - "tab_language": "Lingua", - "tab_scale": "Bilancia Smart", - "gemini": { - "title": "🤖 Google Gemini AI", - "hint": "Chiave API per identificazione prodotti, scadenze e ricette.", - "key_label": "API Key Gemini" + "app": { + "name": "EverShelf", + "loading": "Caricamento..." }, - "bring": { - "title": "🛒 Bring! Shopping List", - "hint": "Credenziali per l'integrazione con la lista della spesa Bring!", - "email_label": "📧 Email Bring!", - "password_label": "🔒 Password Bring!" + "nav": { + "title": "🏠 EverShelf", + "home": "Home", + "inventory": "Dispensa", + "recipes": "Ricette", + "shopping": "Spesa", + "log": "Log" }, - "recipe": { - "title": "🍳 Preferenze Ricette", - "hint": "Configura le opzioni predefinite per la generazione delle ricette.", - "persons_label": "👥 Persone predefinite", - "options_label": "🎯 Opzioni ricetta predefinite", - "fast": "⚡ Pasto Veloce", - "light": "🥗 Poca Fame", - "expiry": "⏰ Priorità Scadenze", - "healthy": "💚 Extra Salutare", - "opened": "📦 Priorità Cose Aperte", - "zerowaste": "♻️ Zero Sprechi", - "dietary_label": "🚫 Intolleranze / Restrizioni", - "dietary_placeholder": "Es: senza glutine, senza lattosio, vegetariano..." + "btn": { + "back": "← Indietro", + "save": "💾 Salva", + "cancel": "✕ Annulla", + "close": "Chiudi", + "add": "✅ Aggiungi", + "delete": "Elimina", + "edit": "✏️ Modifica", + "search": "🔍 Cerca", + "go": "✅ Vai", + "toggle_password": "👁️ Mostra/Nascondi", + "load_more": "Carica altri...", + "save_config": "💾 Salva Configurazione", + "save_product": "💾 Salva Prodotto", + "restart": "↺ Ricomincia", + "reset_default": "↺ Ripristina default" }, - "mealplan": { - "title": "📅 Piano Pasti Settimanale", - "hint": "Imposta la tipologia di pasto per ogni giorno. Sarà usata come guida nella generazione delle ricette.", - "enabled": "✅ Attiva piano pasti settimanale", - "legend": "🌤️ = Pranzo · 🌙 = Cena · Tocca un badge per cambiarlo.", - "types_title": "📋 Tipologie disponibili" + "locations": { + "dispensa": "Dispensa", + "frigo": "Frigo", + "freezer": "Freezer", + "altro": "Altro" }, - "appliances": { - "title": "🔌 Elettrodomestici Disponibili", - "hint": "Indica gli elettrodomestici che hai a disposizione. Saranno considerati nella generazione delle ricette.", - "new_placeholder": "Es: Macchina del pane, Bimby, Friggitrice ad aria...", - "quick_title": "Aggiungi velocemente:", - "oven": "🔥 Forno", - "microwave": "📡 Microonde", - "air_fryer": "🍟 Friggitrice ad aria", - "bread_maker": "🍞 Macchina pane", - "bimby": "🤖 Bimby/Cookeo", - "mixer": "🌀 Planetaria", - "steamer": "♨️ Vaporiera", - "pressure_cooker": "🫕 Pentola pressione", - "toaster": "🍞 Tostapane", - "blender": "🍹 Frullatore", - "empty": "Nessun elettrodomestico aggiunto" + "categories": { + "latticini": "Latticini", + "carne": "Carne", + "pesce": "Pesce", + "frutta": "Frutta", + "verdura": "Verdura", + "pasta": "Pasta & Riso", + "pane": "Pane & Forno", + "surgelati": "Surgelati", + "bevande": "Bevande", + "condimenti": "Condimenti", + "snack": "Snack & Dolci", + "conserve": "Conserve", + "cereali": "Cereali & Legumi", + "igiene": "Igiene", + "pulizia": "Pulizia Casa", + "altro": "Altro", + "select": "-- Seleziona --" }, - "spesa": { - "title": "🛍️ Spesa Online", - "hint": "Configura il provider per la spesa online.", - "provider_label": "🏪 Provider", - "email_label": "📧 Email", - "password_label": "🔒 Password", - "login_btn": "🔐 Accedi", - "ai_prompt_label": "🤖 Prompt AI selezione prodotto", - "ai_prompt_placeholder": "Istruzioni per l'AI quando deve scegliere tra più prodotti...", - "ai_prompt_hint": "L'AI usa questo prompt per scegliere il prodotto più appropriato tra i risultati. Lascia vuoto per il comportamento predefinito.", - "configure_first": "Configura prima la Spesa Online nelle impostazioni" + "units": { + "pz": "pz", + "conf": "conf", + "g": "g", + "ml": "ml", + "pieces": "Pezzi", + "grams": "Grammi", + "box": "Confezione", + "boxes": "Confezioni" }, - "camera": { - "title": "📷 Fotocamera", - "hint": "Scegli quale fotocamera utilizzare per la scansione barcode e l'identificazione AI.", - "device_label": "📸 Fotocamera predefinita", - "back": "📱 Posteriore (default)", - "front": "🤳 Anteriore", - "devices_hint": "Se hai più fotocamere, puoi selezionarne una specifica dall'elenco sopra dopo aver concesso i permessi.", - "detect_btn": "🔄 Rileva fotocamere" + "shopping_sections": { + "frutta_verdura": "Frutta & Verdura", + "carne_pesce": "Carne & Pesce", + "latticini": "Latticini & Fresco", + "pane_dolci": "Pane & Dolci", + "pasta": "Pasta & Cereali", + "conserve": "Conserve & Salse", + "surgelati": "Surgelati", + "bevande": "Bevande", + "pulizia_igiene": "Pulizia & Igiene", + "altro": "Altro" }, - "security": { - "title": "🔒 Certificato HTTPS", - "hint": "Se il browser mostra l'errore \"La connessione non è privata\" (ERR_CERT_AUTHORITY_INVALID), devi installare il certificato CA nel dispositivo.", - "download_btn": "📥 Scarica Certificato CA" + "dashboard": { + "expired_title": "🚫 Scaduti", + "expiring_title": "⏰ Prossime Scadenze", + "stats_period": "📊 Ultimi 30 giorni", + "opened_title": "📦 Prodotti Aperti", + "review_title": "🔍 Da revisionare", + "review_hint": "Quantità che sembrano anomale. Conferma se corrette o modifica.", + "quick_recipe": "🍳 Ricetta veloce con prodotti in scadenza", + "banner_review_title": "Quantità anomala", + "banner_review_action_ok": "È corretto", + "banner_review_action_edit": "Modifica", + "banner_review_action_weigh": "Pesa", + "banner_review_dismiss": "Ignora", + "banner_prediction_title": "Consumo anomalo", + "banner_prediction_hint": "Secondo le previsioni, questa quantità non corrisponde al consumo previsto.", + "banner_prediction_action_confirm": "Confermo quantità", + "banner_prediction_action_weigh": "Pesa con bilancia", + "banner_prediction_action_edit": "Correggi" }, - "tts": { - "title": "🔊 Voce & TTS", - "hint": "Configura la sintesi vocale tramite qualsiasi API REST esterna. I passi della ricetta e i timer scaduti verranno inviati all'endpoint configurato.", - "enabled": "✅ Attiva TTS", - "url_label": "🌐 URL Endpoint", - "method_label": "📡 Metodo HTTP", - "auth_label": "🔐 Autenticazione", - "auth_bearer": "Bearer Token", - "auth_custom": "Header personalizzato", - "auth_none": "Nessuna", - "token_label": "🔑 Bearer Token", - "custom_header_name": "📋 Nome header", - "custom_header_value": "📋 Valore header", - "content_type_label": "📄 Content-Type", - "payload_key_label": "🗝️ Campo testo nel payload", - "payload_key_hint": "Nome del campo JSON che conterrà il testo da leggere (es: message, text).", - "extra_fields_label": "➕ Campi extra (JSON)", - "extra_fields_placeholder": "{\"entity_id\": \"media_player.living_room\"}", - "extra_fields_hint": "Campi aggiuntivi da includere nel payload, in formato JSON. Lascia vuoto se non necessario.", - "test_btn": "🔊 Invia Test Vocale" + "inventory": { + "title": "Dispensa", + "filter_all": "Tutti", + "search_placeholder": "🔍 Cerca prodotto...", + "empty": "Nessun prodotto qui.\nScansiona un prodotto per aggiungerlo!", + "no_items_found": "Nessuna voce di inventario trovata" }, - "language": { - "title": "🌐 Lingua / Language", - "hint": "Seleziona la lingua dell'interfaccia. Select the interface language.", - "label": "🌐 Lingua", - "restart_notice": "La pagina verrà ricaricata per applicare la nuova lingua." + "scan": { + "title": "Scansiona Prodotto", + "mode_shopping": "🛒 Modalità Spesa", + "mode_shopping_end": "✅ Fine spesa", + "zoom": "Zoom", + "barcode_placeholder": "Inserisci codice a barre...", + "quick_name_divider": "oppure scrivi il nome", + "quick_name_placeholder": "Es: Mele, Zucchine, Pane...", + "manual_entry": "✏️ Inserimento Manuale", + "ai_identify": "🤖 Identifica con AI", + "hint": "Scansiona il barcode, scrivi il nome del prodotto, oppure usa l'AI per identificarlo", + "debug_toggle": "🐛 Debug Log", + "barcode_acquired": "🔖 Barcode acquisito: {code}", + "scan_barcode": "🔖 Scansiona Barcode" + }, + "action": { + "title": "Cosa vuoi fare?", + "add_btn": "📥 AGGIUNGI", + "add_sub": "in dispensa/frigo", + "use_btn": "📤 USA / CONSUMA", + "use_sub": "dalla dispensa/frigo" + }, + "add": { + "title": "Aggiungi alla Dispensa", + "location_label": "📍 Dove lo metti?", + "quantity_label": "📦 Quantità", + "conf_size_label": "📦 Ogni confezione contiene:", + "conf_size_placeholder": "es. 300", + "vacuum_label": "🫙 Sotto vuoto", + "vacuum_hint": "La scadenza verrà estesa automaticamente", + "submit": "✅ Aggiungi" + }, + "use": { + "title": "Usa / Consuma", + "location_label": "📍 Da dove?", + "quantity_label": "Quanto hai usato?", + "partial_hint": "Oppure specifica la quantità usata:", + "use_all": "🗑️ Usato TUTTO / Finito", + "submit": "📤 Usa questa quantità", + "available": "📦 Disponibile:", + "not_in_inventory": "⚠️ Prodotto non presente nell'inventario.", + "expiry_warning": "⚠️ Usa prima quella{loc} che scade il {date} — {when}!" + }, + "product": { + "title_new": "Nuovo Prodotto", + "title_edit": "Modifica Prodotto", + "ai_fill": "📷 Scatta foto e identifica con AI", + "ai_fill_hint": "L'AI compilerà automaticamente i campi del prodotto", + "name_label": "🏷️ Nome Prodotto *", + "name_placeholder": "Es: Latte intero, Pasta penne rigate...", + "brand_label": "🏢 Marca", + "brand_placeholder": "Es: Barilla, Granarolo, Mutti...", + "category_label": "📂 Categoria", + "unit_label": "📏 Unità di misura", + "default_qty_label": "🔢 Quantità default", + "conf_size_label": "📦 Ogni confezione contiene:", + "conf_size_placeholder": "es. 300", + "notes_label": "📝 Note", + "notes_placeholder": "Es: senza lattosio, bio, conservare in frigo dopo apertura...", + "barcode_label": "🔖 Barcode", + "barcode_placeholder": "Codice a barre (se disponibile)", + "barcode_hint": "⚠️ Aggiungi il barcode così al prossimo acquisto basta scansionarlo!", + "submit": "💾 Salva Prodotto", + "name_required": "Inserisci il nome del prodotto", + "conf_size_required": "Specifica il contenuto di ogni confezione", + "expiry_estimated": "Scadenza stimata:", + "scan_expiry": "Scansiona data scadenza", + "expiry_hint": "📝 Puoi modificare la data o scansionarla con la fotocamera", + "add_batch": "📦 + Lotto con scadenza diversa", + "package_info": "📦 Confezione: {info}", + "edit_catalog": "⚙️ Modifica scheda prodotto (nome, marca, categoria…)", + "not_recognized": "⚠️ Prodotto non riconosciuto", + "edit_info": "✏️ Modifica informazioni", + "modify_details": "MODIFICA\nscadenza, luogo…" + }, + "products": { + "title": "📦 Tutti i Prodotti", + "search_placeholder": "🔍 Cerca prodotto...", + "empty": "Nessun prodotto nel database.\nScansiona un prodotto per iniziare!", + "no_category": "Nessun prodotto in questa categoria" + }, + "recipes": { + "title": "🍳 Ricette", + "generate": "✨ Genera nuova ricetta" + }, + "shopping": { + "title": "🛒 Lista della Spesa", + "bring_loading": "Connessione a Bring!...", + "tab_to_buy": "🛍️ Da comprare", + "tab_forecast": "🧠 In previsione", + "total_label": "💰 Totale stimato", + "section_to_buy": "🛍️ Da comprare", + "suggestions_title": "💡 Suggerimenti AI", + "suggestions_add": "✅ Aggiungi selezionati a Bring!", + "search_prices": "🔍 Cerca tutti i prezzi", + "suggest_btn": "🤖 Suggerisci cosa comprare", + "smart_title": "🧠 Previsioni intelligenti", + "smart_empty": "Nessuna previsione disponibile.
Aggiungi prodotti alla dispensa per ricevere previsioni intelligenti.", + "smart_filter_all": "Tutti", + "smart_filter_critical": "🔴 Urgenti", + "smart_filter_high": "🟠 Presto", + "smart_filter_medium": "🟡 Pianifica", + "smart_filter_low": "🟢 Previsione", + "smart_add": "🛒 Aggiungi selezionati a Bring!", + "empty": "Lista della spesa vuota!\nUsa il pulsante sotto per generare suggerimenti.", + "already_in_list": "🛒 \"{name}\" già nella lista della spesa", + "already_in_list_short": "ℹ️ Già nella lista della spesa", + "add_prompt": "Vuoi aggiungerlo alla lista della spesa?", + "smart_already": "📊 La spesa intelligente prevede già {name}", + "all_searched": "Tutti i prodotti sono già stati cercati. Usa 🔄 per ricercare singoli.", + "search_complete": "Ricerca completata: {count} prodotti", + "removed_sufficient": "🧹 {removed} prodotto/i con scorte sufficienti rimosso/i dalla lista" + }, + "ai": { + "title": "🤖 Identificazione AI", + "capture": "📸 Scatta Foto", + "retake": "🔄 Riscatta", + "hint": "Scatta una foto del prodotto e l'AI cercherà di identificarlo", + "identifying": "🤖 Identifico il prodotto...", + "no_api_key": "⚠️ Chiave API Gemini non configurata.\nAggiungi GEMINI_API_KEY nel file .env sul server.", + "fields_filled": "✅ Campi compilati dall'AI" + }, + "log": { + "title": "📒 Log Operazioni" + }, + "chat": { + "title": "Gemini Chef", + "welcome": "Ciao! Sono il tuo assistente cucina", + "welcome_desc": "Chiedimi di prepararti un succo, uno spuntino, un piatto veloce... Conosco la tua dispensa, i tuoi elettrodomestici e le tue preferenze!", + "suggestion_snack": "🍿 Spuntino veloce", + "suggestion_juice": "🥤 Succo/Frullato", + "suggestion_light": "🥗 Qualcosa di leggero", + "suggestion_expiry": "⏰ Usa le scadenze", + "clear": "Nuova conversazione", + "placeholder": "Chiedi qualcosa..." + }, + "cooking": { + "close": "Chiudi", + "tts_btn": "Leggi ad alta voce", + "restart": "↺ Ricomincia", + "replay": "🔊 Rileggi", + "timer": "⏱️ {time} · Timer", + "prev": "◀ Precedente", + "next": "Successivo ▶" + }, + "settings": { + "title": "⚙️ Configurazione", + "tab_api": "API Keys", + "tab_bring": "Bring!", + "tab_recipe": "Ricette", + "tab_mealplan": "Piano Settimanale", + "tab_appliances": "Elettrodomestici", + "tab_spesa": "Spesa Online", + "tab_camera": "Fotocamera", + "tab_security": "Sicurezza", + "tab_tts": "Voce (TTS)", + "tab_language": "Lingua", + "tab_scale": "Bilancia Smart", + "gemini": { + "title": "🤖 Google Gemini AI", + "hint": "Chiave API per identificazione prodotti, scadenze e ricette.", + "key_label": "API Key Gemini" + }, + "bring": { + "title": "🛒 Bring! Shopping List", + "hint": "Credenziali per l'integrazione con la lista della spesa Bring!", + "email_label": "📧 Email Bring!", + "password_label": "🔒 Password Bring!" + }, + "recipe": { + "title": "🍳 Preferenze Ricette", + "hint": "Configura le opzioni predefinite per la generazione delle ricette.", + "persons_label": "👥 Persone predefinite", + "options_label": "🎯 Opzioni ricetta predefinite", + "fast": "⚡ Pasto Veloce", + "light": "🥗 Poca Fame", + "expiry": "⏰ Priorità Scadenze", + "healthy": "💚 Extra Salutare", + "opened": "📦 Priorità Cose Aperte", + "zerowaste": "♻️ Zero Sprechi", + "dietary_label": "🚫 Intolleranze / Restrizioni", + "dietary_placeholder": "Es: senza glutine, senza lattosio, vegetariano..." + }, + "mealplan": { + "title": "📅 Piano Pasti Settimanale", + "hint": "Imposta la tipologia di pasto per ogni giorno. Sarà usata come guida nella generazione delle ricette.", + "enabled": "✅ Attiva piano pasti settimanale", + "legend": "🌤️ = Pranzo · 🌙 = Cena · Tocca un badge per cambiarlo.", + "types_title": "📋 Tipologie disponibili" + }, + "appliances": { + "title": "🔌 Elettrodomestici Disponibili", + "hint": "Indica gli elettrodomestici che hai a disposizione. Saranno considerati nella generazione delle ricette.", + "new_placeholder": "Es: Macchina del pane, Bimby, Friggitrice ad aria...", + "quick_title": "Aggiungi velocemente:", + "oven": "🔥 Forno", + "microwave": "📡 Microonde", + "air_fryer": "🍟 Friggitrice ad aria", + "bread_maker": "🍞 Macchina pane", + "bimby": "🤖 Bimby/Cookeo", + "mixer": "🌀 Planetaria", + "steamer": "♨️ Vaporiera", + "pressure_cooker": "🫕 Pentola pressione", + "toaster": "🍞 Tostapane", + "blender": "🍹 Frullatore", + "empty": "Nessun elettrodomestico aggiunto" + }, + "spesa": { + "title": "🛍️ Spesa Online", + "hint": "Configura il provider per la spesa online.", + "provider_label": "🏪 Provider", + "email_label": "📧 Email", + "password_label": "🔒 Password", + "login_btn": "🔐 Accedi", + "ai_prompt_label": "🤖 Prompt AI selezione prodotto", + "ai_prompt_placeholder": "Istruzioni per l'AI quando deve scegliere tra più prodotti...", + "ai_prompt_hint": "L'AI usa questo prompt per scegliere il prodotto più appropriato tra i risultati. Lascia vuoto per il comportamento predefinito.", + "configure_first": "Configura prima la Spesa Online nelle impostazioni" + }, + "camera": { + "title": "📷 Fotocamera", + "hint": "Scegli quale fotocamera utilizzare per la scansione barcode e l'identificazione AI.", + "device_label": "📸 Fotocamera predefinita", + "back": "📱 Posteriore (default)", + "front": "🤳 Anteriore", + "devices_hint": "Se hai più fotocamere, puoi selezionarne una specifica dall'elenco sopra dopo aver concesso i permessi.", + "detect_btn": "🔄 Rileva fotocamere" + }, + "security": { + "title": "🔒 Certificato HTTPS", + "hint": "Se il browser mostra l'errore \"La connessione non è privata\" (ERR_CERT_AUTHORITY_INVALID), devi installare il certificato CA nel dispositivo.", + "download_btn": "📥 Scarica Certificato CA" + }, + "tts": { + "title": "🔊 Voce & TTS", + "hint": "Configura la sintesi vocale tramite qualsiasi API REST esterna. I passi della ricetta e i timer scaduti verranno inviati all'endpoint configurato.", + "enabled": "✅ Attiva TTS", + "url_label": "🌐 URL Endpoint", + "method_label": "📡 Metodo HTTP", + "auth_label": "🔐 Autenticazione", + "auth_bearer": "Bearer Token", + "auth_custom": "Header personalizzato", + "auth_none": "Nessuna", + "token_label": "🔑 Bearer Token", + "custom_header_name": "📋 Nome header", + "custom_header_value": "📋 Valore header", + "content_type_label": "📄 Content-Type", + "payload_key_label": "🗝️ Campo testo nel payload", + "payload_key_hint": "Nome del campo JSON che conterrà il testo da leggere (es: message, text).", + "extra_fields_label": "➕ Campi extra (JSON)", + "extra_fields_placeholder": "{\"entity_id\": \"media_player.living_room\"}", + "extra_fields_hint": "Campi aggiuntivi da includere nel payload, in formato JSON. Lascia vuoto se non necessario.", + "test_btn": "🔊 Invia Test Vocale" + }, + "language": { + "title": "🌐 Lingua / Language", + "hint": "Seleziona la lingua dell'interfaccia. Select the interface language.", + "label": "🌐 Lingua", + "restart_notice": "La pagina verrà ricaricata per applicare la nuova lingua." + }, + "scale": { + "title": "⚖️ Bilancia Smart", + "hint": "Collega una bilancia Bluetooth tramite il gateway Android per leggere il peso automaticamente.", + "tab": "Bilancia Smart", + "enabled": "✅ Abilita bilancia smart", + "url_label": "🌐 URL Gateway WebSocket", + "url_placeholder": "ws://192.168.1.x:8765", + "url_hint": "URL mostrato dall'app Android (stessa rete Wi-Fi). Es:", + "test_btn": "🔗 Testa connessione", + "download_btn": "📥 Scarica Gateway Android (APK)", + "download_hint": "App Android che fa da ponte tra la bilancia BLE e questo sito.", + "download_sub": "Sorgente: evershelf-scale-gateway/ nella root del progetto" + }, + "saved": "✅ Configurazione salvata!", + "saved_local": "✅ Configurazione salvata localmente", + "saved_local_error": "⚠️ Salvato localmente, errore server: {error}" + }, + "expiry": { + "today": "OGGI", + "tomorrow": "Domani", + "days": "{days} giorni", + "expired_days": "Da {days}g", + "expired_yesterday": "Da ieri", + "expired_today": "Oggi" + }, + "status": { + "ok": "OK", + "check": "Controlla", + "discard": "Buttare" + }, + "toast": { + "product_saved": "Prodotto salvato!", + "product_created": "Prodotto creato!", + "product_updated": "✅ Prodotto aggiornato!", + "product_removed": "Prodotto rimosso", + "updated": "Aggiornato!", + "quantity_confirmed": "✓ Quantità confermata", + "added_to_inventory": "✅ {name} aggiunto!", + "removed_from_list": "✅ {name} rimosso dalla lista!", + "removed_from_list_short": "Rimosso dalla lista", + "added_to_shopping": "🛒 Aggiunto alla lista della spesa!", + "removed_from_shopping": "🛒 Rimosso dalla lista della spesa", + "finished_to_bring": "🛒 Prodotto finito → aggiunto a Bring!", + "thrown_away": "🗑️ {name} buttato!", + "thrown_away_partial": "🗑️ Buttato {qty} {unit} di {name}", + "appliance_added": "Elettrodomestico aggiunto", + "item_added": "{name} aggiunto" + }, + "error": { + "generic": "Errore", + "loading": "Errore nel caricamento del prodotto", + "not_found": "Prodotto non trovato", + "not_found_manual": "Prodotto non trovato. Inseriscilo manualmente.", + "search": "Errore nella ricerca. Riprova.", + "search_short": "Errore nella ricerca", + "save": "Errore nel salvataggio", + "connection": "Errore di connessione", + "camera": "Impossibile accedere alla fotocamera", + "bring_add": "Errore nell'aggiunta a Bring!", + "bring_connection": "Errore connessione Bring!", + "identification": "Errore nell'identificazione", + "barcode_empty": "Inserisci un codice a barre", + "barcode_format": "Il codice a barre deve contenere solo numeri (4-14 cifre)", + "min_chars": "Scrivi almeno 2 caratteri", + "not_in_inventory": "Prodotto non nell'inventario", + "appliance_exists": "Elettrodomestico già presente", + "already_exists": "Già presente" + }, + "confirm": { + "remove_item": "Vuoi davvero rimuovere questo prodotto dall'inventario?" + }, + "edit": { + "title": "Modifica {name}" + }, + "screensaver": { + "recipe_btn": "Ricette", + "scan_btn": "Scansiona prodotto" + }, + "days": { + "mon": "Lunedì", + "tue": "Martedì", + "wed": "Mercoledì", + "thu": "Giovedì", + "fri": "Venerdì", + "sat": "Sabato", + "sun": "Domenica" + }, + "meal_types": { + "lunch": "Pranzo", + "dinner": "Cena" }, "scale": { - "title": "⚖️ Bilancia Smart", - "hint": "Collega una bilancia Bluetooth tramite il gateway Android per leggere il peso automaticamente.", - "tab": "Bilancia Smart", - "enabled": "✅ Abilita bilancia smart", - "url_label": "🌐 URL Gateway WebSocket", - "url_placeholder": "ws://192.168.1.x:8765", - "url_hint": "URL mostrato dall'app Android (stessa rete Wi-Fi). Es:", - "test_btn": "🔗 Testa connessione", - "download_btn": "📥 Scarica Gateway Android (APK)", - "download_hint": "App Android che fa da ponte tra la bilancia BLE e questo sito.", - "download_sub": "Sorgente: evershelf-scale-gateway/ nella root del progetto" + "status_connected": "Bilancia connessa", + "status_searching": "Connesso al gateway, attesa bilancia…", + "status_disconnected": "Gateway bilancia non raggiungibile", + "status_error": "Errore connessione gateway", + "not_connected": "Gateway bilancia non connesso", + "read_btn": "⚖️ Leggi dalla bilancia", + "reading_title": "Lettura bilancia", + "place_on_scale": "Metti il prodotto sulla bilancia…", + "waiting_stable": "Il peso venire rilevato automaticamente quando la lettura sarà stabile.", + "no_url": "Inserisci l'URL del gateway", + "testing": "⏳ Test connessione…", + "connected_ok": "Connessione gateway riuscita!", + "timeout": "Timeout: nessuna risposta dal gateway", + "error_connect": "Impossibile connettersi al gateway", + "tab": "Bilancia Smart", + "low_weight": "Peso < 10 g · inserisci manualmente\n(la lettura automatica richiede almeno 10 g)" }, - "saved": "✅ Configurazione salvata!", - "saved_local": "✅ Configurazione salvata localmente", - "saved_local_error": "⚠️ Salvato localmente, errore server: {error}" - }, - "expiry": { - "today": "OGGI", - "tomorrow": "Domani", - "days": "{days} giorni", - "expired_days": "Da {days}g", - "expired_yesterday": "Da ieri", - "expired_today": "Oggi" - }, - "status": { - "ok": "OK", - "check": "Controlla", - "discard": "Buttare" - }, - "toast": { - "product_saved": "Prodotto salvato!", - "product_created": "Prodotto creato!", - "product_updated": "✅ Prodotto aggiornato!", - "product_removed": "Prodotto rimosso", - "updated": "Aggiornato!", - "quantity_confirmed": "✓ Quantità confermata", - "added_to_inventory": "✅ {name} aggiunto!", - "removed_from_list": "✅ {name} rimosso dalla lista!", - "removed_from_list_short": "Rimosso dalla lista", - "added_to_shopping": "🛒 Aggiunto alla lista della spesa!", - "removed_from_shopping": "🛒 Rimosso dalla lista della spesa", - "finished_to_bring": "🛒 Prodotto finito → aggiunto a Bring!", - "thrown_away": "🗑️ {name} buttato!", - "thrown_away_partial": "🗑️ Buttato {qty} {unit} di {name}", - "appliance_added": "Elettrodomestico aggiunto", - "item_added": "{name} aggiunto" - }, - "error": { - "generic": "Errore", - "loading": "Errore nel caricamento del prodotto", - "not_found": "Prodotto non trovato", - "not_found_manual": "Prodotto non trovato. Inseriscilo manualmente.", - "search": "Errore nella ricerca. Riprova.", - "search_short": "Errore nella ricerca", - "save": "Errore nel salvataggio", - "connection": "Errore di connessione", - "camera": "Impossibile accedere alla fotocamera", - "bring_add": "Errore nell'aggiunta a Bring!", - "bring_connection": "Errore connessione Bring!", - "identification": "Errore nell'identificazione", - "barcode_empty": "Inserisci un codice a barre", - "barcode_format": "Il codice a barre deve contenere solo numeri (4-14 cifre)", - "min_chars": "Scrivi almeno 2 caratteri", - "not_in_inventory": "Prodotto non nell'inventario", - "appliance_exists": "Elettrodomestico già presente", - "already_exists": "Già presente" - }, - "confirm": { - "remove_item": "Vuoi davvero rimuovere questo prodotto dall'inventario?" - }, - "edit": { - "title": "Modifica {name}" - }, - "screensaver": { - "recipe_btn": "Ricette", - "scan_btn": "Scansiona prodotto" - }, - "days": { - "mon": "Lunedì", - "tue": "Martedì", - "wed": "Mercoledì", - "thu": "Giovedì", - "fri": "Venerdì", - "sat": "Sabato", - "sun": "Domenica" - }, - "meal_types": { - "lunch": "Pranzo", - "dinner": "Cena" - }, - "scale": { - "status_connected": "Bilancia connessa", - "status_searching": "Connesso al gateway, attesa bilancia…", - "status_disconnected": "Gateway bilancia non raggiungibile", - "status_error": "Errore connessione gateway", - "not_connected": "Gateway bilancia non connesso", - "read_btn": "⚖️ Leggi dalla bilancia", - "reading_title": "Lettura bilancia", - "place_on_scale": "Metti il prodotto sulla bilancia…", - "waiting_stable": "Il peso venire rilevato automaticamente quando la lettura sarà stabile.", - "no_url": "Inserisci l'URL del gateway", - "testing": "⏳ Test connessione…", - "connected_ok": "Connessione gateway riuscita!", - "timeout": "Timeout: nessuna risposta dal gateway", - "error_connect": "Impossibile connettersi al gateway", - "tab": "Bilancia Smart" - } -} + "prediction": { + "expected_qty": "Previsto: {expected} {unit}", + "actual_qty": "Attuale: {actual} {unit}", + "check_suggestion": "Verifica o pesa la quantità residua" + } +} \ No newline at end of file