feat: banner alerts, consumption predictions, scale improvements, kiosk app

- Banner notification system: suspicious quantities + consumption prediction alerts
- Consumption predictions API: tracks 90-day usage patterns, flags >30% deviations
- Scale stability timeout: 5s → 10s, auto-confirm remains 5s
- Scale integration in edit form: weigh button with inline live display
- Banner edit/weigh actions open edit form directly with scale activation
- Cooking mode: Italian aliases + stem-prefix matching for ingredients
- Recipe regeneration: tracks rejected ingredients for diversity
- Settings migration: localStorage → .env server-side storage
- Expiry priority: mandatory ≤3 days, recommended ≤7 days in recipes
- Scale bug fixes: clear stale weight, double-submit guard, cap deduction
- Android kiosk app (evershelf-kiosk): WebView + embedded BLE scale gateway
- Version bump to 1.4.0
This commit is contained in:
dadaloop82
2026-04-16 14:46:30 +00:00
parent 3ff91b3018
commit 3e25fcd5df
25 changed files with 3431 additions and 1500 deletions
+276 -23
View File
@@ -189,6 +189,10 @@ try {
getStats($db); getStats($db);
break; break;
case 'consumption_predictions':
getConsumptionPredictions($db);
break;
// ===== AI ===== // ===== AI =====
case 'gemini_expiry': case 'gemini_expiry':
geminiReadExpiry(); geminiReadExpiry();
@@ -936,6 +940,8 @@ function useFromInventory(PDO $db): void {
} }
$newQty = max(0, $existing['quantity'] - $quantity); $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) { if ($newQty <= 0) {
$stmt = $db->prepare("DELETE FROM inventory WHERE id = ?"); $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'; $type = ($notes === 'Buttato') ? 'waste' : 'out';
$stmt = $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, ?, ?, ?, ?)"); $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; $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 ===== // ===== SETTINGS =====
function getServerSettings(): void { function getServerSettings(): void {
// Return values for client — passwords are never exposed
$geminiKey = env('GEMINI_API_KEY'); $geminiKey = env('GEMINI_API_KEY');
$bringEmail = env('BRING_EMAIL'); $bringEmail = env('BRING_EMAIL');
@@ -1288,6 +1399,22 @@ function getServerSettings(): void {
'tts_content_type' => env('TTS_CONTENT_TYPE', 'application/json'), 'tts_content_type' => env('TTS_CONTENT_TYPE', 'application/json'),
'tts_payload_key' => env('TTS_PAYLOAD_KEY', 'message'), 'tts_payload_key' => env('TTS_PAYLOAD_KEY', 'message'),
'tts_enabled' => env('TTS_ENABLED', 'false') === 'true', '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'; $envFile = __DIR__ . '/../.env';
$envVars = loadEnv(); $envVars = loadEnv();
// Update values from input — only overwrite if new value is non-empty // Map of input key → .env key — only update if present in input
if (!empty($input['gemini_key'])) { $keyMap = [
$envVars['GEMINI_API_KEY'] = $input['gemini_key']; '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'])) { foreach ($boolMap as $inKey => $envKey) {
$envVars['BRING_EMAIL'] = $input['bring_email']; if (array_key_exists($inKey, $input)) {
$envVars[$envKey] = $input[$inKey] ? 'true' : 'false';
}
} }
if (!empty($input['bring_password'])) { foreach ($intMap as $inKey => $envKey) {
$envVars['BRING_PASSWORD'] = $input['bring_password']; 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 // Write .env file
@@ -1314,6 +1484,10 @@ function saveSettings(): void {
} }
$result = file_put_contents($envFile, implode("\n", $lines) . "\n"); $result = file_put_contents($envFile, implode("\n", $lines) . "\n");
// Clear cached env
static $cache = null;
$cache = null;
if ($result !== false) { if ($result !== false) {
echo json_encode(['success' => true]); echo json_encode(['success' => true]);
} else { } else {
@@ -1579,6 +1753,7 @@ function generateRecipe(PDO $db): void {
$todayRecipes = $input['today_recipes'] ?? []; $todayRecipes = $input['today_recipes'] ?? [];
$mealPlanType = $input['meal_plan_type'] ?? ''; // e.g. 'pasta', 'pesce', 'legumi', ... $mealPlanType = $input['meal_plan_type'] ?? ''; // e.g. 'pasta', 'pesce', 'legumi', ...
$variation = max(0, intval($input['variation'] ?? 0)); // 0=first attempt, 1+=re-generation $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 // Fetch all inventory items with expiry info
$stmt = $db->query(" $stmt = $db->query("
@@ -1689,14 +1864,16 @@ function generateRecipe(PDO $db): void {
$label = $item['name'] . ($item['brand'] ? " ({$item['brand']})" : '') . $openNote . $expiryNote; $label = $item['name'] . ($item['brand'] ? " ({$item['brand']})" : '') . $openNote . $expiryNote;
if ($wantsExpiryPriority) { if ($wantsExpiryPriority) {
if ($g === 1 || ($g === 2 && $daysLeft <= 1)) { // Expired or expiring within 3 days → mandatory
if ($g === 1 || $g === 2) {
$mandatoryItems[] = $label; $mandatoryItems[] = $label;
} elseif ($g === 2) { // Expiring within 7 days → strongly recommended
} elseif ($g === 3) {
$recommendedItems[] = $label; $recommendedItems[] = $label;
} }
} }
if (($wantsOpenedPriority || $wantsExpiryPriority) && $isOpen && $daysLeft <= 5 && $daysLeft >= 0) { if (($wantsOpenedPriority || $wantsExpiryPriority) && $isOpen && $daysLeft <= 7 && $daysLeft >= 0) {
// Opened items expiring within 5 days but not already in mandatory/recommended // Opened items expiring within 7 days
if (!in_array($label, $mandatoryItems) && !in_array($label, $recommendedItems)) { if (!in_array($label, $mandatoryItems) && !in_array($label, $recommendedItems)) {
$recommendedItems[] = $label; $recommendedItems[] = $label;
} }
@@ -1870,6 +2047,13 @@ function generateRecipe(PDO $db): void {
"Devi proporre qualcosa di COMPLETAMENTE DIVERSO: stile di cucina diverso, ingrediente principale diverso, " . "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. " . "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!"; "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 = <<<PROMPT $prompt = <<<PROMPT
@@ -1894,6 +2078,8 @@ REGOLE IMPORTANTI:
6. La ricetta deve essere adatta al pasto: $mealLabel 6. La ricetta deve essere adatta al pasto: $mealLabel
7. IMPORTANTE - QUANTITÀ NUMERICHE: per ogni ingrediente dalla dispensa, il campo "qty_number" DEVE contenere il valore NUMERICO da scalare dall'inventario, espresso nella STESSA unità di misura della dispensa. Le unità ammesse sono SOLO: g (grammi), ml (millilitri), pz (pezzi), conf (confezioni). NON usare mai kg o litri. Esempio: se in dispensa c'è "Farina: 1000 g" e la ricetta richiede 200g, qty_number = 200. Se "Riso: 2000 g" e servono 300g, qty_number = 300. Per ingredienti non dalla dispensa, qty_number = 0. 7. IMPORTANTE - QUANTITÀ NUMERICHE: per ogni ingrediente dalla dispensa, il campo "qty_number" DEVE contenere il valore NUMERICO da scalare dall'inventario, espresso nella STESSA unità di misura della dispensa. Le unità ammesse sono SOLO: g (grammi), ml (millilitri), pz (pezzi), conf (confezioni). NON usare mai kg o litri. Esempio: se in dispensa c'è "Farina: 1000 g" e la ricetta richiede 200g, qty_number = 200. Se "Riso: 2000 g" e servono 300g, qty_number = 300. Per ingredienti non dalla dispensa, qty_number = 0.
8. GESTIONE SMART QUANTITÀ: NON lasciare rimasugli poco usabili in dispensa. Se un ingrediente ha una quantità piccola (es. 50g di formaggio, 1 uovo, 100ml di latte), preferisci usarlo TUTTO piuttosto che lasciarne una quantità inutilizzabile. Se invece la quantità è abbondante, usa solo il necessario lasciando abbastanza per un altro pasto. Pensa sempre: "quello che resta sarà sufficiente per un altro utilizzo?" 8. GESTIONE SMART QUANTITÀ: NON lasciare rimasugli poco usabili in dispensa. Se un ingrediente ha una quantità piccola (es. 50g di formaggio, 1 uovo, 100ml di latte), preferisci usarlo TUTTO piuttosto che lasciarne una quantità inutilizzabile. Se invece la quantità è abbondante, usa solo il necessario lasciando abbastanza per un altro pasto. Pensa sempre: "quello che resta sarà sufficiente per un altro utilizzo?"
9. NOMI INGREDIENTI: nel campo "name" di ogni ingrediente dalla dispensa, usa ESATTAMENTE lo stesso nome riportato nella lista sotto (copia-incolla). NON riformulare, NON abbreviare, NON tradurre. Il sistema usa il nome per collegare l'ingrediente all'inventario. Se il nome non corrisponde, l'ingrediente non viene scalato correttamente.
10. COMPLETEZZA: la lista ingredienti DEVE includere TUTTI gli ingredienti necessari citati nei passi della ricetta. Se un passo dice "aggiungere il latte", il latte DEVE comparire nella lista ingredienti. Non dare per scontato nessun ingrediente tranne acqua, sale, pepe e olio.
INGREDIENTI DISPONIBILI IN DISPENSA: INGREDIENTI DISPONIBILI IN DISPENSA:
$ingredientsText $ingredientsText
@@ -1966,14 +2152,49 @@ PROMPT;
if ($recipe && !empty($recipe['title'])) { if ($recipe && !empty($recipe['title'])) {
// Enrich from_pantry ingredients with product_id and location for "use" feature // Enrich from_pantry ingredients with product_id and location for "use" feature
if (!empty($recipe['ingredients'])) { if (!empty($recipe['ingredients'])) {
// Build a category map for better fuzzy matching
$itemsLookup = [];
foreach ($items as $item) {
$itemsLookup[] = [
'item' => $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) { foreach ($recipe['ingredients'] as &$ing) {
if (!empty($ing['from_pantry'])) { if (!empty($ing['from_pantry'])) {
$ingNameLower = mb_strtolower(trim($ing['name']), 'UTF-8'); $ingNameLower = mb_strtolower(trim($ing['name']), 'UTF-8');
$ingWords = preg_split('/[\s,.\-\/]+/', $ingNameLower);
$bestMatch = null; $bestMatch = null;
$bestScore = 0; $bestScore = 0;
foreach ($items as $item) { foreach ($itemsLookup as $entry) {
$itemNameLower = mb_strtolower(trim($item['name']), 'UTF-8'); $itemNameLower = $entry['lower'];
$itemWords = $entry['words'];
$score = 0; $score = 0;
// Exact match // Exact match
@@ -1988,19 +2209,51 @@ PROMPT;
elseif (mb_strpos($ingNameLower, $itemNameLower) !== false) { elseif (mb_strpos($ingNameLower, $itemNameLower) !== false) {
$score = 70; $score = 70;
} }
// Word-level matching: check if key words overlap
else { else {
$ingWords = preg_split('/\s+/', $ingNameLower); // Word-level matching with alias expansion
$itemWords = preg_split('/\s+/', $itemNameLower); $expandedIngWords = $ingWords;
$common = array_intersect($ingWords, $itemWords); foreach ($ingWords as $w) {
if (count($common) > 0) { foreach ($aliases as $key => $group) {
$score = (count($common) / max(count($ingWords), 1)) * 60; 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) { if ($score > $bestScore) {
$bestScore = $score; $bestScore = $score;
$bestMatch = $item; $bestMatch = $entry['item'];
} }
} }
+109 -10
View File
@@ -879,24 +879,19 @@ body {
} }
.scale-live-box.scale-low-weight { .scale-live-box.scale-low-weight {
border-color: #dc2626; 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 { .scale-low-weight .scale-live-val {
color: #dc2626 !important; color: #dc2626 !important;
animation: scaleLowTextBlink 0.65s ease-in-out infinite alternate;
} }
.scale-low-weight .scale-live-label { .scale-low-weight .scale-live-label {
color: #dc2626 !important; color: #dc2626 !important;
font-weight: 600; font-weight: 600;
animation: scaleLowTextBlink 0.65s ease-in-out infinite alternate;
} }
@keyframes scaleLowWeightBlink { @keyframes scaleLowTextBlink {
from { border-color: #dc2626; box-shadow: none; } from { opacity: 1; }
to { border-color: #dc2626; box-shadow: 0 0 0 3px rgba(220,38,38,0.25); } to { opacity: 0.2; }
} }
.btn-accent { .btn-accent {
@@ -4421,6 +4416,110 @@ body {
} }
/* ===== REVIEW SECTION ===== */ /* ===== 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 { .alert-review {
background: #fffbeb; background: #fffbeb;
border-color: #f59e0b; border-color: #f59e0b;
+333 -110
View File
@@ -70,7 +70,7 @@ let _scaleWeightCallback = null; // pending on-demand weight request callback
let _scaleLatestWeight = null; // last received weight message let _scaleLatestWeight = null; // last received weight message
let _scaleAutoConfirmTimer = null; // countdown timer for auto-confirm after stable weight let _scaleAutoConfirmTimer = null; // countdown timer for auto-confirm after stable weight
let _scaleAutoConfirmRAF = null; // rAF handle for auto-confirm progress bar animation 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 _scaleStabilityRAF = null; // rAF handle for stability progress bar in the live box
let _scaleStabilityVal = null; // value we are currently timing for stability let _scaleStabilityVal = null; // value we are currently timing for stability
let _scaleUserDismissed = false; // user tapped or edited → don't retrigger for same value let _scaleUserDismissed = false; // user tapped or edited → don't retrigger for same value
@@ -120,6 +120,9 @@ function _scaleOnMessage(msg) {
// Update live reading modal overlay if visible (scale-read modal) // Update live reading modal overlay if visible (scale-read modal)
const live = document.getElementById('scale-reading-live'); const live = document.getElementById('scale-reading-live');
if (live) live.textContent = `${msg.value} ${msg.unit || 'kg'}${msg.stable ? ' ✓' : ' …'}`; 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) // Always update the persistent live box on the use page (every message, stable or not)
_scaleUpdateLiveBox(msg); _scaleUpdateLiveBox(msg);
// If weight is NOT stable: stop any running timer/bar but keep the sentinel value. // 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 // Weight too low — show red flashing warning
box.classList.add('scale-low-weight'); box.classList.add('scale-low-weight');
if (valEl) valEl.textContent = `${raw} ${msg.unit || 'kg'}`; 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 { } else {
box.classList.remove('scale-low-weight'); box.classList.remove('scale-low-weight');
const stIcon = msg.stable ? ' ✓' : ' …'; 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. * Start a 10-second stability wait with an animated progress bar in the live box.
* Calls onStable() when weight unchanged for 5 s. * Calls onStable() when weight unchanged for 10 s.
*/ */
function _startScaleStabilityWait(onStable) { function _startScaleStabilityWait(onStable) {
_cancelScaleStabilityWait(); _cancelScaleStabilityWait();
const duration = 5000; const duration = 10000;
const start = performance.now(); const start = performance.now();
const bar = document.getElementById('scale-live-progress-bar'); 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 // 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) { function _scaleShowReadingModal(targetInputId, unit) {
document.getElementById('modal-content').innerHTML = ` document.getElementById('modal-content').innerHTML = `
<div class="modal-header"> <div class="modal-header">
@@ -1388,16 +1435,29 @@ function debounce(fn, ms) {
async function syncSettingsFromDB() { async function syncSettingsFromDB() {
try { try {
// Primary: load from server .env
const serverSettings = await api('get_settings');
const s = getSettings();
const serverKeys = ['default_persons','pref_veloce','pref_pocafame','pref_scadenze',
'pref_healthy','pref_opened','pref_zerowaste','dietary','appliances',
'camera_facing','scale_enabled','scale_gateway_url','spesa_provider',
'spesa_ai_prompt','meal_plan_enabled','tts_enabled','tts_url','tts_token',
'tts_method','tts_auth_type','tts_content_type','tts_payload_key'];
for (const key of serverKeys) {
if (serverSettings[key] !== undefined && serverSettings[key] !== null && serverSettings[key] !== '') {
s[key] = serverSettings[key];
}
}
_settingsCache = s;
localStorage.setItem('evershelf_settings', JSON.stringify(s));
// Also load review_confirmed from DB
const res = await api('app_settings_get'); const res = await api('app_settings_get');
if (res.success && res.settings) { if (res.success && res.settings) {
// Spesa credentials still come from DB (not .env)
if (res.settings.user_prefs) { if (res.settings.user_prefs) {
const db = res.settings.user_prefs; const db = res.settings.user_prefs;
const s = getSettings(); for (const key of ['spesa_email','spesa_password','spesa_logged_in',
// Merge DB settings into local (DB wins for shared prefs) 'spesa_user','spesa_data','spesa_token']) {
for (const key of ['default_persons','pref_veloce','pref_pocafame','pref_scadenze',
'pref_healthy','pref_opened','pref_zerowaste','dietary','appliances',
'spesa_provider','spesa_ai_prompt','spesa_email','spesa_password',
'spesa_logged_in','spesa_user','spesa_data','spesa_token']) {
if (db[key] !== undefined) s[key] = db[key]; if (db[key] !== undefined) s[key] = db[key];
} }
_settingsCache = s; _settingsCache = s;
@@ -1489,31 +1549,56 @@ async function loadSettingsUI() {
const ttsExtraEl = document.getElementById('setting-tts-extra-fields'); const ttsExtraEl = document.getElementById('setting-tts-extra-fields');
if (ttsExtraEl) ttsExtraEl.value = s.tts_extra_fields || ''; if (ttsExtraEl) ttsExtraEl.value = s.tts_extra_fields || '';
// Load server-side settings if not already set locally // Load server-side settings as primary source
try { try {
const serverSettings = await api('get_settings'); const serverSettings = await api('get_settings');
if (!s.gemini_key && serverSettings.gemini_key) { // Merge all server settings into local cache (server wins)
document.getElementById('setting-gemini-key').value = serverSettings.gemini_key; const serverKeys = ['gemini_key','bring_email','bring_password',
'default_persons','pref_veloce','pref_pocafame','pref_scadenze',
'pref_healthy','pref_opened','pref_zerowaste','dietary','appliances',
'camera_facing','scale_enabled','scale_gateway_url','spesa_provider',
'spesa_ai_prompt','meal_plan_enabled',
'tts_enabled','tts_url','tts_token','tts_method','tts_auth_type',
'tts_content_type','tts_payload_key'];
let changed = false;
for (const key of serverKeys) {
if (serverSettings[key] !== undefined && serverSettings[key] !== null && serverSettings[key] !== '') {
s[key] = serverSettings[key];
changed = true;
}
} }
if (!s.bring_email && serverSettings.bring_email) { if (changed) {
document.getElementById('setting-bring-email').value = serverSettings.bring_email; _settingsCache = s;
localStorage.setItem('evershelf_settings', JSON.stringify(s));
// Re-populate UI with merged values
document.getElementById('setting-gemini-key').value = s.gemini_key || '';
document.getElementById('setting-bring-email').value = s.bring_email || '';
document.getElementById('setting-bring-password').value = s.bring_password || '';
document.getElementById('setting-default-persons').value = s.default_persons || 1;
document.getElementById('setting-pref-veloce').checked = !!s.pref_veloce;
document.getElementById('setting-pref-pocafame').checked = !!s.pref_pocafame;
document.getElementById('setting-pref-scadenze').checked = !!s.pref_scadenze;
document.getElementById('setting-pref-healthy').checked = !!s.pref_healthy;
document.getElementById('setting-pref-opened').checked = !!s.pref_opened;
document.getElementById('setting-pref-zerowaste').checked = !!s.pref_zerowaste;
document.getElementById('setting-dietary').value = s.dietary || '';
if (cameraSelect) cameraSelect.value = s.camera_facing || 'environment';
renderAppliances(s.appliances || []);
if (ttsEnabledEl) ttsEnabledEl.checked = s.tts_enabled === true;
if (ttsUrlEl) ttsUrlEl.value = s.tts_url || '';
if (ttsTokenEl) ttsTokenEl.value = s.tts_token || '';
if (ttsMethEl) ttsMethEl.value = s.tts_method || 'POST';
if (ttsAuthTypeEl) ttsAuthTypeEl.value = s.tts_auth_type || 'bearer';
if (ttsCtEl) ttsCtEl.value = s.tts_content_type || 'application/json';
if (ttsPayloadKeyEl) ttsPayloadKeyEl.value = s.tts_payload_key || 'message';
if (scaleEnabledUiEl) scaleEnabledUiEl.checked = !!s.scale_enabled;
if (scaleUrlUiEl) scaleUrlUiEl.value = s.scale_gateway_url || '';
const mpEnabledUp = s.meal_plan_enabled !== false;
if (mpEnabledEl) mpEnabledEl.checked = mpEnabledUp;
if (mpConfigSection) mpConfigSection.style.display = mpEnabledUp ? '' : 'none';
if (mpLegendCard) mpLegendCard.style.display = mpEnabledUp ? '' : 'none';
} }
// Load TTS defaults from server .env if not set locally } catch(e) { /* offline, use local */ }
if (!s.tts_url && serverSettings.tts_url) {
s.tts_url = serverSettings.tts_url;
s.tts_token = serverSettings.tts_token || '';
s.tts_method = serverSettings.tts_method || 'POST';
s.tts_auth_type = serverSettings.tts_auth_type || 'bearer';
s.tts_content_type = serverSettings.tts_content_type || 'application/json';
s.tts_payload_key = serverSettings.tts_payload_key || 'message';
s.tts_enabled = serverSettings.tts_enabled || false;
saveSettingsToStorage(s);
// Update UI fields with server values
if (ttsUrlEl) ttsUrlEl.value = s.tts_url;
if (ttsTokenEl) ttsTokenEl.value = s.tts_token;
if (ttsEnabledEl) ttsEnabledEl.checked = s.tts_enabled;
}
} catch(e) { /* ignore */ }
// Scale settings // Scale settings
const scaleEnabledUiEl = document.getElementById('setting-scale-enabled'); const scaleEnabledUiEl = document.getElementById('setting-scale-enabled');
if (scaleEnabledUiEl) scaleEnabledUiEl.checked = !!s.scale_enabled; if (scaleEnabledUiEl) scaleEnabledUiEl.checked = !!s.scale_enabled;
@@ -1647,12 +1732,34 @@ async function saveSettings() {
if (scaleUrlEl) s.scale_gateway_url = scaleUrlEl.value.trim(); if (scaleUrlEl) s.scale_gateway_url = scaleUrlEl.value.trim();
saveSettingsToStorage(s); saveSettingsToStorage(s);
// Also save to server .env // Save ALL settings to server .env
try { try {
const result = await api('save_settings', {}, 'POST', { const result = await api('save_settings', {}, 'POST', {
gemini_key: s.gemini_key, gemini_key: s.gemini_key,
bring_email: s.bring_email, bring_email: s.bring_email,
bring_password: s.bring_password bring_password: s.bring_password,
default_persons: s.default_persons,
pref_veloce: s.pref_veloce,
pref_pocafame: s.pref_pocafame,
pref_scadenze: s.pref_scadenze,
pref_healthy: s.pref_healthy,
pref_opened: s.pref_opened,
pref_zerowaste: s.pref_zerowaste,
dietary: s.dietary,
appliances: s.appliances,
camera_facing: s.camera_facing,
scale_enabled: s.scale_enabled,
scale_gateway_url: s.scale_gateway_url,
spesa_provider: s.spesa_provider,
spesa_ai_prompt: s.spesa_ai_prompt,
meal_plan_enabled: s.meal_plan_enabled,
tts_enabled: s.tts_enabled,
tts_url: s.tts_url,
tts_token: s.tts_token,
tts_method: s.tts_method,
tts_auth_type: s.tts_auth_type,
tts_content_type: s.tts_content_type,
tts_payload_key: s.tts_payload_key,
}); });
const statusEl = document.getElementById('settings-status'); const statusEl = document.getElementById('settings-status');
if (result.success) { if (result.success) {
@@ -1871,8 +1978,8 @@ async function loadDashboard() {
expiredSection.style.display = 'none'; expiredSection.style.display = 'none';
} }
// Review suspicious quantities // Banner alerts (suspicious quantities + consumption predictions)
loadReviewItems(); loadBannerAlerts();
// Waste vs consumption chart // Waste vs consumption chart
const wasteSection = document.getElementById('waste-chart-section'); const wasteSection = document.getElementById('waste-chart-section');
@@ -2007,7 +2114,7 @@ function quickRecipeSuggestion() {
}, 500); }, 500);
} }
// === SUSPICIOUS QUANTITY REVIEW === // === SUSPICIOUS QUANTITY THRESHOLDS ===
const QTY_THRESHOLDS = { const QTY_THRESHOLDS = {
'pz': { min: 0.3, max: 50 }, 'pz': { min: 0.3, max: 50 },
'conf': { min: 0.3, max: 50 }, 'conf': { min: 0.3, max: 50 },
@@ -2018,17 +2125,16 @@ const QTY_THRESHOLDS = {
function isSuspiciousQty(qty, unit) { function isSuspiciousQty(qty, unit) {
const n = parseFloat(qty); const n = parseFloat(qty);
if (isNaN(n) || n <= 0) return false; if (isNaN(n) || n <= 0) return false;
const t = QTY_THRESHOLDS[unit] || QTY_THRESHOLDS['pz']; const th = QTY_THRESHOLDS[unit] || QTY_THRESHOLDS['pz'];
return n < t.min || n > t.max; return n < th.min || n > th.max;
} }
function isSuspiciousDefaultQty(defaultQty, unit, packageUnit) { function isSuspiciousDefaultQty(defaultQty, unit, packageUnit) {
const n = parseFloat(defaultQty); const n = parseFloat(defaultQty);
if (!n || n <= 0) return false; if (!n || n <= 0) return false;
// For conf products, default_quantity is in package_unit (g, ml, etc.)
const checkUnit = (unit === 'conf' && packageUnit) ? packageUnit : unit; const checkUnit = (unit === 'conf' && packageUnit) ? packageUnit : unit;
const t = QTY_THRESHOLDS[checkUnit] || QTY_THRESHOLDS['pz']; const th = QTY_THRESHOLDS[checkUnit] || QTY_THRESHOLDS['pz'];
return n > t.max; return n > th.max;
} }
function getReviewConfirmed() { function getReviewConfirmed() {
@@ -2040,87 +2146,170 @@ function setReviewConfirmed(inventoryId) {
const c = getReviewConfirmed(); const c = getReviewConfirmed();
c[inventoryId] = Date.now(); c[inventoryId] = Date.now();
_reviewConfirmedCache = c; _reviewConfirmedCache = c;
// Persist to shared DB
api('app_settings_save', {}, 'POST', { settings: { review_confirmed: c } }).catch(() => {}); api('app_settings_save', {}, 'POST', { settings: { review_confirmed: c } }).catch(() => {});
} }
async function loadReviewItems() { // === ALERT BANNER SYSTEM (replaces old review table) ===
const section = document.getElementById('alert-review'); let _bannerQueue = []; // array of { type, data } — 'review' or 'prediction'
const list = document.getElementById('review-list'); let _bannerIndex = 0;
/**
* Load suspicious quantities + consumption predictions, merge into a single
* banner queue and show the first item.
*/
async function loadBannerAlerts() {
_bannerQueue = [];
_bannerIndex = 0;
const banner = document.getElementById('alert-banner');
if (!banner) { console.warn('[Banner] #alert-banner not found'); return; }
try { try {
const data = await api('inventory_list'); const [invData, predData] = await Promise.all([
const items = data.inventory || []; api('inventory_list'),
api('consumption_predictions').catch(err => { console.warn('[Banner] predictions fetch failed:', err); return { predictions: [] }; }),
]);
const items = invData.inventory || [];
const confirmed = getReviewConfirmed(); const confirmed = getReviewConfirmed();
const suspicious = items.filter(item => { // 1. Suspicious quantities
if (confirmed[item.id]) return false; items.forEach(item => {
return isSuspiciousQty(item.quantity, item.unit) || isSuspiciousDefaultQty(item.default_quantity, item.unit, item.package_unit); if (confirmed[item.id]) return;
if (isSuspiciousQty(item.quantity, item.unit) || isSuspiciousDefaultQty(item.default_quantity, item.unit, item.package_unit)) {
const t_ = QTY_THRESHOLDS[item.unit] || QTY_THRESHOLDS['pz'];
const suspQty = isSuspiciousQty(item.quantity, item.unit);
const suspDq = isSuspiciousDefaultQty(item.default_quantity, item.unit, item.package_unit);
let warning;
if (suspDq && !suspQty) warning = '📦 Conf. sospetta';
else if (parseFloat(item.quantity) < t_.min) warning = '⬇️ Troppo poco';
else warning = '⬆️ Troppo';
_bannerQueue.push({ type: 'review', data: { ...item, warning } });
}
}); });
if (suspicious.length === 0) { // 2. Consumption predictions that don't match actual quantity
section.style.display = 'none'; const predictions = predData.predictions || [];
return; predictions.forEach(pred => {
} if (confirmed['pred_' + pred.inventory_id]) return;
_bannerQueue.push({ type: 'prediction', data: pred });
section.style.display = 'block'; });
list.innerHTML = suspicious.map(item => {
const catIcon = CATEGORY_ICONS[mapToLocalCategory(item.category, item.name)] || '📦'; console.log(`[Banner] queue ready: ${_bannerQueue.length} items (${items.length} inv, ${predictions.length} pred, ${Object.keys(confirmed).length} confirmed)`);
const qtyDisplay = formatQuantity(item.quantity, item.unit, item.default_quantity, item.package_unit);
const locInfo = LOCATIONS[item.location] || { icon: '📦', label: item.location }; } catch (e) {
const t = QTY_THRESHOLDS[item.unit] || QTY_THRESHOLDS['pz']; console.error('[Banner] loadBannerAlerts error:', e);
const suspQty = isSuspiciousQty(item.quantity, item.unit); }
const suspDq = isSuspiciousDefaultQty(item.default_quantity, item.unit, item.package_unit);
let warning; if (_bannerQueue.length > 0) {
if (suspDq && !suspQty) warning = '📦 Conf. sospetta'; _bannerIndex = 0;
else if (parseFloat(item.quantity) < t.min) warning = '⬇️ Troppo poco'; renderBannerItem();
else warning = '⬆️ Troppo'; } else {
banner.style.display = 'none';
return `
<div class="review-item" id="review-item-${item.id}">
<div class="review-item-info">
<span class="review-item-icon">${item.image_url ? `<img src="${escapeHtml(item.image_url)}" alt="">` : catIcon}</span>
<div class="review-item-text">
<div class="review-item-name">${escapeHtml(item.name)}</div>
<div class="review-item-meta">${locInfo.icon} ${locInfo.label} · <span class="review-warn">${warning}</span></div>
</div>
</div>
<div class="review-item-qty">
<span class="review-qty-value">${qtyDisplay}</span>
</div>
<div class="review-item-actions">
<button class="btn-review btn-review-ok" onclick="confirmReviewItem(${item.id})" title="È corretto"></button>
<button class="btn-review btn-review-edit" onclick="editReviewItem(${item.id}, ${item.product_id})" title="Modifica"></button>
</div>
</div>`;
}).join('');
} catch(e) {
section.style.display = 'none';
} }
} }
function confirmReviewItem(inventoryId) { function renderBannerItem() {
setReviewConfirmed(inventoryId); const banner = document.getElementById('alert-banner');
const el = document.getElementById(`review-item-${inventoryId}`); if (!banner || _bannerQueue.length === 0) { if (banner) banner.style.display = 'none'; return; }
if (el) { if (_bannerIndex >= _bannerQueue.length) _bannerIndex = 0;
el.style.transition = 'opacity 0.3s, transform 0.3s';
el.style.opacity = '0'; const entry = _bannerQueue[_bannerIndex];
el.style.transform = 'translateX(60px)'; const iconEl = document.getElementById('alert-banner-icon');
setTimeout(() => { const titleEl = document.getElementById('alert-banner-title');
el.remove(); const detailEl = document.getElementById('alert-banner-detail');
// Hide section if empty const actionsEl = document.getElementById('alert-banner-actions');
const list = document.getElementById('review-list'); const counterEl = document.getElementById('alert-banner-counter');
if (!list.children.length) { const s = getSettings();
document.getElementById('alert-review').style.display = 'none'; const hasScale = s.scale_enabled && s.scale_gateway_url && _scaleConnected;
}
}, 300); if (entry.type === 'review') {
const item = entry.data;
const qtyDisplay = formatQuantity(item.quantity, item.unit, item.default_quantity, item.package_unit);
banner.className = 'alert-banner';
iconEl.textContent = '⚠️';
titleEl.textContent = `${t('dashboard.banner_review_title')}: ${item.name}`;
detailEl.textContent = `${item.warning} · ${qtyDisplay}`;
let btns = `<button class="btn-banner btn-banner-ok" onclick="confirmBannerReview()">${t('dashboard.banner_review_action_ok')}</button>`;
btns += `<button class="btn-banner btn-banner-edit" onclick="editBannerReview()">${t('dashboard.banner_review_action_edit')}</button>`;
if (hasScale) {
btns += `<button class="btn-banner btn-banner-weigh" onclick="weighBannerItem()">⚖️ ${t('dashboard.banner_review_action_weigh')}</button>`;
}
actionsEl.innerHTML = btns;
} else if (entry.type === 'prediction') {
const pred = entry.data;
banner.className = 'alert-banner banner-prediction';
iconEl.textContent = '📊';
titleEl.textContent = `${t('dashboard.banner_prediction_title')}: ${pred.name}`;
const expTxt = t('prediction.expected_qty').replace('{expected}', pred.expected_qty).replace('{unit}', pred.unit);
const actTxt = t('prediction.actual_qty').replace('{actual}', pred.actual_qty).replace('{unit}', pred.unit);
detailEl.innerHTML = `${expTxt} · ${actTxt}<br><small>${t('prediction.check_suggestion')}</small>`;
let btns = `<button class="btn-banner btn-banner-confirm" onclick="confirmBannerPrediction()">${t('dashboard.banner_prediction_action_confirm')}</button>`;
btns += `<button class="btn-banner btn-banner-edit" onclick="editBannerPrediction()">${t('dashboard.banner_prediction_action_edit')}</button>`;
if (hasScale) {
btns += `<button class="btn-banner btn-banner-weigh" onclick="weighBannerItem()">⚖️ ${t('dashboard.banner_prediction_action_weigh')}</button>`;
}
actionsEl.innerHTML = btns;
} }
counterEl.textContent = _bannerQueue.length > 1 ? `${_bannerIndex + 1} / ${_bannerQueue.length}` : '';
banner.style.display = '';
}
function dismissBannerItem() {
_bannerQueue.splice(_bannerIndex, 1);
if (_bannerQueue.length === 0) {
document.getElementById('alert-banner').style.display = 'none';
return;
}
if (_bannerIndex >= _bannerQueue.length) _bannerIndex = 0;
renderBannerItem();
}
function confirmBannerReview() {
const entry = _bannerQueue[_bannerIndex];
if (!entry || entry.type !== 'review') return;
setReviewConfirmed(entry.data.id);
showToast(t('toast.quantity_confirmed'), 'success'); showToast(t('toast.quantity_confirmed'), 'success');
dismissBannerItem();
}
function editBannerReview() {
const entry = _bannerQueue[_bannerIndex];
if (!entry || entry.type !== 'review') return;
editReviewItem(entry.data.id, entry.data.product_id);
}
function confirmBannerPrediction() {
const entry = _bannerQueue[_bannerIndex];
if (!entry || entry.type !== 'prediction') return;
setReviewConfirmed('pred_' + entry.data.inventory_id);
showToast(t('toast.quantity_confirmed'), 'success');
dismissBannerItem();
}
function editBannerPrediction() {
const entry = _bannerQueue[_bannerIndex];
if (!entry || entry.type !== 'prediction') return;
editReviewItem(entry.data.inventory_id, entry.data.product_id);
}
function weighBannerItem() {
const entry = _bannerQueue[_bannerIndex];
if (!entry) return;
const item = entry.data;
const targetId = entry.type === 'prediction' ? item.inventory_id : item.id;
// Navigate to edit form and auto-start scale reading
api('inventory_list').then(data => {
currentInventory = data.inventory || [];
editInventoryItem(targetId);
setTimeout(() => readScaleForEdit(), 200);
});
} }
function editReviewItem(inventoryId, productId) { function editReviewItem(inventoryId, productId) {
api('inventory_list').then(data => { api('inventory_list').then(data => {
currentInventory = data.inventory || []; currentInventory = data.inventory || [];
showItemDetail(inventoryId, productId); editInventoryItem(inventoryId);
}); });
} }
@@ -2468,6 +2657,7 @@ function closeModal() {
_cancelScaleAutoConfirm(false); _cancelScaleAutoConfirm(false);
_scaleRecipeAutoFillPaused = false; _scaleRecipeAutoFillPaused = false;
_scaleUserDismissed = false; _scaleUserDismissed = false;
_scaleWeightCallback = null;
} }
async function quickUse(productId, location) { async function quickUse(productId, location) {
@@ -2540,6 +2730,12 @@ function editInventoryItem(id) {
const confSizeVal = (isConf && item.default_quantity > 0) ? item.default_quantity : ''; const confSizeVal = (isConf && item.default_quantity > 0) ? item.default_quantity : '';
const confUnitVal = (isConf && item.package_unit) ? item.package_unit : 'g'; const confUnitVal = (isConf && item.package_unit) ? item.package_unit : 'g';
// Determine if scale is available for this item's unit
const s = getSettings();
const effectiveUnit = isConf ? (item.package_unit || 'g') : (item.unit || 'pz');
const scaleEditReady = s.scale_enabled && s.scale_gateway_url && _scaleConnected &&
(effectiveUnit === 'g' || effectiveUnit === 'ml');
window._editingProduct = { name: item.name, category: item.category || '', _isOpened: !!item.opened_at }; window._editingProduct = { name: item.name, category: item.category || '', _isOpened: !!item.opened_at };
// Rebuild modal content for editing (don't close and reopen - just replace content) // Rebuild modal content for editing (don't close and reopen - just replace content)
@@ -2556,6 +2752,14 @@ function editInventoryItem(id) {
<input type="number" id="edit-qty" value="${item.quantity}" min="0" step="any" class="qty-input"> <input type="number" id="edit-qty" value="${item.quantity}" min="0" step="any" class="qty-input">
<button type="button" class="qty-btn" onclick="adjustQty('edit-qty', 1)">+</button> <button type="button" class="qty-btn" onclick="adjustQty('edit-qty', 1)">+</button>
</div> </div>
${scaleEditReady ? `
<div id="edit-scale-section" style="display:none;text-align:center;padding:10px;background:linear-gradient(135deg,#f3e8ff,#ede9fe);border-radius:10px;margin-top:8px">
<div style="font-size:1.8rem;font-weight:bold;color:#5b21b6" id="edit-scale-reading"> </div>
<div style="font-size:0.78rem;color:#7c6cb0;margin-top:2px">${t('scale.place_on_scale')}</div>
</div>
<button type="button" id="btn-scale-edit" class="btn btn-secondary scale-read-btn" style="margin-top:8px;width:100%"
onclick="readScaleForEdit()"> ${t('scale.read_btn')}</button>
` : ''}
</div> </div>
<div class="form-group"> <div class="form-group">
<label>📏 Unità di misura</label> <label>📏 Unità di misura</label>
@@ -4723,11 +4927,14 @@ async function submitAdd(e) {
} }
// ===== USE FROM INVENTORY ===== // ===== USE FROM INVENTORY =====
let _useSubmitting = false; // double-submit guard
function showUseForm() { function showUseForm() {
renderUsePreview(); renderUsePreview();
_useConfMode = null; // reset _useConfMode = null; // reset
_useSubmitting = false;
_scaleUserDismissed = false; _scaleUserDismissed = false;
_scaleStabilityVal = null; _scaleStabilityVal = null;
_scaleLatestWeight = null; // clear stale weight from previous product
_cancelScaleAutoConfirm(false); _cancelScaleAutoConfirm(false);
document.getElementById('use-quantity').value = 1; document.getElementById('use-quantity').value = 1;
document.getElementById('use-location').value = 'dispensa'; document.getElementById('use-location').value = 'dispensa';
@@ -5294,6 +5501,9 @@ async function submitUseAll() {
async function submitUse(e) { async function submitUse(e) {
e.preventDefault(); e.preventDefault();
if (_useSubmitting) return; // prevent double-submit from scale auto-confirm
_useSubmitting = true;
_cancelScaleAutoConfirm(false); // stop any running auto-confirm
showLoading(true); showLoading(true);
try { try {
let qty = parseFloat(document.getElementById('use-quantity').value) || 1; let qty = parseFloat(document.getElementById('use-quantity').value) || 1;
@@ -5314,6 +5524,7 @@ async function submitUse(e) {
location: document.getElementById('use-location').value, location: document.getElementById('use-location').value,
}); });
showLoading(false); showLoading(false);
_useSubmitting = false;
if (result.success) { if (result.success) {
const usedText = displayUnit ? `${displayQty}${displayUnit}` : displayQty; const usedText = displayUnit ? `${displayQty}${displayUnit}` : displayQty;
showToast(`📤 Usato ${usedText} di ${currentProduct.name}`, 'success'); showToast(`📤 Usato ${usedText} di ${currentProduct.name}`, 'success');
@@ -5332,6 +5543,7 @@ async function submitUse(e) {
} }
} catch (err) { } catch (err) {
showLoading(false); showLoading(false);
_useSubmitting = false;
showToast(t('error.connection'), 'error'); showToast(t('error.connection'), 'error');
} }
} }
@@ -7660,6 +7872,7 @@ function viewArchivedRecipe(idx) {
let _cachedRecipe = null; let _cachedRecipe = null;
let _generatedTodayTitles = []; // client-side list, robust vs race conditions let _generatedTodayTitles = []; // client-side list, robust vs race conditions
let _recipeVariationCount = {}; // { 'pranzo': 0, 'cena': 1, ... } let _recipeVariationCount = {}; // { 'pranzo': 0, 'cena': 1, ... }
let _rejectedRecipeIngredients = []; // ingredient names from previously rejected recipes
function openRecipeDialog() { function openRecipeDialog() {
const meal = getMealType(); const meal = getMealType();
@@ -8701,14 +8914,18 @@ function _renderMealPlanHint(mealSlot) {
} }
function regenerateRecipe() { 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; _cachedRecipe = null;
// Use the meal the user currently has selected (not the auto-detected one)
const meal = getSelectedMealType(); const meal = getSelectedMealType();
// increment variation counter for this meal slot
_recipeVariationCount[meal] = (_recipeVariationCount[meal] || 0) + 1; _recipeVariationCount[meal] = (_recipeVariationCount[meal] || 0) + 1;
document.getElementById('recipe-result').style.display = 'none'; document.getElementById('recipe-result').style.display = 'none';
document.getElementById('recipe-loading').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 = ''; document.getElementById('recipe-ask').style.display = '';
} }
@@ -8717,6 +8934,11 @@ async function generateRecipe() {
const persons = parseInt(document.getElementById('recipe-persons').value) || 1; const persons = parseInt(document.getElementById('recipe-persons').value) || 1;
const settings = getSettings(); 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, // Determine meal plan type for today's selected slot,
// but only if the user has NOT unchecked the meal-plan chip // but only if the user has NOT unchecked the meal-plan chip
const mealPlanChipWrap = document.getElementById('recipe-opt-mealplan-wrap'); const mealPlanChipWrap = document.getElementById('recipe-opt-mealplan-wrap');
@@ -8755,6 +8977,7 @@ async function generateRecipe() {
today_recipes: [...new Set([...await getTodayRecipeTitles(), ..._generatedTodayTitles])], today_recipes: [...new Set([...await getTodayRecipeTitles(), ..._generatedTodayTitles])],
meal_plan_type: mealPlanType, meal_plan_type: mealPlanType,
variation: _recipeVariationCount[meal] || 0, variation: _recipeVariationCount[meal] || 0,
rejected_ingredients: _rejectedRecipeIngredients,
}); });
if (!result.success) { if (!result.success) {
+46
View File
@@ -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
+48
View File
@@ -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")
}
@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- BLE permissions for Android < 12 -->
<uses-permission android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<!-- BLE permissions for Android 12+ -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Location (required for BLE scanning on Android 611) -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- Network -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- Keep screen on / foreground service -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.MaterialComponents.Light.NoActionBar">
<activity
android:name=".KioskActivity"
android:exported="true"
android:configChanges="orientation|screenSize|keyboard|keyboardHidden"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".SettingsActivity"
android:exported="false"
android:theme="@style/Theme.MaterialComponents.Light.NoActionBar" />
<service
android:name=".ScaleGatewayService"
android:foregroundServiceType="connectedDevice"
android:exported="false" />
</application>
</manifest>
@@ -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<BluetoothGattCharacteristic>()
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<BluetoothGattCharacteristic>()
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) }
}
}
}
@@ -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<WebSocket> =
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()
}
}
@@ -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(
"""
<html><body style="font-family:sans-serif;text-align:center;padding:40px;background:#1a1a2e;color:#fff">
<h2>⚠️ Connection Error</h2>
<p>Cannot reach EverShelf server</p>
<p style="color:#888;font-size:14px">${getSavedUrl()}</p>
<button onclick="location.reload()" style="padding:12px 24px;font-size:16px;border:none;border-radius:8px;background:#7C3AED;color:#fff;cursor:pointer;margin-top:20px">Retry</button>
<br><br>
<button onclick="window.location='evershelf://settings'" style="padding:8px 16px;font-size:14px;border:1px solid #666;border-radius:8px;background:transparent;color:#aaa;cursor:pointer">Settings</button>
</body></html>
""".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<String>()
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
}
}
@@ -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))
}
}
@@ -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)
}
@@ -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()
}
}
}
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#000000">
<WebView
android:id="@+id/webView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<!-- Settings button — small transparent gear icon in top-right corner -->
<ImageButton
android:id="@+id/btnSettings"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="top|end"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:background="@android:color/transparent"
android:src="@android:drawable/ic_menu_manage"
android:alpha="0.15"
android:contentDescription="Settings"
android:scaleType="centerInside" />
</FrameLayout>
@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="#1a1a2e"
android:padding="32dp"
android:gravity="center">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="⚙️ EverShelf Kiosk Settings"
android:textColor="#FFFFFF"
android:textSize="22sp"
android:textStyle="bold"
android:layout_marginBottom="32dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="EverShelf Server URL"
android:textColor="#AAAAAA"
android:textSize="14sp"
android:layout_marginBottom="8dp" />
<EditText
android:id="@+id/editUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="http://192.168.1.100"
android:inputType="textUri"
android:textColor="#FFFFFF"
android:textColorHint="#666666"
android:background="#2a2a3e"
android:padding="14dp"
android:textSize="16sp"
android:singleLine="true"
android:layout_marginBottom="24dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="The app will display this URL in full-screen kiosk mode.\nThe Scale Gateway runs on port 8765 (WebSocket).\nSet the gateway URL in EverShelf settings to:\nws://localhost:8765"
android:textColor="#888888"
android:textSize="13sp"
android:lineSpacingExtra="4dp"
android:layout_marginBottom="32dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSave"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Save &amp; Return"
android:textSize="16sp"
android:backgroundTint="#7C3AED"
android:layout_marginBottom="12dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnBack"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Cancel"
android:textSize="14sp"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:strokeColor="#666666"
android:textColor="#AAAAAA" />
</LinearLayout>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="accent">#7C3AED</color>
<color name="green">#059669</color>
<color name="red">#EF4444</color>
<color name="blue">#1D4ED8</color>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">EverShelf Kiosk</string>
</resources>
+5
View File
@@ -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
}
+2
View File
@@ -0,0 +1,2 @@
android.useAndroidX=true
android.enableJetifier=true
@@ -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
+17
View File
@@ -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")
+16 -9
View File
@@ -20,7 +20,7 @@
<!-- Top Header --> <!-- Top Header -->
<header class="app-header"> <header class="app-header">
<div class="header-content"> <div class="header-content">
<h1 class="header-title" onclick="showPage('dashboard')"><span data-i18n="nav.title">🏠 EverShelf</span><span class="header-version">v1.3.0</span></h1> <h1 class="header-title" onclick="showPage('dashboard')"><span data-i18n="nav.title">🏠 EverShelf</span><span class="header-version">v1.4.0</span></h1>
<div class="header-actions"> <div class="header-actions">
<span id="scale-status-indicator" class="scale-status-indicator scale-status-disconnected" style="display:none" data-i18n-title="scale.status_disconnected" title="⚖️ Bilancia">⚖️</span> <span id="scale-status-indicator" class="scale-status-indicator scale-status-disconnected" style="display:none" data-i18n-title="scale.status_disconnected" title="⚖️ Bilancia">⚖️</span>
<button class="header-scan-btn header-gemini-btn" onclick="showPage('chat')" title="Chat con Gemini" data-i18n-title="chat.title"> <button class="header-scan-btn header-gemini-btn" onclick="showPage('chat')" title="Chat con Gemini" data-i18n-title="chat.title">
@@ -62,6 +62,20 @@
</div> </div>
</div> </div>
<!-- Top notification banner (anomalous qty + consumption predictions) -->
<div id="alert-banner" class="alert-banner" style="display:none">
<div class="alert-banner-inner">
<div class="alert-banner-icon" id="alert-banner-icon">⚠️</div>
<div class="alert-banner-body">
<div class="alert-banner-title" id="alert-banner-title"></div>
<div class="alert-banner-detail" id="alert-banner-detail"></div>
</div>
<button class="alert-banner-close" id="alert-banner-close" onclick="dismissBannerItem()"></button>
</div>
<div class="alert-banner-actions" id="alert-banner-actions"></div>
<div class="alert-banner-counter" id="alert-banner-counter"></div>
</div>
<!-- Quick recipe suggestion --> <!-- Quick recipe suggestion -->
<div class="quick-recipe-bar" id="quick-recipe-bar" style="display:none"> <div class="quick-recipe-bar" id="quick-recipe-bar" style="display:none">
<button class="btn-quick-recipe" onclick="quickRecipeSuggestion()"> <button class="btn-quick-recipe" onclick="quickRecipeSuggestion()">
@@ -95,13 +109,6 @@
<div id="opened-list"></div> <div id="opened-list"></div>
</div> </div>
<!-- Review suspicious quantities -->
<div class="alert-section alert-review" id="alert-review" style="display:none">
<h3 data-i18n="dashboard.review_title">🔍 Da revisionare</h3>
<p class="review-hint" data-i18n="dashboard.review_hint">Quantità che sembrano anomale. Conferma se corrette o modifica.</p>
<div id="review-list"></div>
</div>
</section> </section>
<!-- ===== INVENTORY LIST ===== --> <!-- ===== INVENTORY LIST ===== -->
@@ -1247,6 +1254,6 @@
</div> </div>
</div> </div>
<script src="assets/js/app.js?v=20260415f"></script> <script src="assets/js/app.js?v=20260418a"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "EverShelf", "name": "EverShelf",
"short_name": "EverShelf", "short_name": "EverShelf",
"description": "Gestione completa della dispensa di casa con scansione barcode", "description": "Gestione completa della dispensa di casa con scansione barcode",
"version": "1.2.0", "version": "1.4.0",
"start_url": "/evershelf/", "start_url": "/evershelf/",
"display": "standalone", "display": "standalone",
"background_color": "#f0f4e8", "background_color": "#f0f4e8",
+465 -449
View File
@@ -1,462 +1,478 @@
{ {
"app": { "app": {
"name": "EverShelf", "name": "EverShelf",
"loading": "Laden..." "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.<br>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.\n<small>Füge GEMINI_API_KEY in der .env Datei auf dem Server hinzu.</small>",
"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": { "nav": {
"title": "🛒 Bring! Einkaufsliste", "title": "🏠 EverShelf",
"hint": "Zugangsdaten für die Bring! Einkaufslisten-Integration.", "home": "Home",
"email_label": "📧 Bring! E-Mail", "inventory": "Vorrat",
"password_label": "🔒 Bring! Passwort" "recipes": "Rezepte",
"shopping": "Einkauf",
"log": "Log"
}, },
"recipe": { "btn": {
"title": "🍳 Rezept-Einstellungen", "back": "← Zurück",
"hint": "Konfiguriere die Standardoptionen für die Rezeptgenerierung.", "save": "💾 Speichern",
"persons_label": "👥 Standard-Portionen", "cancel": "✕ Abbrechen",
"options_label": "🎯 Standard-Rezeptoptionen", "close": "Schließen",
"fast": "⚡ Schnelles Gericht", "add": "✅ Hinzufügen",
"light": "🥗 Leichte Mahlzeit", "delete": "Löschen",
"expiry": "⏰ Ablauf-Priorität", "edit": "✏️ Bearbeiten",
"healthy": "💚 Extra Gesund", "search": "🔍 Suchen",
"opened": "📦 Offene Produkte zuerst", "go": "✅ Los",
"zerowaste": " Keine Verschwendung", "toggle_password": "👁 Anzeigen/Ausblenden",
"dietary_label": "🚫 Unverträglichkeiten / Einschränkungen", "load_more": "Mehr laden...",
"dietary_placeholder": "z.B.: glutenfrei, laktosefrei, vegetarisch..." "save_config": "💾 Konfiguration speichern",
"save_product": "💾 Produkt speichern",
"restart": "↺ Neustart",
"reset_default": "↺ Standard wiederherstellen"
}, },
"mealplan": { "locations": {
"title": "📅 Wöchentlicher Essensplan", "dispensa": "Vorratskammer",
"hint": "Lege die Mahlzeitenart für jeden Tag fest. Wird als Leitfaden bei der Rezeptgenerierung verwendet.", "frigo": "Kühlschrank",
"enabled": "✅ Wöchentlichen Essensplan aktivieren", "freezer": "Gefrierschrank",
"legend": "🌤️ = Mittagessen · 🌙 = Abendessen · Tippe auf ein Badge, um es zu ändern.", "altro": "Sonstiges"
"types_title": "📋 Verfügbare Typen"
}, },
"appliances": { "categories": {
"title": "🔌 Verfügbare Geräte", "latticini": "Milchprodukte",
"hint": "Gib an, welche Geräte du hast. Sie werden bei der Rezeptgenerierung berücksichtigt.", "carne": "Fleisch",
"new_placeholder": "z.B.: Brotbackmaschine, Thermomix, Heißluftfritteuse...", "pesce": "Fisch",
"quick_title": "Schnell hinzufügen:", "frutta": "Obst",
"oven": "🔥 Backofen", "verdura": "Gemüse",
"microwave": "📡 Mikrowelle", "pasta": "Pasta & Reis",
"air_fryer": "🍟 Heißluftfritteuse", "pane": "Brot & Backwaren",
"bread_maker": "🍞 Brotbackmaschine", "surgelati": "Tiefkühl",
"bimby": "🤖 Thermomix/Cookeo", "bevande": "Getränke",
"mixer": "🌀 Küchenmaschine", "condimenti": "Gewürze",
"steamer": "♨️ Dampfgarer", "snack": "Snacks & Süßes",
"pressure_cooker": "🫕 Schnellkochtopf", "conserve": "Konserven",
"toaster": "🍞 Toaster", "cereali": "Getreide & Hülsenfrüchte",
"blender": "🍹 Mixer", "igiene": "Hygiene",
"empty": "Keine Geräte hinzugefügt" "pulizia": "Reinigung",
"altro": "Sonstiges",
"select": "-- Auswählen --"
}, },
"spesa": { "units": {
"title": "🛍️ Online-Einkauf", "pz": "Stk",
"hint": "Online-Einkaufsanbieter konfigurieren.", "conf": "Pkg",
"provider_label": "🏪 Anbieter", "g": "g",
"email_label": "📧 E-Mail", "ml": "ml",
"password_label": "🔒 Passwort", "pieces": "Stück",
"login_btn": "🔐 Anmelden", "grams": "Gramm",
"ai_prompt_label": "🤖 KI-Produktauswahl Prompt", "box": "Packung",
"ai_prompt_placeholder": "Anweisungen für die KI bei der Auswahl zwischen mehreren Produkten...", "boxes": "Packungen"
"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": { "shopping_sections": {
"title": "📷 Kamera", "frutta_verdura": "Obst & Gemüse",
"hint": "Wähle die Kamera für Barcode-Scanning und KI-Identifikation.", "carne_pesce": "Fleisch & Fisch",
"device_label": "📸 Standardkamera", "latticini": "Milchprodukte & Frisches",
"back": "📱 Rückkamera (Standard)", "pane_dolci": "Brot & Süßes",
"front": "🤳 Frontkamera", "pasta": "Pasta & Getreide",
"devices_hint": "Bei mehreren Kameras kannst du nach Freigabe der Berechtigungen eine bestimmte aus der Liste oben wählen.", "conserve": "Konserven & Soßen",
"detect_btn": "🔄 Kameras erkennen" "surgelati": "Tiefkühl",
"bevande": "Getränke",
"pulizia_igiene": "Reinigung & Hygiene",
"altro": "Sonstiges"
}, },
"security": { "dashboard": {
"title": "🔒 HTTPS-Zertifikat", "expired_title": "🚫 Abgelaufen",
"hint": "Wenn der Browser den Fehler \"Verbindung nicht sicher\" (ERR_CERT_AUTHORITY_INVALID) zeigt, installiere das CA-Zertifikat auf dem Gerät.", "expiring_title": "⏰ Bald ablaufend",
"download_btn": "📥 CA-Zertifikat herunterladen" "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": { "inventory": {
"title": "🔊 Sprache & TTS", "title": "Vorrat",
"hint": "Sprachsynthese über externe REST-API konfigurieren. Rezeptschritte und abgelaufene Timer werden an den Endpunkt gesendet.", "filter_all": "Alle",
"enabled": "✅ TTS aktivieren", "search_placeholder": "🔍 Produkt suchen...",
"url_label": "🌐 Endpunkt-URL", "empty": "Keine Produkte hier.\nScanne ein Produkt, um es hinzuzufügen!",
"method_label": "📡 HTTP-Methode", "no_items_found": "Keine Bestandseinträge gefunden"
"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": { "scan": {
"title": "🌐 Sprache", "title": "Produkt scannen",
"hint": "Wähle die Sprache der Benutzeroberfläche.", "mode_shopping": "🛒 Einkaufsmodus",
"label": "🌐 Sprache", "mode_shopping_end": "✅ Einkauf beenden",
"restart_notice": "Die Seite wird neu geladen, um die neue Sprache anzuwenden." "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.<br>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.\n<small>Füge GEMINI_API_KEY in der .env Datei auf dem Server hinzu.</small>",
"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": { "scale": {
"title": "⚖️ Smart-Waage", "status_connected": "Waage verbunden",
"hint": "Verbinde eine Bluetooth-Waage über das Android-Gateway, um das Gewicht automatisch auszulesen.", "status_searching": "Gateway verbunden, warte auf Waage…",
"tab": "Smart-Waage", "status_disconnected": "Waagen-Gateway nicht erreichbar",
"enabled": "✅ Smart-Waage aktivieren", "status_error": "Verbindungsfehler zum Gateway",
"url_label": "🌐 WebSocket-Gateway-URL", "not_connected": "Waagen-Gateway nicht verbunden",
"url_placeholder": "ws://192.168.1.x:8765", "read_btn": "⚖️ Von Waage lesen",
"url_hint": "URL der Android-App (gleiches WLAN). z.B.:", "reading_title": "Waage lesen",
"test_btn": "🔗 Verbindung testen", "place_on_scale": "Produkt auf die Waage legen",
"download_btn": "📥 Android-Gateway herunterladen (APK)", "waiting_stable": "Das Gewicht wird automatisch erfasst, wenn die Messung stabil ist.",
"download_hint": "Android-App als Brücke zwischen BLE-Waage und EverShelf.", "no_url": "Gateway-URL eingeben",
"download_sub": "Quellcode: evershelf-scale-gateway/ im Projektstamm" "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!", "prediction": {
"saved_local": "✅ Konfiguration lokal gespeichert", "expected_qty": "Erwartet: {expected} {unit}",
"saved_local_error": "⚠️ Lokal gespeichert, Serverfehler: {error}" "actual_qty": "Aktuell: {actual} {unit}",
}, "check_suggestion": "Überprüfe oder wiege die Restmenge"
"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"
}
}
+465 -449
View File
@@ -1,462 +1,478 @@
{ {
"app": { "app": {
"name": "EverShelf", "name": "EverShelf",
"loading": "Loading..." "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.<br>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.\n<small>Add GEMINI_API_KEY to the .env file on the server.</small>",
"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": { "nav": {
"title": "🛒 Bring! Shopping List", "title": "🏠 EverShelf",
"hint": "Credentials for the Bring! shopping list integration.", "home": "Home",
"email_label": "📧 Bring! Email", "inventory": "Pantry",
"password_label": "🔒 Bring! Password" "recipes": "Recipes",
"shopping": "Shopping",
"log": "Log"
}, },
"recipe": { "btn": {
"title": "🍳 Recipe Preferences", "back": "← Back",
"hint": "Configure the default options for recipe generation.", "save": "💾 Save",
"persons_label": "👥 Default servings", "cancel": "✕ Cancel",
"options_label": "🎯 Default recipe options", "close": "Close",
"fast": "⚡ Quick Meal", "add": "✅ Add",
"light": "🥗 Light Meal", "delete": "Delete",
"expiry": "⏰ Expiry Priority", "edit": "✏️ Edit",
"healthy": "💚 Extra Healthy", "search": "🔍 Search",
"opened": "📦 Open Items Priority", "go": "✅ Go",
"zerowaste": " Zero Waste", "toggle_password": "👁 Show/Hide",
"dietary_label": "🚫 Intolerances / Restrictions", "load_more": "Load more...",
"dietary_placeholder": "E.g.: gluten free, lactose free, vegetarian..." "save_config": "💾 Save Configuration",
"save_product": "💾 Save Product",
"restart": "↺ Restart",
"reset_default": "↺ Reset to default"
}, },
"mealplan": { "locations": {
"title": "📅 Weekly Meal Plan", "dispensa": "Pantry",
"hint": "Set the meal type for each day. It will be used as a guide in recipe generation.", "frigo": "Fridge",
"enabled": "✅ Enable weekly meal plan", "freezer": "Freezer",
"legend": "🌤️ = Lunch · 🌙 = Dinner · Tap a badge to change it.", "altro": "Other"
"types_title": "📋 Available types"
}, },
"appliances": { "categories": {
"title": "🔌 Available Appliances", "latticini": "Dairy",
"hint": "Indicate the appliances you have. They will be considered in recipe generation.", "carne": "Meat",
"new_placeholder": "E.g.: Bread machine, Thermomix, Air fryer...", "pesce": "Fish",
"quick_title": "Quick add:", "frutta": "Fruit",
"oven": "🔥 Oven", "verdura": "Vegetables",
"microwave": "📡 Microwave", "pasta": "Pasta & Rice",
"air_fryer": "🍟 Air fryer", "pane": "Bread & Bakery",
"bread_maker": "🍞 Bread maker", "surgelati": "Frozen",
"bimby": "🤖 Thermomix/Cookeo", "bevande": "Beverages",
"mixer": "🌀 Stand mixer", "condimenti": "Condiments",
"steamer": "♨️ Steamer", "snack": "Snacks & Sweets",
"pressure_cooker": "🫕 Pressure cooker", "conserve": "Canned Goods",
"toaster": "🍞 Toaster", "cereali": "Cereals & Legumes",
"blender": "🍹 Blender", "igiene": "Hygiene",
"empty": "No appliances added" "pulizia": "Household",
"altro": "Other",
"select": "-- Select --"
}, },
"spesa": { "units": {
"title": "🛍️ Online Shopping", "pz": "pcs",
"hint": "Configure the online shopping provider.", "conf": "pkg",
"provider_label": "🏪 Provider", "g": "g",
"email_label": "📧 Email", "ml": "ml",
"password_label": "🔒 Password", "pieces": "Pieces",
"login_btn": "🔐 Login", "grams": "Grams",
"ai_prompt_label": "🤖 AI product selection prompt", "box": "Package",
"ai_prompt_placeholder": "Instructions for AI when choosing between multiple products...", "boxes": "Packages"
"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": { "shopping_sections": {
"title": "📷 Camera", "frutta_verdura": "Fruits & Vegetables",
"hint": "Choose which camera to use for barcode scanning and AI identification.", "carne_pesce": "Meat & Fish",
"device_label": "📸 Default camera", "latticini": "Dairy & Fresh",
"back": "📱 Rear (default)", "pane_dolci": "Bread & Sweets",
"front": "🤳 Front", "pasta": "Pasta & Cereals",
"devices_hint": "If you have multiple cameras, you can select a specific one from the list above after granting permissions.", "conserve": "Canned & Sauces",
"detect_btn": "🔄 Detect cameras" "surgelati": "Frozen",
"bevande": "Beverages",
"pulizia_igiene": "Cleaning & Hygiene",
"altro": "Other"
}, },
"security": { "dashboard": {
"title": "🔒 HTTPS Certificate", "expired_title": "🚫 Expired",
"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.", "expiring_title": "⏰ Expiring Soon",
"download_btn": "📥 Download CA Certificate" "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": { "inventory": {
"title": "🔊 Voice & TTS", "title": "Pantry",
"hint": "Configure text-to-speech via any external REST API. Recipe steps and expired timers will be sent to the configured endpoint.", "filter_all": "All",
"enabled": "✅ Enable TTS", "search_placeholder": "🔍 Search product...",
"url_label": "🌐 Endpoint URL", "empty": "No products here.\nScan a product to add it!",
"method_label": "📡 HTTP Method", "no_items_found": "No inventory items found"
"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": { "scan": {
"title": "🌐 Language", "title": "Scan Product",
"hint": "Select the interface language.", "mode_shopping": "🛒 Shopping Mode",
"label": "🌐 Language", "mode_shopping_end": "✅ End shopping",
"restart_notice": "The page will reload to apply the new language." "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.<br>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.\n<small>Add GEMINI_API_KEY to the .env file on the server.</small>",
"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": { "scale": {
"title": "⚖️ Smart Scale", "status_connected": "Scale connected",
"hint": "Connect a Bluetooth scale via the Android gateway to automatically read weight.", "status_searching": "Gateway connected, waiting for scale…",
"tab": "Smart Scale", "status_disconnected": "Scale gateway unreachable",
"enabled": "✅ Enable smart scale", "status_error": "Gateway connection error",
"url_label": "🌐 WebSocket Gateway URL", "not_connected": "Scale gateway not connected",
"url_placeholder": "ws://192.168.1.x:8765", "read_btn": "⚖️ Read from scale",
"url_hint": "URL shown by the Android app (same Wi-Fi network). E.g.:", "reading_title": "Scale reading",
"test_btn": "🔗 Test connection", "place_on_scale": "Place the product on the scale…",
"download_btn": "📥 Download Android Gateway (APK)", "waiting_stable": "Weight will be captured automatically once the reading is stable.",
"download_hint": "Android app that bridges your BLE scale and EverShelf.", "no_url": "Enter the gateway URL",
"download_sub": "Source: evershelf-scale-gateway/ in the project root" "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!", "prediction": {
"saved_local": "✅ Configuration saved locally", "expected_qty": "Expected: {expected} {unit}",
"saved_local_error": "⚠️ Saved locally, server error: {error}" "actual_qty": "Current: {actual} {unit}",
}, "check_suggestion": "Check or weigh the remaining quantity"
"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"
}
}
+465 -449
View File
@@ -1,462 +1,478 @@
{ {
"app": { "app": {
"name": "EverShelf", "name": "EverShelf",
"loading": "Caricamento..." "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.<br>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.\n<small>Aggiungi GEMINI_API_KEY nel file .env sul server.</small>",
"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": { "nav": {
"title": "🛒 Bring! Shopping List", "title": "🏠 EverShelf",
"hint": "Credenziali per l'integrazione con la lista della spesa Bring!", "home": "Home",
"email_label": "📧 Email Bring!", "inventory": "Dispensa",
"password_label": "🔒 Password Bring!" "recipes": "Ricette",
"shopping": "Spesa",
"log": "Log"
}, },
"recipe": { "btn": {
"title": "🍳 Preferenze Ricette", "back": "← Indietro",
"hint": "Configura le opzioni predefinite per la generazione delle ricette.", "save": "💾 Salva",
"persons_label": "👥 Persone predefinite", "cancel": "✕ Annulla",
"options_label": "🎯 Opzioni ricetta predefinite", "close": "Chiudi",
"fast": "⚡ Pasto Veloce", "add": "✅ Aggiungi",
"light": "🥗 Poca Fame", "delete": "Elimina",
"expiry": "⏰ Priorità Scadenze", "edit": "✏️ Modifica",
"healthy": "💚 Extra Salutare", "search": "🔍 Cerca",
"opened": "📦 Priorità Cose Aperte", "go": "✅ Vai",
"zerowaste": " Zero Sprechi", "toggle_password": "👁 Mostra/Nascondi",
"dietary_label": "🚫 Intolleranze / Restrizioni", "load_more": "Carica altri...",
"dietary_placeholder": "Es: senza glutine, senza lattosio, vegetariano..." "save_config": "💾 Salva Configurazione",
"save_product": "💾 Salva Prodotto",
"restart": "↺ Ricomincia",
"reset_default": "↺ Ripristina default"
}, },
"mealplan": { "locations": {
"title": "📅 Piano Pasti Settimanale", "dispensa": "Dispensa",
"hint": "Imposta la tipologia di pasto per ogni giorno. Sarà usata come guida nella generazione delle ricette.", "frigo": "Frigo",
"enabled": "✅ Attiva piano pasti settimanale", "freezer": "Freezer",
"legend": "🌤️ = Pranzo · 🌙 = Cena · Tocca un badge per cambiarlo.", "altro": "Altro"
"types_title": "📋 Tipologie disponibili"
}, },
"appliances": { "categories": {
"title": "🔌 Elettrodomestici Disponibili", "latticini": "Latticini",
"hint": "Indica gli elettrodomestici che hai a disposizione. Saranno considerati nella generazione delle ricette.", "carne": "Carne",
"new_placeholder": "Es: Macchina del pane, Bimby, Friggitrice ad aria...", "pesce": "Pesce",
"quick_title": "Aggiungi velocemente:", "frutta": "Frutta",
"oven": "🔥 Forno", "verdura": "Verdura",
"microwave": "📡 Microonde", "pasta": "Pasta & Riso",
"air_fryer": "🍟 Friggitrice ad aria", "pane": "Pane & Forno",
"bread_maker": "🍞 Macchina pane", "surgelati": "Surgelati",
"bimby": "🤖 Bimby/Cookeo", "bevande": "Bevande",
"mixer": "🌀 Planetaria", "condimenti": "Condimenti",
"steamer": "♨️ Vaporiera", "snack": "Snack & Dolci",
"pressure_cooker": "🫕 Pentola pressione", "conserve": "Conserve",
"toaster": "🍞 Tostapane", "cereali": "Cereali & Legumi",
"blender": "🍹 Frullatore", "igiene": "Igiene",
"empty": "Nessun elettrodomestico aggiunto" "pulizia": "Pulizia Casa",
"altro": "Altro",
"select": "-- Seleziona --"
}, },
"spesa": { "units": {
"title": "🛍️ Spesa Online", "pz": "pz",
"hint": "Configura il provider per la spesa online.", "conf": "conf",
"provider_label": "🏪 Provider", "g": "g",
"email_label": "📧 Email", "ml": "ml",
"password_label": "🔒 Password", "pieces": "Pezzi",
"login_btn": "🔐 Accedi", "grams": "Grammi",
"ai_prompt_label": "🤖 Prompt AI selezione prodotto", "box": "Confezione",
"ai_prompt_placeholder": "Istruzioni per l'AI quando deve scegliere tra più prodotti...", "boxes": "Confezioni"
"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": { "shopping_sections": {
"title": "📷 Fotocamera", "frutta_verdura": "Frutta & Verdura",
"hint": "Scegli quale fotocamera utilizzare per la scansione barcode e l'identificazione AI.", "carne_pesce": "Carne & Pesce",
"device_label": "📸 Fotocamera predefinita", "latticini": "Latticini & Fresco",
"back": "📱 Posteriore (default)", "pane_dolci": "Pane & Dolci",
"front": "🤳 Anteriore", "pasta": "Pasta & Cereali",
"devices_hint": "Se hai più fotocamere, puoi selezionarne una specifica dall'elenco sopra dopo aver concesso i permessi.", "conserve": "Conserve & Salse",
"detect_btn": "🔄 Rileva fotocamere" "surgelati": "Surgelati",
"bevande": "Bevande",
"pulizia_igiene": "Pulizia & Igiene",
"altro": "Altro"
}, },
"security": { "dashboard": {
"title": "🔒 Certificato HTTPS", "expired_title": "🚫 Scaduti",
"hint": "Se il browser mostra l'errore \"La connessione non è privata\" (ERR_CERT_AUTHORITY_INVALID), devi installare il certificato CA nel dispositivo.", "expiring_title": "⏰ Prossime Scadenze",
"download_btn": "📥 Scarica Certificato CA" "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": { "inventory": {
"title": "🔊 Voce & TTS", "title": "Dispensa",
"hint": "Configura la sintesi vocale tramite qualsiasi API REST esterna. I passi della ricetta e i timer scaduti verranno inviati all'endpoint configurato.", "filter_all": "Tutti",
"enabled": "✅ Attiva TTS", "search_placeholder": "🔍 Cerca prodotto...",
"url_label": "🌐 URL Endpoint", "empty": "Nessun prodotto qui.\nScansiona un prodotto per aggiungerlo!",
"method_label": "📡 Metodo HTTP", "no_items_found": "Nessuna voce di inventario trovata"
"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": { "scan": {
"title": "🌐 Lingua / Language", "title": "Scansiona Prodotto",
"hint": "Seleziona la lingua dell'interfaccia. Select the interface language.", "mode_shopping": "🛒 Modalità Spesa",
"label": "🌐 Lingua", "mode_shopping_end": "✅ Fine spesa",
"restart_notice": "La pagina verrà ricaricata per applicare la nuova lingua." "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.<br>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.\n<small>Aggiungi GEMINI_API_KEY nel file .env sul server.</small>",
"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": { "scale": {
"title": "⚖️ Bilancia Smart", "status_connected": "Bilancia connessa",
"hint": "Collega una bilancia Bluetooth tramite il gateway Android per leggere il peso automaticamente.", "status_searching": "Connesso al gateway, attesa bilancia…",
"tab": "Bilancia Smart", "status_disconnected": "Gateway bilancia non raggiungibile",
"enabled": "✅ Abilita bilancia smart", "status_error": "Errore connessione gateway",
"url_label": "🌐 URL Gateway WebSocket", "not_connected": "Gateway bilancia non connesso",
"url_placeholder": "ws://192.168.1.x:8765", "read_btn": "⚖️ Leggi dalla bilancia",
"url_hint": "URL mostrato dall'app Android (stessa rete Wi-Fi). Es:", "reading_title": "Lettura bilancia",
"test_btn": "🔗 Testa connessione", "place_on_scale": "Metti il prodotto sulla bilancia…",
"download_btn": "📥 Scarica Gateway Android (APK)", "waiting_stable": "Il peso venire rilevato automaticamente quando la lettura sarà stabile.",
"download_hint": "App Android che fa da ponte tra la bilancia BLE e questo sito.", "no_url": "Inserisci l'URL del gateway",
"download_sub": "Sorgente: evershelf-scale-gateway/ nella root del progetto" "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!", "prediction": {
"saved_local": "✅ Configurazione salvata localmente", "expected_qty": "Previsto: {expected} {unit}",
"saved_local_error": "⚠️ Salvato localmente, errore server: {error}" "actual_qty": "Attuale: {actual} {unit}",
}, "check_suggestion": "Verifica o pesa la quantità residua"
"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"
}
}