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:
+276
-23
@@ -189,6 +189,10 @@ try {
|
||||
getStats($db);
|
||||
break;
|
||||
|
||||
case 'consumption_predictions':
|
||||
getConsumptionPredictions($db);
|
||||
break;
|
||||
|
||||
// ===== AI =====
|
||||
case 'gemini_expiry':
|
||||
geminiReadExpiry();
|
||||
@@ -936,6 +940,8 @@ function useFromInventory(PDO $db): void {
|
||||
}
|
||||
|
||||
$newQty = max(0, $existing['quantity'] - $quantity);
|
||||
// Cap actual deducted quantity to what was available (prevent phantom over-deduction)
|
||||
$actualDeducted = min($quantity, $existing['quantity']);
|
||||
|
||||
if ($newQty <= 0) {
|
||||
$stmt = $db->prepare("DELETE FROM inventory WHERE id = ?");
|
||||
@@ -974,10 +980,10 @@ function useFromInventory(PDO $db): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Log transaction
|
||||
// Log transaction (actual amount removed, not requested)
|
||||
$type = ($notes === 'Buttato') ? 'waste' : 'out';
|
||||
$stmt = $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, ?, ?, ?, ?)");
|
||||
$stmt->execute([$productId, $type, $quantity, $location, $notes]);
|
||||
$stmt->execute([$productId, $type, $actualDeducted, $location, $notes]);
|
||||
|
||||
$remaining = $newQty;
|
||||
|
||||
@@ -1269,10 +1275,115 @@ function getStats(PDO $db): void {
|
||||
]);
|
||||
}
|
||||
|
||||
// ===== CONSUMPTION PREDICTIONS =====
|
||||
|
||||
/**
|
||||
* Analyze transaction history to predict expected quantity of each product
|
||||
* and flag items whose current quantity deviates significantly from the prediction.
|
||||
*/
|
||||
function getConsumptionPredictions(PDO $db): void {
|
||||
// Get all current inventory items with their consumption history
|
||||
$items = $db->query("
|
||||
SELECT i.id AS inventory_id, i.product_id, i.quantity, i.location,
|
||||
p.name, p.brand, p.unit, p.default_quantity, p.package_unit,
|
||||
i.updated_at
|
||||
FROM inventory i
|
||||
JOIN products p ON p.id = i.product_id
|
||||
WHERE i.quantity > 0
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$predictions = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
$pid = $item['product_id'];
|
||||
$loc = $item['location'];
|
||||
|
||||
// Get last 90 days of 'out' transactions for this product+location
|
||||
$txns = $db->prepare("
|
||||
SELECT quantity, created_at
|
||||
FROM transactions
|
||||
WHERE product_id = ? AND location = ? AND type = 'out'
|
||||
AND created_at >= datetime('now', '-90 days')
|
||||
ORDER BY created_at ASC
|
||||
");
|
||||
$txns->execute([$pid, $loc]);
|
||||
$rows = $txns->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (count($rows) < 3) continue; // Need at least 3 data points
|
||||
|
||||
// Calculate average daily consumption
|
||||
$totalUsed = 0;
|
||||
foreach ($rows as $r) $totalUsed += abs(floatval($r['quantity']));
|
||||
|
||||
$firstDate = strtotime($rows[0]['created_at']);
|
||||
$lastDate = strtotime($rows[count($rows) - 1]['created_at']);
|
||||
$daySpan = max(1, ($lastDate - $firstDate) / 86400);
|
||||
$dailyRate = $totalUsed / $daySpan;
|
||||
|
||||
if ($dailyRate < 0.01) continue; // negligible consumption
|
||||
|
||||
// Get the most recent restock (last 'in' transaction)
|
||||
$lastIn = $db->prepare("
|
||||
SELECT quantity, created_at
|
||||
FROM transactions
|
||||
WHERE product_id = ? AND location = ? AND type = 'in'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
");
|
||||
$lastIn->execute([$pid, $loc]);
|
||||
$restock = $lastIn->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$restock) continue;
|
||||
|
||||
$restockDate = strtotime($restock['created_at']);
|
||||
$restockQty = floatval($restock['quantity']);
|
||||
$daysSinceRestock = max(1, (time() - $restockDate) / 86400);
|
||||
|
||||
// Predicted remaining qty = restock qty - (daily rate * days since restock)
|
||||
$expectedQty = max(0, $restockQty - ($dailyRate * $daysSinceRestock));
|
||||
$actualQty = floatval($item['quantity']);
|
||||
|
||||
// Flag if deviation > 30% and absolute diff > meaningful threshold
|
||||
$deviation = abs($actualQty - $expectedQty);
|
||||
$threshold = max($dailyRate * 3, 0.5); // at least 3 days worth or 0.5 units
|
||||
$pctDev = $expectedQty > 0 ? ($deviation / $expectedQty) : ($actualQty > 0 ? 1 : 0);
|
||||
|
||||
if ($pctDev > 0.30 && $deviation > $threshold) {
|
||||
$unit = $item['unit'];
|
||||
// Format expected/actual in human units
|
||||
if ($unit === 'conf' && $item['default_quantity'] > 0 && $item['package_unit']) {
|
||||
$pu = $item['package_unit'];
|
||||
$sz = floatval($item['default_quantity']);
|
||||
$expDisplay = round($expectedQty * $sz);
|
||||
$actDisplay = round($actualQty * $sz);
|
||||
$displayUnit = $pu;
|
||||
} else {
|
||||
$expDisplay = round($expectedQty, 1);
|
||||
$actDisplay = round($actualQty, 1);
|
||||
$displayUnit = $unit;
|
||||
}
|
||||
|
||||
$predictions[] = [
|
||||
'inventory_id' => (int)$item['inventory_id'],
|
||||
'product_id' => (int)$item['product_id'],
|
||||
'name' => $item['name'],
|
||||
'brand' => $item['brand'],
|
||||
'location' => $item['location'],
|
||||
'unit' => $displayUnit,
|
||||
'expected_qty' => $expDisplay,
|
||||
'actual_qty' => $actDisplay,
|
||||
'daily_rate' => round($dailyRate, 3),
|
||||
'deviation_pct'=> round($pctDev * 100),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'predictions' => $predictions]);
|
||||
}
|
||||
|
||||
// ===== SETTINGS =====
|
||||
|
||||
function getServerSettings(): void {
|
||||
// Return values for client — passwords are never exposed
|
||||
$geminiKey = env('GEMINI_API_KEY');
|
||||
$bringEmail = env('BRING_EMAIL');
|
||||
|
||||
@@ -1288,6 +1399,22 @@ function getServerSettings(): void {
|
||||
'tts_content_type' => env('TTS_CONTENT_TYPE', 'application/json'),
|
||||
'tts_payload_key' => env('TTS_PAYLOAD_KEY', 'message'),
|
||||
'tts_enabled' => env('TTS_ENABLED', 'false') === 'true',
|
||||
// User preferences (now server-side)
|
||||
'default_persons' => intval(env('DEFAULT_PERSONS', '1')),
|
||||
'pref_veloce' => env('PREF_VELOCE', 'false') === 'true',
|
||||
'pref_pocafame' => env('PREF_POCAFAME', 'false') === 'true',
|
||||
'pref_scadenze' => env('PREF_SCADENZE', 'false') === 'true',
|
||||
'pref_healthy' => env('PREF_HEALTHY', 'false') === 'true',
|
||||
'pref_opened' => env('PREF_OPENED', 'false') === 'true',
|
||||
'pref_zerowaste' => env('PREF_ZEROWASTE', 'false') === 'true',
|
||||
'dietary' => env('DIETARY', ''),
|
||||
'appliances' => env('APPLIANCES', '') ? explode(',', env('APPLIANCES', '')) : [],
|
||||
'camera_facing' => env('CAMERA_FACING', 'environment'),
|
||||
'scale_enabled' => env('SCALE_ENABLED', 'false') === 'true',
|
||||
'scale_gateway_url' => env('SCALE_GATEWAY_URL', ''),
|
||||
'spesa_provider' => env('SPESA_PROVIDER', 'bring'),
|
||||
'spesa_ai_prompt' => env('SPESA_AI_PROMPT', ''),
|
||||
'meal_plan_enabled' => env('MEAL_PLAN_ENABLED', 'false') === 'true',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1296,15 +1423,58 @@ function saveSettings(): void {
|
||||
$envFile = __DIR__ . '/../.env';
|
||||
$envVars = loadEnv();
|
||||
|
||||
// Update values from input — only overwrite if new value is non-empty
|
||||
if (!empty($input['gemini_key'])) {
|
||||
$envVars['GEMINI_API_KEY'] = $input['gemini_key'];
|
||||
// Map of input key → .env key — only update if present in input
|
||||
$keyMap = [
|
||||
'gemini_key' => 'GEMINI_API_KEY',
|
||||
'bring_email' => 'BRING_EMAIL',
|
||||
'bring_password' => 'BRING_PASSWORD',
|
||||
'tts_url' => 'TTS_URL',
|
||||
'tts_token' => 'TTS_TOKEN',
|
||||
'tts_method' => 'TTS_METHOD',
|
||||
'tts_auth_type' => 'TTS_AUTH_TYPE',
|
||||
'tts_content_type'=> 'TTS_CONTENT_TYPE',
|
||||
'tts_payload_key' => 'TTS_PAYLOAD_KEY',
|
||||
'camera_facing' => 'CAMERA_FACING',
|
||||
'dietary' => 'DIETARY',
|
||||
'scale_gateway_url' => 'SCALE_GATEWAY_URL',
|
||||
'spesa_provider' => 'SPESA_PROVIDER',
|
||||
'spesa_ai_prompt' => 'SPESA_AI_PROMPT',
|
||||
];
|
||||
// Boolean keys
|
||||
$boolMap = [
|
||||
'tts_enabled' => 'TTS_ENABLED',
|
||||
'pref_veloce' => 'PREF_VELOCE',
|
||||
'pref_pocafame' => 'PREF_POCAFAME',
|
||||
'pref_scadenze' => 'PREF_SCADENZE',
|
||||
'pref_healthy' => 'PREF_HEALTHY',
|
||||
'pref_opened' => 'PREF_OPENED',
|
||||
'pref_zerowaste' => 'PREF_ZEROWASTE',
|
||||
'scale_enabled' => 'SCALE_ENABLED',
|
||||
'meal_plan_enabled' => 'MEAL_PLAN_ENABLED',
|
||||
];
|
||||
// Integer keys
|
||||
$intMap = [
|
||||
'default_persons' => 'DEFAULT_PERSONS',
|
||||
];
|
||||
|
||||
foreach ($keyMap as $inKey => $envKey) {
|
||||
if (array_key_exists($inKey, $input)) {
|
||||
$envVars[$envKey] = (string)$input[$inKey];
|
||||
}
|
||||
}
|
||||
if (!empty($input['bring_email'])) {
|
||||
$envVars['BRING_EMAIL'] = $input['bring_email'];
|
||||
foreach ($boolMap as $inKey => $envKey) {
|
||||
if (array_key_exists($inKey, $input)) {
|
||||
$envVars[$envKey] = $input[$inKey] ? 'true' : 'false';
|
||||
}
|
||||
}
|
||||
if (!empty($input['bring_password'])) {
|
||||
$envVars['BRING_PASSWORD'] = $input['bring_password'];
|
||||
foreach ($intMap as $inKey => $envKey) {
|
||||
if (array_key_exists($inKey, $input)) {
|
||||
$envVars[$envKey] = (string)intval($input[$inKey]);
|
||||
}
|
||||
}
|
||||
// Arrays stored as comma-separated
|
||||
if (array_key_exists('appliances', $input)) {
|
||||
$envVars['APPLIANCES'] = is_array($input['appliances']) ? implode(',', $input['appliances']) : (string)$input['appliances'];
|
||||
}
|
||||
|
||||
// Write .env file
|
||||
@@ -1314,6 +1484,10 @@ function saveSettings(): void {
|
||||
}
|
||||
$result = file_put_contents($envFile, implode("\n", $lines) . "\n");
|
||||
|
||||
// Clear cached env
|
||||
static $cache = null;
|
||||
$cache = null;
|
||||
|
||||
if ($result !== false) {
|
||||
echo json_encode(['success' => true]);
|
||||
} else {
|
||||
@@ -1579,6 +1753,7 @@ function generateRecipe(PDO $db): void {
|
||||
$todayRecipes = $input['today_recipes'] ?? [];
|
||||
$mealPlanType = $input['meal_plan_type'] ?? ''; // e.g. 'pasta', 'pesce', 'legumi', ...
|
||||
$variation = max(0, intval($input['variation'] ?? 0)); // 0=first attempt, 1+=re-generation
|
||||
$rejectedIngredients = $input['rejected_ingredients'] ?? []; // ingredient names from previous rejected recipes
|
||||
|
||||
// Fetch all inventory items with expiry info
|
||||
$stmt = $db->query("
|
||||
@@ -1689,14 +1864,16 @@ function generateRecipe(PDO $db): void {
|
||||
$label = $item['name'] . ($item['brand'] ? " ({$item['brand']})" : '') . $openNote . $expiryNote;
|
||||
|
||||
if ($wantsExpiryPriority) {
|
||||
if ($g === 1 || ($g === 2 && $daysLeft <= 1)) {
|
||||
// Expired or expiring within 3 days → mandatory
|
||||
if ($g === 1 || $g === 2) {
|
||||
$mandatoryItems[] = $label;
|
||||
} elseif ($g === 2) {
|
||||
// Expiring within 7 days → strongly recommended
|
||||
} elseif ($g === 3) {
|
||||
$recommendedItems[] = $label;
|
||||
}
|
||||
}
|
||||
if (($wantsOpenedPriority || $wantsExpiryPriority) && $isOpen && $daysLeft <= 5 && $daysLeft >= 0) {
|
||||
// Opened items expiring within 5 days but not already in mandatory/recommended
|
||||
if (($wantsOpenedPriority || $wantsExpiryPriority) && $isOpen && $daysLeft <= 7 && $daysLeft >= 0) {
|
||||
// Opened items expiring within 7 days
|
||||
if (!in_array($label, $mandatoryItems) && !in_array($label, $recommendedItems)) {
|
||||
$recommendedItems[] = $label;
|
||||
}
|
||||
@@ -1870,6 +2047,13 @@ function generateRecipe(PDO $db): void {
|
||||
"Devi proporre qualcosa di COMPLETAMENTE DIVERSO: stile di cucina diverso, ingrediente principale diverso, " .
|
||||
"tecnica di cottura diversa, piatto di un'altra tradizione culinaria o di un'altra categoria. " .
|
||||
"Non basta cambiare il nome della stessa idea. Sorprendi! Sii creativo!";
|
||||
if (!empty($rejectedIngredients)) {
|
||||
$rejList = implode(', ', array_map(fn($n) => '"' . $n . '"', $rejectedIngredients));
|
||||
$regenText .= "\n\n🚫 INGREDIENTI PRINCIPALI GIÀ RIFIUTATI DALL'UTENTE: {$rejList}\n" .
|
||||
"NON usare NESSUNO di questi come ingrediente PRINCIPALE della nuova ricetta. " .
|
||||
"Puoi usarli come ingrediente secondario solo se indispensabile. " .
|
||||
"Scegli ingredienti principali completamente diversi dalla lista della dispensa!";
|
||||
}
|
||||
}
|
||||
|
||||
$prompt = <<<PROMPT
|
||||
@@ -1894,6 +2078,8 @@ REGOLE IMPORTANTI:
|
||||
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.
|
||||
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:
|
||||
$ingredientsText
|
||||
@@ -1966,14 +2152,49 @@ PROMPT;
|
||||
if ($recipe && !empty($recipe['title'])) {
|
||||
// Enrich from_pantry ingredients with product_id and location for "use" feature
|
||||
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) {
|
||||
if (!empty($ing['from_pantry'])) {
|
||||
$ingNameLower = mb_strtolower(trim($ing['name']), 'UTF-8');
|
||||
$ingWords = preg_split('/[\s,.\-\/]+/', $ingNameLower);
|
||||
$bestMatch = null;
|
||||
$bestScore = 0;
|
||||
|
||||
foreach ($items as $item) {
|
||||
$itemNameLower = mb_strtolower(trim($item['name']), 'UTF-8');
|
||||
foreach ($itemsLookup as $entry) {
|
||||
$itemNameLower = $entry['lower'];
|
||||
$itemWords = $entry['words'];
|
||||
$score = 0;
|
||||
|
||||
// Exact match
|
||||
@@ -1988,19 +2209,51 @@ PROMPT;
|
||||
elseif (mb_strpos($ingNameLower, $itemNameLower) !== false) {
|
||||
$score = 70;
|
||||
}
|
||||
// Word-level matching: check if key words overlap
|
||||
else {
|
||||
$ingWords = preg_split('/\s+/', $ingNameLower);
|
||||
$itemWords = preg_split('/\s+/', $itemNameLower);
|
||||
$common = array_intersect($ingWords, $itemWords);
|
||||
if (count($common) > 0) {
|
||||
$score = (count($common) / max(count($ingWords), 1)) * 60;
|
||||
// Word-level matching with alias expansion
|
||||
$expandedIngWords = $ingWords;
|
||||
foreach ($ingWords as $w) {
|
||||
foreach ($aliases as $key => $group) {
|
||||
if (in_array($w, $group) || mb_strpos($w, $key) === 0 || mb_strpos($key, $w) === 0) {
|
||||
$expandedIngWords = array_merge($expandedIngWords, $group);
|
||||
}
|
||||
}
|
||||
}
|
||||
$expandedIngWords = array_unique($expandedIngWords);
|
||||
|
||||
$common = 0;
|
||||
foreach ($expandedIngWords as $ew) {
|
||||
foreach ($itemWords as $iw) {
|
||||
// Partial stem match (min 4 chars shared prefix)
|
||||
$minLen = min(mb_strlen($ew), mb_strlen($iw));
|
||||
if ($minLen >= 3) {
|
||||
$prefixLen = 0;
|
||||
for ($c = 0; $c < $minLen; $c++) {
|
||||
if (mb_substr($ew, $c, 1) === mb_substr($iw, $c, 1)) $prefixLen++;
|
||||
else break;
|
||||
}
|
||||
if ($prefixLen >= min(4, $minLen)) { $common++; break; }
|
||||
}
|
||||
if ($ew === $iw) { $common++; break; }
|
||||
}
|
||||
}
|
||||
if ($common > 0) {
|
||||
$score = ($common / max(count($ingWords), 1)) * 65;
|
||||
// Bonus: if the main/first ingredient word matches
|
||||
if (count($ingWords) > 0 && $common > 0) {
|
||||
foreach ($itemWords as $iw) {
|
||||
if (mb_strpos($iw, $ingWords[0]) === 0 || mb_strpos($ingWords[0], $iw) === 0) {
|
||||
$score += 10;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($score > $bestScore) {
|
||||
$bestScore = $score;
|
||||
$bestMatch = $item;
|
||||
$bestMatch = $entry['item'];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+109
-10
@@ -879,24 +879,19 @@ body {
|
||||
}
|
||||
.scale-live-box.scale-low-weight {
|
||||
border-color: #dc2626;
|
||||
background: #fef2f2;
|
||||
animation: scaleLowWeightBlink 0.8s ease-in-out infinite alternate;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.scale-live-box.scale-low-weight {
|
||||
background: #3b0000;
|
||||
}
|
||||
}
|
||||
.scale-low-weight .scale-live-val {
|
||||
color: #dc2626 !important;
|
||||
animation: scaleLowTextBlink 0.65s ease-in-out infinite alternate;
|
||||
}
|
||||
.scale-low-weight .scale-live-label {
|
||||
color: #dc2626 !important;
|
||||
font-weight: 600;
|
||||
animation: scaleLowTextBlink 0.65s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes scaleLowWeightBlink {
|
||||
from { border-color: #dc2626; box-shadow: none; }
|
||||
to { border-color: #dc2626; box-shadow: 0 0 0 3px rgba(220,38,38,0.25); }
|
||||
@keyframes scaleLowTextBlink {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0.2; }
|
||||
}
|
||||
|
||||
.btn-accent {
|
||||
@@ -4421,6 +4416,110 @@ body {
|
||||
}
|
||||
|
||||
/* ===== REVIEW SECTION ===== */
|
||||
/* ===== ALERT TOP BANNER ===== */
|
||||
.alert-banner {
|
||||
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
|
||||
border: 1.5px solid #f59e0b;
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
animation: bannerSlideIn 0.35s ease-out;
|
||||
}
|
||||
@keyframes bannerSlideIn {
|
||||
from { opacity: 0; transform: translateY(-12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.alert-banner.banner-prediction {
|
||||
background: linear-gradient(135deg, #ede9fe 0%, #ddd6fe 100%);
|
||||
border-color: #8b5cf6;
|
||||
}
|
||||
.alert-banner-inner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 12px 8px;
|
||||
}
|
||||
.alert-banner-icon {
|
||||
font-size: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
.alert-banner-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.alert-banner-title {
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
color: #92400e;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.banner-prediction .alert-banner-title {
|
||||
color: #5b21b6;
|
||||
}
|
||||
.alert-banner-detail {
|
||||
font-size: 0.82rem;
|
||||
color: #78716c;
|
||||
margin-top: 2px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.alert-banner-close {
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(0,0,0,0.08);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #78716c;
|
||||
}
|
||||
.alert-banner-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 0 12px 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.alert-banner-actions .btn-banner {
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
.btn-banner-ok {
|
||||
background: #d1fae5;
|
||||
color: #059669;
|
||||
}
|
||||
.btn-banner-edit {
|
||||
background: #e0e7ff;
|
||||
color: #4338ca;
|
||||
}
|
||||
.btn-banner-weigh {
|
||||
background: #f3e8ff;
|
||||
color: #7c3aed;
|
||||
}
|
||||
.btn-banner-confirm {
|
||||
background: #d1fae5;
|
||||
color: #059669;
|
||||
}
|
||||
.alert-banner-counter {
|
||||
font-size: 0.72rem;
|
||||
color: #a1977a;
|
||||
text-align: center;
|
||||
padding: 0 12px 8px;
|
||||
}
|
||||
.banner-prediction .alert-banner-counter {
|
||||
color: #7c6cb0;
|
||||
}
|
||||
|
||||
.alert-review {
|
||||
background: #fffbeb;
|
||||
border-color: #f59e0b;
|
||||
|
||||
+333
-110
@@ -70,7 +70,7 @@ let _scaleWeightCallback = null; // pending on-demand weight request callback
|
||||
let _scaleLatestWeight = null; // last received weight message
|
||||
let _scaleAutoConfirmTimer = null; // countdown timer for auto-confirm after stable weight
|
||||
let _scaleAutoConfirmRAF = null; // rAF handle for auto-confirm progress bar animation
|
||||
let _scaleStabilityTimer = null; // setTimeout: wait 5 s stable before starting confirm bar
|
||||
let _scaleStabilityTimer = null; // setTimeout: wait 10 s stable before starting confirm bar
|
||||
let _scaleStabilityRAF = null; // rAF handle for stability progress bar in the live box
|
||||
let _scaleStabilityVal = null; // value we are currently timing for stability
|
||||
let _scaleUserDismissed = false; // user tapped or edited → don't retrigger for same value
|
||||
@@ -120,6 +120,9 @@ function _scaleOnMessage(msg) {
|
||||
// Update live reading modal overlay if visible (scale-read modal)
|
||||
const live = document.getElementById('scale-reading-live');
|
||||
if (live) live.textContent = `${msg.value} ${msg.unit || 'kg'}${msg.stable ? ' ✓' : ' …'}`;
|
||||
// Also update edit-form inline scale reading if visible
|
||||
const editLive = document.getElementById('edit-scale-reading');
|
||||
if (editLive) editLive.textContent = `${msg.value} ${msg.unit || 'kg'}${msg.stable ? ' ✓' : ' …'}`;
|
||||
// Always update the persistent live box on the use page (every message, stable or not)
|
||||
_scaleUpdateLiveBox(msg);
|
||||
// If weight is NOT stable: stop any running timer/bar but keep the sentinel value.
|
||||
@@ -204,7 +207,7 @@ function _scaleUpdateLiveBox(msg) {
|
||||
// Weight too low — show red flashing warning
|
||||
box.classList.add('scale-low-weight');
|
||||
if (valEl) valEl.textContent = `${raw} ${msg.unit || 'kg'}`;
|
||||
if (lblEl) lblEl.textContent = '< 10 g · inserisci manualmente';
|
||||
if (lblEl) lblEl.textContent = t('scale.low_weight');
|
||||
} else {
|
||||
box.classList.remove('scale-low-weight');
|
||||
const stIcon = msg.stable ? ' ✓' : ' …';
|
||||
@@ -436,12 +439,12 @@ function _cancelScaleStabilityWait() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a 5-second stability wait with an animated progress bar in the live box.
|
||||
* Calls onStable() when weight unchanged for 5 s.
|
||||
* Start a 10-second stability wait with an animated progress bar in the live box.
|
||||
* Calls onStable() when weight unchanged for 10 s.
|
||||
*/
|
||||
function _startScaleStabilityWait(onStable) {
|
||||
_cancelScaleStabilityWait();
|
||||
const duration = 5000;
|
||||
const duration = 10000;
|
||||
const start = performance.now();
|
||||
const bar = document.getElementById('scale-live-progress-bar');
|
||||
|
||||
@@ -535,6 +538,50 @@ function readScaleWeight(targetInputId, getUnit) {
|
||||
// Weight data streams continuously via SSE; _scaleWeightCallback fires on the next stable reading
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline scale reading for the edit-inventory modal.
|
||||
* Shows a live weight display inside the form and fills edit-qty on stable reading.
|
||||
*/
|
||||
function readScaleForEdit() {
|
||||
if (!_scaleConnected) { showToast('⚖️ ' + t('scale.not_connected'), 'error'); return; }
|
||||
const section = document.getElementById('edit-scale-section');
|
||||
const btn = document.getElementById('btn-scale-edit');
|
||||
if (section) section.style.display = '';
|
||||
if (btn) btn.style.display = 'none';
|
||||
|
||||
_scaleWeightCallback = (msg) => {
|
||||
const editQty = document.getElementById('edit-qty');
|
||||
const editUnit = document.getElementById('edit-unit');
|
||||
if (!editQty || !editUnit) return;
|
||||
|
||||
let unit = editUnit.value;
|
||||
const isConf = unit === 'conf';
|
||||
let confSize = 0;
|
||||
if (isConf) confSize = parseFloat(document.getElementById('edit-conf-size')?.value) || 0;
|
||||
|
||||
let raw = parseFloat(msg.value);
|
||||
const srcUnit = (msg.unit || 'kg').toLowerCase();
|
||||
let grams;
|
||||
if (srcUnit === 'kg') grams = raw * 1000;
|
||||
else if (srcUnit === 'lbs' || srcUnit === 'lb') grams = raw * 453.592;
|
||||
else if (srcUnit === 'oz') grams = raw * 28.3495;
|
||||
else grams = raw; // g or ml
|
||||
|
||||
let val;
|
||||
if (isConf && confSize > 0) {
|
||||
val = Math.round((grams / confSize) * 100) / 100;
|
||||
} else {
|
||||
val = Math.round(grams);
|
||||
}
|
||||
|
||||
editQty.value = val;
|
||||
editQty.dispatchEvent(new Event('input'));
|
||||
if (section) section.style.display = 'none';
|
||||
if (btn) btn.style.display = '';
|
||||
showToast(`⚖️ ${val} ${unit}`, 'success');
|
||||
};
|
||||
}
|
||||
|
||||
function _scaleShowReadingModal(targetInputId, unit) {
|
||||
document.getElementById('modal-content').innerHTML = `
|
||||
<div class="modal-header">
|
||||
@@ -1388,16 +1435,29 @@ function debounce(fn, ms) {
|
||||
|
||||
async function syncSettingsFromDB() {
|
||||
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');
|
||||
if (res.success && res.settings) {
|
||||
// Spesa credentials still come from DB (not .env)
|
||||
if (res.settings.user_prefs) {
|
||||
const db = res.settings.user_prefs;
|
||||
const s = getSettings();
|
||||
// Merge DB settings into local (DB wins for shared prefs)
|
||||
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']) {
|
||||
for (const key of ['spesa_email','spesa_password','spesa_logged_in',
|
||||
'spesa_user','spesa_data','spesa_token']) {
|
||||
if (db[key] !== undefined) s[key] = db[key];
|
||||
}
|
||||
_settingsCache = s;
|
||||
@@ -1489,31 +1549,56 @@ async function loadSettingsUI() {
|
||||
const ttsExtraEl = document.getElementById('setting-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 {
|
||||
const serverSettings = await api('get_settings');
|
||||
if (!s.gemini_key && serverSettings.gemini_key) {
|
||||
document.getElementById('setting-gemini-key').value = serverSettings.gemini_key;
|
||||
// Merge all server settings into local cache (server wins)
|
||||
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) {
|
||||
document.getElementById('setting-bring-email').value = serverSettings.bring_email;
|
||||
if (changed) {
|
||||
_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
|
||||
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 */ }
|
||||
} catch(e) { /* offline, use local */ }
|
||||
// Scale settings
|
||||
const scaleEnabledUiEl = document.getElementById('setting-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();
|
||||
saveSettingsToStorage(s);
|
||||
|
||||
// Also save to server .env
|
||||
// Save ALL settings to server .env
|
||||
try {
|
||||
const result = await api('save_settings', {}, 'POST', {
|
||||
gemini_key: s.gemini_key,
|
||||
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');
|
||||
if (result.success) {
|
||||
@@ -1871,8 +1978,8 @@ async function loadDashboard() {
|
||||
expiredSection.style.display = 'none';
|
||||
}
|
||||
|
||||
// Review suspicious quantities
|
||||
loadReviewItems();
|
||||
// Banner alerts (suspicious quantities + consumption predictions)
|
||||
loadBannerAlerts();
|
||||
|
||||
// Waste vs consumption chart
|
||||
const wasteSection = document.getElementById('waste-chart-section');
|
||||
@@ -2007,7 +2114,7 @@ function quickRecipeSuggestion() {
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// === SUSPICIOUS QUANTITY REVIEW ===
|
||||
// === SUSPICIOUS QUANTITY THRESHOLDS ===
|
||||
const QTY_THRESHOLDS = {
|
||||
'pz': { min: 0.3, max: 50 },
|
||||
'conf': { min: 0.3, max: 50 },
|
||||
@@ -2018,17 +2125,16 @@ const QTY_THRESHOLDS = {
|
||||
function isSuspiciousQty(qty, unit) {
|
||||
const n = parseFloat(qty);
|
||||
if (isNaN(n) || n <= 0) return false;
|
||||
const t = QTY_THRESHOLDS[unit] || QTY_THRESHOLDS['pz'];
|
||||
return n < t.min || n > t.max;
|
||||
const th = QTY_THRESHOLDS[unit] || QTY_THRESHOLDS['pz'];
|
||||
return n < th.min || n > th.max;
|
||||
}
|
||||
|
||||
function isSuspiciousDefaultQty(defaultQty, unit, packageUnit) {
|
||||
const n = parseFloat(defaultQty);
|
||||
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 t = QTY_THRESHOLDS[checkUnit] || QTY_THRESHOLDS['pz'];
|
||||
return n > t.max;
|
||||
const th = QTY_THRESHOLDS[checkUnit] || QTY_THRESHOLDS['pz'];
|
||||
return n > th.max;
|
||||
}
|
||||
|
||||
function getReviewConfirmed() {
|
||||
@@ -2040,87 +2146,170 @@ function setReviewConfirmed(inventoryId) {
|
||||
const c = getReviewConfirmed();
|
||||
c[inventoryId] = Date.now();
|
||||
_reviewConfirmedCache = c;
|
||||
// Persist to shared DB
|
||||
api('app_settings_save', {}, 'POST', { settings: { review_confirmed: c } }).catch(() => {});
|
||||
}
|
||||
|
||||
async function loadReviewItems() {
|
||||
const section = document.getElementById('alert-review');
|
||||
const list = document.getElementById('review-list');
|
||||
// === ALERT BANNER SYSTEM (replaces old review table) ===
|
||||
let _bannerQueue = []; // array of { type, data } — 'review' or 'prediction'
|
||||
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 {
|
||||
const data = await api('inventory_list');
|
||||
const items = data.inventory || [];
|
||||
const [invData, predData] = await Promise.all([
|
||||
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 suspicious = items.filter(item => {
|
||||
if (confirmed[item.id]) return false;
|
||||
return isSuspiciousQty(item.quantity, item.unit) || isSuspiciousDefaultQty(item.default_quantity, item.unit, item.package_unit);
|
||||
|
||||
// 1. Suspicious quantities
|
||||
items.forEach(item => {
|
||||
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) {
|
||||
section.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
section.style.display = 'block';
|
||||
list.innerHTML = suspicious.map(item => {
|
||||
const catIcon = CATEGORY_ICONS[mapToLocalCategory(item.category, item.name)] || '📦';
|
||||
const qtyDisplay = formatQuantity(item.quantity, item.unit, item.default_quantity, item.package_unit);
|
||||
const locInfo = LOCATIONS[item.location] || { icon: '📦', label: item.location };
|
||||
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';
|
||||
|
||||
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';
|
||||
|
||||
// 2. Consumption predictions that don't match actual quantity
|
||||
const predictions = predData.predictions || [];
|
||||
predictions.forEach(pred => {
|
||||
if (confirmed['pred_' + pred.inventory_id]) return;
|
||||
_bannerQueue.push({ type: 'prediction', data: pred });
|
||||
});
|
||||
|
||||
console.log(`[Banner] queue ready: ${_bannerQueue.length} items (${items.length} inv, ${predictions.length} pred, ${Object.keys(confirmed).length} confirmed)`);
|
||||
|
||||
} catch (e) {
|
||||
console.error('[Banner] loadBannerAlerts error:', e);
|
||||
}
|
||||
|
||||
if (_bannerQueue.length > 0) {
|
||||
_bannerIndex = 0;
|
||||
renderBannerItem();
|
||||
} else {
|
||||
banner.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function confirmReviewItem(inventoryId) {
|
||||
setReviewConfirmed(inventoryId);
|
||||
const el = document.getElementById(`review-item-${inventoryId}`);
|
||||
if (el) {
|
||||
el.style.transition = 'opacity 0.3s, transform 0.3s';
|
||||
el.style.opacity = '0';
|
||||
el.style.transform = 'translateX(60px)';
|
||||
setTimeout(() => {
|
||||
el.remove();
|
||||
// Hide section if empty
|
||||
const list = document.getElementById('review-list');
|
||||
if (!list.children.length) {
|
||||
document.getElementById('alert-review').style.display = 'none';
|
||||
}
|
||||
}, 300);
|
||||
function renderBannerItem() {
|
||||
const banner = document.getElementById('alert-banner');
|
||||
if (!banner || _bannerQueue.length === 0) { if (banner) banner.style.display = 'none'; return; }
|
||||
if (_bannerIndex >= _bannerQueue.length) _bannerIndex = 0;
|
||||
|
||||
const entry = _bannerQueue[_bannerIndex];
|
||||
const iconEl = document.getElementById('alert-banner-icon');
|
||||
const titleEl = document.getElementById('alert-banner-title');
|
||||
const detailEl = document.getElementById('alert-banner-detail');
|
||||
const actionsEl = document.getElementById('alert-banner-actions');
|
||||
const counterEl = document.getElementById('alert-banner-counter');
|
||||
const s = getSettings();
|
||||
const hasScale = s.scale_enabled && s.scale_gateway_url && _scaleConnected;
|
||||
|
||||
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');
|
||||
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) {
|
||||
api('inventory_list').then(data => {
|
||||
currentInventory = data.inventory || [];
|
||||
showItemDetail(inventoryId, productId);
|
||||
editInventoryItem(inventoryId);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2468,6 +2657,7 @@ function closeModal() {
|
||||
_cancelScaleAutoConfirm(false);
|
||||
_scaleRecipeAutoFillPaused = false;
|
||||
_scaleUserDismissed = false;
|
||||
_scaleWeightCallback = null;
|
||||
}
|
||||
|
||||
async function quickUse(productId, location) {
|
||||
@@ -2540,6 +2730,12 @@ function editInventoryItem(id) {
|
||||
const confSizeVal = (isConf && item.default_quantity > 0) ? item.default_quantity : '';
|
||||
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 };
|
||||
|
||||
// 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">
|
||||
<button type="button" class="qty-btn" onclick="adjustQty('edit-qty', 1)">+</button>
|
||||
</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 class="form-group">
|
||||
<label>📏 Unità di misura</label>
|
||||
@@ -4723,11 +4927,14 @@ async function submitAdd(e) {
|
||||
}
|
||||
|
||||
// ===== USE FROM INVENTORY =====
|
||||
let _useSubmitting = false; // double-submit guard
|
||||
function showUseForm() {
|
||||
renderUsePreview();
|
||||
_useConfMode = null; // reset
|
||||
_useSubmitting = false;
|
||||
_scaleUserDismissed = false;
|
||||
_scaleStabilityVal = null;
|
||||
_scaleLatestWeight = null; // clear stale weight from previous product
|
||||
_cancelScaleAutoConfirm(false);
|
||||
document.getElementById('use-quantity').value = 1;
|
||||
document.getElementById('use-location').value = 'dispensa';
|
||||
@@ -5294,6 +5501,9 @@ async function submitUseAll() {
|
||||
|
||||
async function submitUse(e) {
|
||||
e.preventDefault();
|
||||
if (_useSubmitting) return; // prevent double-submit from scale auto-confirm
|
||||
_useSubmitting = true;
|
||||
_cancelScaleAutoConfirm(false); // stop any running auto-confirm
|
||||
showLoading(true);
|
||||
try {
|
||||
let qty = parseFloat(document.getElementById('use-quantity').value) || 1;
|
||||
@@ -5314,6 +5524,7 @@ async function submitUse(e) {
|
||||
location: document.getElementById('use-location').value,
|
||||
});
|
||||
showLoading(false);
|
||||
_useSubmitting = false;
|
||||
if (result.success) {
|
||||
const usedText = displayUnit ? `${displayQty}${displayUnit}` : displayQty;
|
||||
showToast(`📤 Usato ${usedText} di ${currentProduct.name}`, 'success');
|
||||
@@ -5332,6 +5543,7 @@ async function submitUse(e) {
|
||||
}
|
||||
} catch (err) {
|
||||
showLoading(false);
|
||||
_useSubmitting = false;
|
||||
showToast(t('error.connection'), 'error');
|
||||
}
|
||||
}
|
||||
@@ -7660,6 +7872,7 @@ function viewArchivedRecipe(idx) {
|
||||
let _cachedRecipe = null;
|
||||
let _generatedTodayTitles = []; // client-side list, robust vs race conditions
|
||||
let _recipeVariationCount = {}; // { 'pranzo': 0, 'cena': 1, ... }
|
||||
let _rejectedRecipeIngredients = []; // ingredient names from previously rejected recipes
|
||||
|
||||
function openRecipeDialog() {
|
||||
const meal = getMealType();
|
||||
@@ -8701,14 +8914,18 @@ function _renderMealPlanHint(mealSlot) {
|
||||
}
|
||||
|
||||
function regenerateRecipe() {
|
||||
// Collect main ingredients from the rejected recipe to exclude them
|
||||
if (_cachedRecipe && _cachedRecipe.recipe && _cachedRecipe.recipe.ingredients) {
|
||||
const mainIngs = _cachedRecipe.recipe.ingredients
|
||||
.filter(i => i.from_pantry)
|
||||
.map(i => i.name);
|
||||
_rejectedRecipeIngredients = [...new Set([..._rejectedRecipeIngredients, ...mainIngs])];
|
||||
}
|
||||
_cachedRecipe = null;
|
||||
// Use the meal the user currently has selected (not the auto-detected one)
|
||||
const meal = getSelectedMealType();
|
||||
// increment variation counter for this meal slot
|
||||
_recipeVariationCount[meal] = (_recipeVariationCount[meal] || 0) + 1;
|
||||
document.getElementById('recipe-result').style.display = 'none';
|
||||
document.getElementById('recipe-loading').style.display = 'none';
|
||||
// Keep all existing form settings (persons, chips, meal) — just show the form again
|
||||
document.getElementById('recipe-ask').style.display = '';
|
||||
}
|
||||
|
||||
@@ -8717,6 +8934,11 @@ async function generateRecipe() {
|
||||
const persons = parseInt(document.getElementById('recipe-persons').value) || 1;
|
||||
const settings = getSettings();
|
||||
|
||||
// Reset rejected ingredients on first generation (not regeneration)
|
||||
if ((_recipeVariationCount[meal] || 0) === 0) {
|
||||
_rejectedRecipeIngredients = [];
|
||||
}
|
||||
|
||||
// Determine meal plan type for today's selected slot,
|
||||
// but only if the user has NOT unchecked the meal-plan chip
|
||||
const mealPlanChipWrap = document.getElementById('recipe-opt-mealplan-wrap');
|
||||
@@ -8755,6 +8977,7 @@ async function generateRecipe() {
|
||||
today_recipes: [...new Set([...await getTodayRecipeTitles(), ..._generatedTodayTitles])],
|
||||
meal_plan_type: mealPlanType,
|
||||
variation: _recipeVariationCount[meal] || 0,
|
||||
rejected_ingredients: _rejectedRecipeIngredients,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
|
||||
@@ -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
|
||||
@@ -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 6–11) -->
|
||||
<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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
+100
@@ -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
|
||||
}
|
||||
}
|
||||
+194
@@ -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 & 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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -20,7 +20,7 @@
|
||||
<!-- Top Header -->
|
||||
<header class="app-header">
|
||||
<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">
|
||||
<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">
|
||||
@@ -62,6 +62,20 @@
|
||||
</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 -->
|
||||
<div class="quick-recipe-bar" id="quick-recipe-bar" style="display:none">
|
||||
<button class="btn-quick-recipe" onclick="quickRecipeSuggestion()">
|
||||
@@ -95,13 +109,6 @@
|
||||
<div id="opened-list"></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>
|
||||
|
||||
<!-- ===== INVENTORY LIST ===== -->
|
||||
@@ -1247,6 +1254,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="assets/js/app.js?v=20260415f"></script>
|
||||
<script src="assets/js/app.js?v=20260418a"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "EverShelf",
|
||||
"short_name": "EverShelf",
|
||||
"description": "Gestione completa della dispensa di casa con scansione barcode",
|
||||
"version": "1.2.0",
|
||||
"version": "1.4.0",
|
||||
"start_url": "/evershelf/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f0f4e8",
|
||||
|
||||
+465
-449
@@ -1,462 +1,478 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "EverShelf",
|
||||
"loading": "Laden..."
|
||||
},
|
||||
"nav": {
|
||||
"title": "🏠 EverShelf",
|
||||
"home": "Home",
|
||||
"inventory": "Vorrat",
|
||||
"recipes": "Rezepte",
|
||||
"shopping": "Einkauf",
|
||||
"log": "Log"
|
||||
},
|
||||
"btn": {
|
||||
"back": "← Zurück",
|
||||
"save": "💾 Speichern",
|
||||
"cancel": "✕ Abbrechen",
|
||||
"close": "Schließen",
|
||||
"add": "✅ Hinzufügen",
|
||||
"delete": "Löschen",
|
||||
"edit": "✏️ Bearbeiten",
|
||||
"search": "🔍 Suchen",
|
||||
"go": "✅ Los",
|
||||
"toggle_password": "👁️ Anzeigen/Ausblenden",
|
||||
"load_more": "Mehr laden...",
|
||||
"save_config": "💾 Konfiguration speichern",
|
||||
"save_product": "💾 Produkt speichern",
|
||||
"restart": "↺ Neustart",
|
||||
"reset_default": "↺ Standard wiederherstellen"
|
||||
},
|
||||
"locations": {
|
||||
"dispensa": "Vorratskammer",
|
||||
"frigo": "Kühlschrank",
|
||||
"freezer": "Gefrierschrank",
|
||||
"altro": "Sonstiges"
|
||||
},
|
||||
"categories": {
|
||||
"latticini": "Milchprodukte",
|
||||
"carne": "Fleisch",
|
||||
"pesce": "Fisch",
|
||||
"frutta": "Obst",
|
||||
"verdura": "Gemüse",
|
||||
"pasta": "Pasta & Reis",
|
||||
"pane": "Brot & Backwaren",
|
||||
"surgelati": "Tiefkühl",
|
||||
"bevande": "Getränke",
|
||||
"condimenti": "Gewürze",
|
||||
"snack": "Snacks & Süßes",
|
||||
"conserve": "Konserven",
|
||||
"cereali": "Getreide & Hülsenfrüchte",
|
||||
"igiene": "Hygiene",
|
||||
"pulizia": "Reinigung",
|
||||
"altro": "Sonstiges",
|
||||
"select": "-- Auswählen --"
|
||||
},
|
||||
"units": {
|
||||
"pz": "Stk",
|
||||
"conf": "Pkg",
|
||||
"g": "g",
|
||||
"ml": "ml",
|
||||
"pieces": "Stück",
|
||||
"grams": "Gramm",
|
||||
"box": "Packung",
|
||||
"boxes": "Packungen"
|
||||
},
|
||||
"shopping_sections": {
|
||||
"frutta_verdura": "Obst & Gemüse",
|
||||
"carne_pesce": "Fleisch & Fisch",
|
||||
"latticini": "Milchprodukte & Frisches",
|
||||
"pane_dolci": "Brot & Süßes",
|
||||
"pasta": "Pasta & Getreide",
|
||||
"conserve": "Konserven & Soßen",
|
||||
"surgelati": "Tiefkühl",
|
||||
"bevande": "Getränke",
|
||||
"pulizia_igiene": "Reinigung & Hygiene",
|
||||
"altro": "Sonstiges"
|
||||
},
|
||||
"dashboard": {
|
||||
"expired_title": "🚫 Abgelaufen",
|
||||
"expiring_title": "⏰ Bald ablaufend",
|
||||
"stats_period": "📊 Letzte 30 Tage",
|
||||
"opened_title": "📦 Geöffnete Produkte",
|
||||
"review_title": "🔍 Zu prüfen",
|
||||
"review_hint": "Mengen, die ungewöhnlich erscheinen. Bestätigen oder ändern.",
|
||||
"quick_recipe": "🍳 Schnelles Rezept mit ablaufenden Produkten"
|
||||
},
|
||||
"inventory": {
|
||||
"title": "Vorrat",
|
||||
"filter_all": "Alle",
|
||||
"search_placeholder": "🔍 Produkt suchen...",
|
||||
"empty": "Keine Produkte hier.\nScanne ein Produkt, um es hinzuzufügen!",
|
||||
"no_items_found": "Keine Bestandseinträge gefunden"
|
||||
},
|
||||
"scan": {
|
||||
"title": "Produkt scannen",
|
||||
"mode_shopping": "🛒 Einkaufsmodus",
|
||||
"mode_shopping_end": "✅ Einkauf beenden",
|
||||
"zoom": "Zoom",
|
||||
"barcode_placeholder": "Barcode eingeben...",
|
||||
"quick_name_divider": "oder Name eingeben",
|
||||
"quick_name_placeholder": "z.B.: Äpfel, Zucchini, Brot...",
|
||||
"manual_entry": "✏️ Manuelle Eingabe",
|
||||
"ai_identify": "🤖 Mit KI identifizieren",
|
||||
"hint": "Barcode scannen, Produktname eingeben oder KI zur Identifikation nutzen",
|
||||
"debug_toggle": "🐛 Debug Log",
|
||||
"barcode_acquired": "🔖 Barcode gescannt: {code}",
|
||||
"scan_barcode": "🔖 Barcode scannen"
|
||||
},
|
||||
"action": {
|
||||
"title": "Was möchtest du tun?",
|
||||
"add_btn": "📥 HINZUFÜGEN",
|
||||
"add_sub": "in Vorrat/Kühlschrank",
|
||||
"use_btn": "📤 VERWENDEN / VERBRAUCHEN",
|
||||
"use_sub": "aus Vorrat/Kühlschrank"
|
||||
},
|
||||
"add": {
|
||||
"title": "Zum Vorrat hinzufügen",
|
||||
"location_label": "📍 Wohin?",
|
||||
"quantity_label": "📦 Menge",
|
||||
"conf_size_label": "📦 Jede Packung enthält:",
|
||||
"conf_size_placeholder": "z.B. 300",
|
||||
"vacuum_label": "🫙 Vakuumiert",
|
||||
"vacuum_hint": "Ablaufdatum wird automatisch verlängert",
|
||||
"submit": "✅ Hinzufügen"
|
||||
},
|
||||
"use": {
|
||||
"title": "Verwenden / Verbrauchen",
|
||||
"location_label": "📍 Woher?",
|
||||
"quantity_label": "Wie viel hast du benutzt?",
|
||||
"partial_hint": "Oder genaue Menge angeben:",
|
||||
"use_all": "🗑️ ALLES verwendet / Aufgebraucht",
|
||||
"submit": "📤 Diese Menge verwenden",
|
||||
"available": "📦 Verfügbar:",
|
||||
"not_in_inventory": "⚠️ Produkt nicht im Bestand.",
|
||||
"expiry_warning": "⚠️ Verwende zuerst die{loc}, die am {date} abläuft — {when}!"
|
||||
},
|
||||
"product": {
|
||||
"title_new": "Neues Produkt",
|
||||
"title_edit": "Produkt bearbeiten",
|
||||
"ai_fill": "📷 Foto machen und mit KI identifizieren",
|
||||
"ai_fill_hint": "KI füllt die Produktfelder automatisch aus",
|
||||
"name_label": "🏷️ Produktname *",
|
||||
"name_placeholder": "z.B.: Vollmilch, Penne Nudeln...",
|
||||
"brand_label": "🏢 Marke",
|
||||
"brand_placeholder": "z.B.: Barilla, Müller, Knorr...",
|
||||
"category_label": "📂 Kategorie",
|
||||
"unit_label": "📏 Maßeinheit",
|
||||
"default_qty_label": "🔢 Standardmenge",
|
||||
"conf_size_label": "📦 Jede Packung enthält:",
|
||||
"conf_size_placeholder": "z.B. 300",
|
||||
"notes_label": "📝 Notizen",
|
||||
"notes_placeholder": "z.B.: laktosefrei, bio, nach dem Öffnen im Kühlschrank aufbewahren...",
|
||||
"barcode_label": "🔖 Barcode",
|
||||
"barcode_placeholder": "Barcode (falls vorhanden)",
|
||||
"barcode_hint": "⚠️ Barcode hinzufügen, damit du beim nächsten Einkauf nur scannen musst!",
|
||||
"submit": "💾 Produkt speichern",
|
||||
"name_required": "Produktname eingeben",
|
||||
"conf_size_required": "Packungsinhalt angeben",
|
||||
"expiry_estimated": "Geschätztes Ablaufdatum:",
|
||||
"scan_expiry": "Ablaufdatum scannen",
|
||||
"expiry_hint": "📝 Du kannst das Datum ändern oder mit der Kamera scannen",
|
||||
"add_batch": "📦 + Charge mit anderem Ablaufdatum",
|
||||
"package_info": "📦 Packung: {info}",
|
||||
"edit_catalog": "⚙️ Produktinfo bearbeiten (Name, Marke, Kategorie…)",
|
||||
"not_recognized": "⚠️ Produkt nicht erkannt",
|
||||
"edit_info": "✏️ Informationen bearbeiten",
|
||||
"modify_details": "BEARBEITEN\nAblauf, Ort…"
|
||||
},
|
||||
"products": {
|
||||
"title": "📦 Alle Produkte",
|
||||
"search_placeholder": "🔍 Produkt suchen...",
|
||||
"empty": "Keine Produkte in der Datenbank.\nScanne ein Produkt zum Starten!",
|
||||
"no_category": "Keine Produkte in dieser Kategorie"
|
||||
},
|
||||
"recipes": {
|
||||
"title": "🍳 Rezepte",
|
||||
"generate": "✨ Neues Rezept generieren"
|
||||
},
|
||||
"shopping": {
|
||||
"title": "🛒 Einkaufsliste",
|
||||
"bring_loading": "Verbindung zu Bring!...",
|
||||
"tab_to_buy": "🛍️ Zu kaufen",
|
||||
"tab_forecast": "🧠 Vorhersage",
|
||||
"total_label": "💰 Geschätzter Gesamtbetrag",
|
||||
"section_to_buy": "🛍️ Zu kaufen",
|
||||
"suggestions_title": "💡 KI-Vorschläge",
|
||||
"suggestions_add": "✅ Ausgewählte zu Bring! hinzufügen",
|
||||
"search_prices": "🔍 Alle Preise suchen",
|
||||
"suggest_btn": "🤖 Einkaufsvorschläge",
|
||||
"smart_title": "🧠 Intelligente Vorhersagen",
|
||||
"smart_empty": "Keine Vorhersagen verfügbar.<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"
|
||||
"app": {
|
||||
"name": "EverShelf",
|
||||
"loading": "Laden..."
|
||||
},
|
||||
"bring": {
|
||||
"title": "🛒 Bring! Einkaufsliste",
|
||||
"hint": "Zugangsdaten für die Bring! Einkaufslisten-Integration.",
|
||||
"email_label": "📧 Bring! E-Mail",
|
||||
"password_label": "🔒 Bring! Passwort"
|
||||
"nav": {
|
||||
"title": "🏠 EverShelf",
|
||||
"home": "Home",
|
||||
"inventory": "Vorrat",
|
||||
"recipes": "Rezepte",
|
||||
"shopping": "Einkauf",
|
||||
"log": "Log"
|
||||
},
|
||||
"recipe": {
|
||||
"title": "🍳 Rezept-Einstellungen",
|
||||
"hint": "Konfiguriere die Standardoptionen für die Rezeptgenerierung.",
|
||||
"persons_label": "👥 Standard-Portionen",
|
||||
"options_label": "🎯 Standard-Rezeptoptionen",
|
||||
"fast": "⚡ Schnelles Gericht",
|
||||
"light": "🥗 Leichte Mahlzeit",
|
||||
"expiry": "⏰ Ablauf-Priorität",
|
||||
"healthy": "💚 Extra Gesund",
|
||||
"opened": "📦 Offene Produkte zuerst",
|
||||
"zerowaste": "♻️ Keine Verschwendung",
|
||||
"dietary_label": "🚫 Unverträglichkeiten / Einschränkungen",
|
||||
"dietary_placeholder": "z.B.: glutenfrei, laktosefrei, vegetarisch..."
|
||||
"btn": {
|
||||
"back": "← Zurück",
|
||||
"save": "💾 Speichern",
|
||||
"cancel": "✕ Abbrechen",
|
||||
"close": "Schließen",
|
||||
"add": "✅ Hinzufügen",
|
||||
"delete": "Löschen",
|
||||
"edit": "✏️ Bearbeiten",
|
||||
"search": "🔍 Suchen",
|
||||
"go": "✅ Los",
|
||||
"toggle_password": "👁️ Anzeigen/Ausblenden",
|
||||
"load_more": "Mehr laden...",
|
||||
"save_config": "💾 Konfiguration speichern",
|
||||
"save_product": "💾 Produkt speichern",
|
||||
"restart": "↺ Neustart",
|
||||
"reset_default": "↺ Standard wiederherstellen"
|
||||
},
|
||||
"mealplan": {
|
||||
"title": "📅 Wöchentlicher Essensplan",
|
||||
"hint": "Lege die Mahlzeitenart für jeden Tag fest. Wird als Leitfaden bei der Rezeptgenerierung verwendet.",
|
||||
"enabled": "✅ Wöchentlichen Essensplan aktivieren",
|
||||
"legend": "🌤️ = Mittagessen · 🌙 = Abendessen · Tippe auf ein Badge, um es zu ändern.",
|
||||
"types_title": "📋 Verfügbare Typen"
|
||||
"locations": {
|
||||
"dispensa": "Vorratskammer",
|
||||
"frigo": "Kühlschrank",
|
||||
"freezer": "Gefrierschrank",
|
||||
"altro": "Sonstiges"
|
||||
},
|
||||
"appliances": {
|
||||
"title": "🔌 Verfügbare Geräte",
|
||||
"hint": "Gib an, welche Geräte du hast. Sie werden bei der Rezeptgenerierung berücksichtigt.",
|
||||
"new_placeholder": "z.B.: Brotbackmaschine, Thermomix, Heißluftfritteuse...",
|
||||
"quick_title": "Schnell hinzufügen:",
|
||||
"oven": "🔥 Backofen",
|
||||
"microwave": "📡 Mikrowelle",
|
||||
"air_fryer": "🍟 Heißluftfritteuse",
|
||||
"bread_maker": "🍞 Brotbackmaschine",
|
||||
"bimby": "🤖 Thermomix/Cookeo",
|
||||
"mixer": "🌀 Küchenmaschine",
|
||||
"steamer": "♨️ Dampfgarer",
|
||||
"pressure_cooker": "🫕 Schnellkochtopf",
|
||||
"toaster": "🍞 Toaster",
|
||||
"blender": "🍹 Mixer",
|
||||
"empty": "Keine Geräte hinzugefügt"
|
||||
"categories": {
|
||||
"latticini": "Milchprodukte",
|
||||
"carne": "Fleisch",
|
||||
"pesce": "Fisch",
|
||||
"frutta": "Obst",
|
||||
"verdura": "Gemüse",
|
||||
"pasta": "Pasta & Reis",
|
||||
"pane": "Brot & Backwaren",
|
||||
"surgelati": "Tiefkühl",
|
||||
"bevande": "Getränke",
|
||||
"condimenti": "Gewürze",
|
||||
"snack": "Snacks & Süßes",
|
||||
"conserve": "Konserven",
|
||||
"cereali": "Getreide & Hülsenfrüchte",
|
||||
"igiene": "Hygiene",
|
||||
"pulizia": "Reinigung",
|
||||
"altro": "Sonstiges",
|
||||
"select": "-- Auswählen --"
|
||||
},
|
||||
"spesa": {
|
||||
"title": "🛍️ Online-Einkauf",
|
||||
"hint": "Online-Einkaufsanbieter konfigurieren.",
|
||||
"provider_label": "🏪 Anbieter",
|
||||
"email_label": "📧 E-Mail",
|
||||
"password_label": "🔒 Passwort",
|
||||
"login_btn": "🔐 Anmelden",
|
||||
"ai_prompt_label": "🤖 KI-Produktauswahl Prompt",
|
||||
"ai_prompt_placeholder": "Anweisungen für die KI bei der Auswahl zwischen mehreren Produkten...",
|
||||
"ai_prompt_hint": "Die KI verwendet diesen Prompt zur Auswahl des passendsten Produkts. Leer lassen für Standardverhalten.",
|
||||
"configure_first": "Konfiguriere zuerst den Online-Einkauf in den Einstellungen"
|
||||
"units": {
|
||||
"pz": "Stk",
|
||||
"conf": "Pkg",
|
||||
"g": "g",
|
||||
"ml": "ml",
|
||||
"pieces": "Stück",
|
||||
"grams": "Gramm",
|
||||
"box": "Packung",
|
||||
"boxes": "Packungen"
|
||||
},
|
||||
"camera": {
|
||||
"title": "📷 Kamera",
|
||||
"hint": "Wähle die Kamera für Barcode-Scanning und KI-Identifikation.",
|
||||
"device_label": "📸 Standardkamera",
|
||||
"back": "📱 Rückkamera (Standard)",
|
||||
"front": "🤳 Frontkamera",
|
||||
"devices_hint": "Bei mehreren Kameras kannst du nach Freigabe der Berechtigungen eine bestimmte aus der Liste oben wählen.",
|
||||
"detect_btn": "🔄 Kameras erkennen"
|
||||
"shopping_sections": {
|
||||
"frutta_verdura": "Obst & Gemüse",
|
||||
"carne_pesce": "Fleisch & Fisch",
|
||||
"latticini": "Milchprodukte & Frisches",
|
||||
"pane_dolci": "Brot & Süßes",
|
||||
"pasta": "Pasta & Getreide",
|
||||
"conserve": "Konserven & Soßen",
|
||||
"surgelati": "Tiefkühl",
|
||||
"bevande": "Getränke",
|
||||
"pulizia_igiene": "Reinigung & Hygiene",
|
||||
"altro": "Sonstiges"
|
||||
},
|
||||
"security": {
|
||||
"title": "🔒 HTTPS-Zertifikat",
|
||||
"hint": "Wenn der Browser den Fehler \"Verbindung nicht sicher\" (ERR_CERT_AUTHORITY_INVALID) zeigt, installiere das CA-Zertifikat auf dem Gerät.",
|
||||
"download_btn": "📥 CA-Zertifikat herunterladen"
|
||||
"dashboard": {
|
||||
"expired_title": "🚫 Abgelaufen",
|
||||
"expiring_title": "⏰ Bald ablaufend",
|
||||
"stats_period": "📊 Letzte 30 Tage",
|
||||
"opened_title": "📦 Geöffnete Produkte",
|
||||
"review_title": "🔍 Zu prüfen",
|
||||
"review_hint": "Mengen, die ungewöhnlich erscheinen. Bestätigen oder ändern.",
|
||||
"quick_recipe": "🍳 Schnelles Rezept mit ablaufenden Produkten",
|
||||
"banner_review_title": "Ungewöhnliche Menge",
|
||||
"banner_review_action_ok": "Ist korrekt",
|
||||
"banner_review_action_edit": "Bearbeiten",
|
||||
"banner_review_action_weigh": "Wiegen",
|
||||
"banner_review_dismiss": "Ignorieren",
|
||||
"banner_prediction_title": "Ungewöhnlicher Verbrauch",
|
||||
"banner_prediction_hint": "Laut Vorhersage stimmt diese Menge nicht mit dem erwarteten Verbrauch überein.",
|
||||
"banner_prediction_action_confirm": "Menge bestätigen",
|
||||
"banner_prediction_action_weigh": "Mit Waage wiegen",
|
||||
"banner_prediction_action_edit": "Korrigieren"
|
||||
},
|
||||
"tts": {
|
||||
"title": "🔊 Sprache & TTS",
|
||||
"hint": "Sprachsynthese über externe REST-API konfigurieren. Rezeptschritte und abgelaufene Timer werden an den Endpunkt gesendet.",
|
||||
"enabled": "✅ TTS aktivieren",
|
||||
"url_label": "🌐 Endpunkt-URL",
|
||||
"method_label": "📡 HTTP-Methode",
|
||||
"auth_label": "🔐 Authentifizierung",
|
||||
"auth_bearer": "Bearer Token",
|
||||
"auth_custom": "Benutzerdefinierter Header",
|
||||
"auth_none": "Keine",
|
||||
"token_label": "🔑 Bearer Token",
|
||||
"custom_header_name": "📋 Header-Name",
|
||||
"custom_header_value": "📋 Header-Wert",
|
||||
"content_type_label": "📄 Content-Type",
|
||||
"payload_key_label": "🗝️ Textfeld im Payload",
|
||||
"payload_key_hint": "Name des JSON-Feldes für den zu lesenden Text (z.B.: message, text).",
|
||||
"extra_fields_label": "➕ Zusätzliche Felder (JSON)",
|
||||
"extra_fields_placeholder": "{\"entity_id\": \"media_player.living_room\"}",
|
||||
"extra_fields_hint": "Zusätzliche Felder im Payload, im JSON-Format. Leer lassen wenn nicht benötigt.",
|
||||
"test_btn": "🔊 Testansage senden"
|
||||
"inventory": {
|
||||
"title": "Vorrat",
|
||||
"filter_all": "Alle",
|
||||
"search_placeholder": "🔍 Produkt suchen...",
|
||||
"empty": "Keine Produkte hier.\nScanne ein Produkt, um es hinzuzufügen!",
|
||||
"no_items_found": "Keine Bestandseinträge gefunden"
|
||||
},
|
||||
"language": {
|
||||
"title": "🌐 Sprache",
|
||||
"hint": "Wähle die Sprache der Benutzeroberfläche.",
|
||||
"label": "🌐 Sprache",
|
||||
"restart_notice": "Die Seite wird neu geladen, um die neue Sprache anzuwenden."
|
||||
"scan": {
|
||||
"title": "Produkt scannen",
|
||||
"mode_shopping": "🛒 Einkaufsmodus",
|
||||
"mode_shopping_end": "✅ Einkauf beenden",
|
||||
"zoom": "Zoom",
|
||||
"barcode_placeholder": "Barcode eingeben...",
|
||||
"quick_name_divider": "oder Name eingeben",
|
||||
"quick_name_placeholder": "z.B.: Äpfel, Zucchini, Brot...",
|
||||
"manual_entry": "✏️ Manuelle Eingabe",
|
||||
"ai_identify": "🤖 Mit KI identifizieren",
|
||||
"hint": "Barcode scannen, Produktname eingeben oder KI zur Identifikation nutzen",
|
||||
"debug_toggle": "🐛 Debug Log",
|
||||
"barcode_acquired": "🔖 Barcode gescannt: {code}",
|
||||
"scan_barcode": "🔖 Barcode scannen"
|
||||
},
|
||||
"action": {
|
||||
"title": "Was möchtest du tun?",
|
||||
"add_btn": "📥 HINZUFÜGEN",
|
||||
"add_sub": "in Vorrat/Kühlschrank",
|
||||
"use_btn": "📤 VERWENDEN / VERBRAUCHEN",
|
||||
"use_sub": "aus Vorrat/Kühlschrank"
|
||||
},
|
||||
"add": {
|
||||
"title": "Zum Vorrat hinzufügen",
|
||||
"location_label": "📍 Wohin?",
|
||||
"quantity_label": "📦 Menge",
|
||||
"conf_size_label": "📦 Jede Packung enthält:",
|
||||
"conf_size_placeholder": "z.B. 300",
|
||||
"vacuum_label": "🫙 Vakuumiert",
|
||||
"vacuum_hint": "Ablaufdatum wird automatisch verlängert",
|
||||
"submit": "✅ Hinzufügen"
|
||||
},
|
||||
"use": {
|
||||
"title": "Verwenden / Verbrauchen",
|
||||
"location_label": "📍 Woher?",
|
||||
"quantity_label": "Wie viel hast du benutzt?",
|
||||
"partial_hint": "Oder genaue Menge angeben:",
|
||||
"use_all": "🗑️ ALLES verwendet / Aufgebraucht",
|
||||
"submit": "📤 Diese Menge verwenden",
|
||||
"available": "📦 Verfügbar:",
|
||||
"not_in_inventory": "⚠️ Produkt nicht im Bestand.",
|
||||
"expiry_warning": "⚠️ Verwende zuerst die{loc}, die am {date} abläuft — {when}!"
|
||||
},
|
||||
"product": {
|
||||
"title_new": "Neues Produkt",
|
||||
"title_edit": "Produkt bearbeiten",
|
||||
"ai_fill": "📷 Foto machen und mit KI identifizieren",
|
||||
"ai_fill_hint": "KI füllt die Produktfelder automatisch aus",
|
||||
"name_label": "🏷️ Produktname *",
|
||||
"name_placeholder": "z.B.: Vollmilch, Penne Nudeln...",
|
||||
"brand_label": "🏢 Marke",
|
||||
"brand_placeholder": "z.B.: Barilla, Müller, Knorr...",
|
||||
"category_label": "📂 Kategorie",
|
||||
"unit_label": "📏 Maßeinheit",
|
||||
"default_qty_label": "🔢 Standardmenge",
|
||||
"conf_size_label": "📦 Jede Packung enthält:",
|
||||
"conf_size_placeholder": "z.B. 300",
|
||||
"notes_label": "📝 Notizen",
|
||||
"notes_placeholder": "z.B.: laktosefrei, bio, nach dem Öffnen im Kühlschrank aufbewahren...",
|
||||
"barcode_label": "🔖 Barcode",
|
||||
"barcode_placeholder": "Barcode (falls vorhanden)",
|
||||
"barcode_hint": "⚠️ Barcode hinzufügen, damit du beim nächsten Einkauf nur scannen musst!",
|
||||
"submit": "💾 Produkt speichern",
|
||||
"name_required": "Produktname eingeben",
|
||||
"conf_size_required": "Packungsinhalt angeben",
|
||||
"expiry_estimated": "Geschätztes Ablaufdatum:",
|
||||
"scan_expiry": "Ablaufdatum scannen",
|
||||
"expiry_hint": "📝 Du kannst das Datum ändern oder mit der Kamera scannen",
|
||||
"add_batch": "📦 + Charge mit anderem Ablaufdatum",
|
||||
"package_info": "📦 Packung: {info}",
|
||||
"edit_catalog": "⚙️ Produktinfo bearbeiten (Name, Marke, Kategorie…)",
|
||||
"not_recognized": "⚠️ Produkt nicht erkannt",
|
||||
"edit_info": "✏️ Informationen bearbeiten",
|
||||
"modify_details": "BEARBEITEN\nAblauf, Ort…"
|
||||
},
|
||||
"products": {
|
||||
"title": "📦 Alle Produkte",
|
||||
"search_placeholder": "🔍 Produkt suchen...",
|
||||
"empty": "Keine Produkte in der Datenbank.\nScanne ein Produkt zum Starten!",
|
||||
"no_category": "Keine Produkte in dieser Kategorie"
|
||||
},
|
||||
"recipes": {
|
||||
"title": "🍳 Rezepte",
|
||||
"generate": "✨ Neues Rezept generieren"
|
||||
},
|
||||
"shopping": {
|
||||
"title": "🛒 Einkaufsliste",
|
||||
"bring_loading": "Verbindung zu Bring!...",
|
||||
"tab_to_buy": "🛍️ Zu kaufen",
|
||||
"tab_forecast": "🧠 Vorhersage",
|
||||
"total_label": "💰 Geschätzter Gesamtbetrag",
|
||||
"section_to_buy": "🛍️ Zu kaufen",
|
||||
"suggestions_title": "💡 KI-Vorschläge",
|
||||
"suggestions_add": "✅ Ausgewählte zu Bring! hinzufügen",
|
||||
"search_prices": "🔍 Alle Preise suchen",
|
||||
"suggest_btn": "🤖 Einkaufsvorschläge",
|
||||
"smart_title": "🧠 Intelligente Vorhersagen",
|
||||
"smart_empty": "Keine Vorhersagen verfügbar.<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": {
|
||||
"title": "⚖️ Smart-Waage",
|
||||
"hint": "Verbinde eine Bluetooth-Waage über das Android-Gateway, um das Gewicht automatisch auszulesen.",
|
||||
"tab": "Smart-Waage",
|
||||
"enabled": "✅ Smart-Waage aktivieren",
|
||||
"url_label": "🌐 WebSocket-Gateway-URL",
|
||||
"url_placeholder": "ws://192.168.1.x:8765",
|
||||
"url_hint": "URL der Android-App (gleiches WLAN). z.B.:",
|
||||
"test_btn": "🔗 Verbindung testen",
|
||||
"download_btn": "📥 Android-Gateway herunterladen (APK)",
|
||||
"download_hint": "Android-App als Brücke zwischen BLE-Waage und EverShelf.",
|
||||
"download_sub": "Quellcode: evershelf-scale-gateway/ im Projektstamm"
|
||||
"status_connected": "Waage verbunden",
|
||||
"status_searching": "Gateway verbunden, warte auf Waage…",
|
||||
"status_disconnected": "Waagen-Gateway nicht erreichbar",
|
||||
"status_error": "Verbindungsfehler zum Gateway",
|
||||
"not_connected": "Waagen-Gateway nicht verbunden",
|
||||
"read_btn": "⚖️ Von Waage lesen",
|
||||
"reading_title": "Waage lesen",
|
||||
"place_on_scale": "Produkt auf die Waage legen…",
|
||||
"waiting_stable": "Das Gewicht wird automatisch erfasst, wenn die Messung stabil ist.",
|
||||
"no_url": "Gateway-URL eingeben",
|
||||
"testing": "⏳ Verbindung wird getestet…",
|
||||
"connected_ok": "Gateway-Verbindung erfolgreich!",
|
||||
"timeout": "Timeout: keine Antwort vom Gateway",
|
||||
"error_connect": "Verbindung zum Gateway nicht möglich",
|
||||
"tab": "Smart-Waage",
|
||||
"low_weight": "Gewicht < 10 g · manuell eingeben\n(Auto-Erkennung erfordert mind. 10 g)"
|
||||
},
|
||||
"saved": "✅ Konfiguration gespeichert!",
|
||||
"saved_local": "✅ Konfiguration lokal gespeichert",
|
||||
"saved_local_error": "⚠️ Lokal gespeichert, Serverfehler: {error}"
|
||||
},
|
||||
"expiry": {
|
||||
"today": "HEUTE",
|
||||
"tomorrow": "Morgen",
|
||||
"days": "{days} Tage",
|
||||
"expired_days": "Seit {days}T",
|
||||
"expired_yesterday": "Seit gestern",
|
||||
"expired_today": "Heute"
|
||||
},
|
||||
"status": {
|
||||
"ok": "OK",
|
||||
"check": "Prüfen",
|
||||
"discard": "Entsorgen"
|
||||
},
|
||||
"toast": {
|
||||
"product_saved": "Produkt gespeichert!",
|
||||
"product_created": "Produkt erstellt!",
|
||||
"product_updated": "✅ Produkt aktualisiert!",
|
||||
"product_removed": "Produkt entfernt",
|
||||
"updated": "Aktualisiert!",
|
||||
"quantity_confirmed": "✓ Menge bestätigt",
|
||||
"added_to_inventory": "✅ {name} hinzugefügt!",
|
||||
"removed_from_list": "✅ {name} von der Liste entfernt!",
|
||||
"removed_from_list_short": "Von der Liste entfernt",
|
||||
"added_to_shopping": "🛒 Zur Einkaufsliste hinzugefügt!",
|
||||
"removed_from_shopping": "🛒 Von der Einkaufsliste entfernt",
|
||||
"finished_to_bring": "🛒 Produkt aufgebraucht → zu Bring! hinzugefügt",
|
||||
"thrown_away": "🗑️ {name} weggeworfen!",
|
||||
"thrown_away_partial": "🗑️ {qty} {unit} von {name} weggeworfen",
|
||||
"appliance_added": "Gerät hinzugefügt",
|
||||
"item_added": "{name} hinzugefügt"
|
||||
},
|
||||
"error": {
|
||||
"generic": "Fehler",
|
||||
"loading": "Fehler beim Laden des Produkts",
|
||||
"not_found": "Produkt nicht gefunden",
|
||||
"not_found_manual": "Produkt nicht gefunden. Manuell eingeben.",
|
||||
"search": "Suchfehler. Nochmal versuchen.",
|
||||
"search_short": "Suchfehler",
|
||||
"save": "Fehler beim Speichern",
|
||||
"connection": "Verbindungsfehler",
|
||||
"camera": "Kamera nicht verfügbar",
|
||||
"bring_add": "Fehler beim Hinzufügen zu Bring!",
|
||||
"bring_connection": "Bring! Verbindungsfehler",
|
||||
"identification": "Identifikationsfehler",
|
||||
"barcode_empty": "Barcode eingeben",
|
||||
"barcode_format": "Barcode darf nur Zahlen enthalten (4-14 Ziffern)",
|
||||
"min_chars": "Mindestens 2 Zeichen eingeben",
|
||||
"not_in_inventory": "Produkt nicht im Bestand",
|
||||
"appliance_exists": "Gerät bereits vorhanden",
|
||||
"already_exists": "Bereits vorhanden"
|
||||
},
|
||||
"confirm": {
|
||||
"remove_item": "Möchtest du dieses Produkt wirklich aus dem Bestand entfernen?"
|
||||
},
|
||||
"edit": {
|
||||
"title": "{name} bearbeiten"
|
||||
},
|
||||
"screensaver": {
|
||||
"recipe_btn": "Rezepte",
|
||||
"scan_btn": "Produkt scannen"
|
||||
},
|
||||
"days": {
|
||||
"mon": "Montag",
|
||||
"tue": "Dienstag",
|
||||
"wed": "Mittwoch",
|
||||
"thu": "Donnerstag",
|
||||
"fri": "Freitag",
|
||||
"sat": "Samstag",
|
||||
"sun": "Sonntag"
|
||||
},
|
||||
"meal_types": {
|
||||
"lunch": "Mittagessen",
|
||||
"dinner": "Abendessen"
|
||||
},
|
||||
"scale": {
|
||||
"status_connected": "Waage verbunden",
|
||||
"status_searching": "Gateway verbunden, warte auf Waage…",
|
||||
"status_disconnected": "Waagen-Gateway nicht erreichbar",
|
||||
"status_error": "Verbindungsfehler zum Gateway",
|
||||
"not_connected": "Waagen-Gateway nicht verbunden",
|
||||
"read_btn": "⚖️ Von Waage lesen",
|
||||
"reading_title": "Waage lesen",
|
||||
"place_on_scale": "Produkt auf die Waage legen…",
|
||||
"waiting_stable": "Das Gewicht wird automatisch erfasst, wenn die Messung stabil ist.",
|
||||
"no_url": "Gateway-URL eingeben",
|
||||
"testing": "⏳ Verbindung wird getestet…",
|
||||
"connected_ok": "Gateway-Verbindung erfolgreich!",
|
||||
"timeout": "Timeout: keine Antwort vom Gateway",
|
||||
"error_connect": "Verbindung zum Gateway nicht möglich",
|
||||
"tab": "Smart-Waage"
|
||||
}
|
||||
}
|
||||
"prediction": {
|
||||
"expected_qty": "Erwartet: {expected} {unit}",
|
||||
"actual_qty": "Aktuell: {actual} {unit}",
|
||||
"check_suggestion": "Überprüfe oder wiege die Restmenge"
|
||||
}
|
||||
}
|
||||
+465
-449
@@ -1,462 +1,478 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "EverShelf",
|
||||
"loading": "Loading..."
|
||||
},
|
||||
"nav": {
|
||||
"title": "🏠 EverShelf",
|
||||
"home": "Home",
|
||||
"inventory": "Pantry",
|
||||
"recipes": "Recipes",
|
||||
"shopping": "Shopping",
|
||||
"log": "Log"
|
||||
},
|
||||
"btn": {
|
||||
"back": "← Back",
|
||||
"save": "💾 Save",
|
||||
"cancel": "✕ Cancel",
|
||||
"close": "Close",
|
||||
"add": "✅ Add",
|
||||
"delete": "Delete",
|
||||
"edit": "✏️ Edit",
|
||||
"search": "🔍 Search",
|
||||
"go": "✅ Go",
|
||||
"toggle_password": "👁️ Show/Hide",
|
||||
"load_more": "Load more...",
|
||||
"save_config": "💾 Save Configuration",
|
||||
"save_product": "💾 Save Product",
|
||||
"restart": "↺ Restart",
|
||||
"reset_default": "↺ Reset to default"
|
||||
},
|
||||
"locations": {
|
||||
"dispensa": "Pantry",
|
||||
"frigo": "Fridge",
|
||||
"freezer": "Freezer",
|
||||
"altro": "Other"
|
||||
},
|
||||
"categories": {
|
||||
"latticini": "Dairy",
|
||||
"carne": "Meat",
|
||||
"pesce": "Fish",
|
||||
"frutta": "Fruit",
|
||||
"verdura": "Vegetables",
|
||||
"pasta": "Pasta & Rice",
|
||||
"pane": "Bread & Bakery",
|
||||
"surgelati": "Frozen",
|
||||
"bevande": "Beverages",
|
||||
"condimenti": "Condiments",
|
||||
"snack": "Snacks & Sweets",
|
||||
"conserve": "Canned Goods",
|
||||
"cereali": "Cereals & Legumes",
|
||||
"igiene": "Hygiene",
|
||||
"pulizia": "Household",
|
||||
"altro": "Other",
|
||||
"select": "-- Select --"
|
||||
},
|
||||
"units": {
|
||||
"pz": "pcs",
|
||||
"conf": "pkg",
|
||||
"g": "g",
|
||||
"ml": "ml",
|
||||
"pieces": "Pieces",
|
||||
"grams": "Grams",
|
||||
"box": "Package",
|
||||
"boxes": "Packages"
|
||||
},
|
||||
"shopping_sections": {
|
||||
"frutta_verdura": "Fruits & Vegetables",
|
||||
"carne_pesce": "Meat & Fish",
|
||||
"latticini": "Dairy & Fresh",
|
||||
"pane_dolci": "Bread & Sweets",
|
||||
"pasta": "Pasta & Cereals",
|
||||
"conserve": "Canned & Sauces",
|
||||
"surgelati": "Frozen",
|
||||
"bevande": "Beverages",
|
||||
"pulizia_igiene": "Cleaning & Hygiene",
|
||||
"altro": "Other"
|
||||
},
|
||||
"dashboard": {
|
||||
"expired_title": "🚫 Expired",
|
||||
"expiring_title": "⏰ Expiring Soon",
|
||||
"stats_period": "📊 Last 30 days",
|
||||
"opened_title": "📦 Opened Products",
|
||||
"review_title": "🔍 To Review",
|
||||
"review_hint": "Quantities that seem unusual. Confirm if correct or modify.",
|
||||
"quick_recipe": "🍳 Quick recipe with expiring products"
|
||||
},
|
||||
"inventory": {
|
||||
"title": "Pantry",
|
||||
"filter_all": "All",
|
||||
"search_placeholder": "🔍 Search product...",
|
||||
"empty": "No products here.\nScan a product to add it!",
|
||||
"no_items_found": "No inventory items found"
|
||||
},
|
||||
"scan": {
|
||||
"title": "Scan Product",
|
||||
"mode_shopping": "🛒 Shopping Mode",
|
||||
"mode_shopping_end": "✅ End shopping",
|
||||
"zoom": "Zoom",
|
||||
"barcode_placeholder": "Enter barcode...",
|
||||
"quick_name_divider": "or type the name",
|
||||
"quick_name_placeholder": "E.g.: Apples, Zucchini, Bread...",
|
||||
"manual_entry": "✏️ Manual Entry",
|
||||
"ai_identify": "🤖 Identify with AI",
|
||||
"hint": "Scan the barcode, type the product name, or use AI to identify it",
|
||||
"debug_toggle": "🐛 Debug Log",
|
||||
"barcode_acquired": "🔖 Barcode scanned: {code}",
|
||||
"scan_barcode": "🔖 Scan Barcode"
|
||||
},
|
||||
"action": {
|
||||
"title": "What do you want to do?",
|
||||
"add_btn": "📥 ADD",
|
||||
"add_sub": "to pantry/fridge",
|
||||
"use_btn": "📤 USE / CONSUME",
|
||||
"use_sub": "from pantry/fridge"
|
||||
},
|
||||
"add": {
|
||||
"title": "Add to Pantry",
|
||||
"location_label": "📍 Where do you put it?",
|
||||
"quantity_label": "📦 Quantity",
|
||||
"conf_size_label": "📦 Each package contains:",
|
||||
"conf_size_placeholder": "e.g. 300",
|
||||
"vacuum_label": "🫙 Vacuum sealed",
|
||||
"vacuum_hint": "Expiry date will be extended automatically",
|
||||
"submit": "✅ Add"
|
||||
},
|
||||
"use": {
|
||||
"title": "Use / Consume",
|
||||
"location_label": "📍 From where?",
|
||||
"quantity_label": "How much did you use?",
|
||||
"partial_hint": "Or specify the quantity used:",
|
||||
"use_all": "🗑️ Used ALL / Finished",
|
||||
"submit": "📤 Use this quantity",
|
||||
"available": "📦 Available:",
|
||||
"not_in_inventory": "⚠️ Product not in inventory.",
|
||||
"expiry_warning": "⚠️ Use first the one{loc} that expires on {date} — {when}!"
|
||||
},
|
||||
"product": {
|
||||
"title_new": "New Product",
|
||||
"title_edit": "Edit Product",
|
||||
"ai_fill": "📷 Take photo and identify with AI",
|
||||
"ai_fill_hint": "AI will automatically fill in the product fields",
|
||||
"name_label": "🏷️ Product Name *",
|
||||
"name_placeholder": "E.g.: Whole milk, Penne pasta...",
|
||||
"brand_label": "🏢 Brand",
|
||||
"brand_placeholder": "E.g.: Barilla, Granarolo, Mutti...",
|
||||
"category_label": "📂 Category",
|
||||
"unit_label": "📏 Unit of measure",
|
||||
"default_qty_label": "🔢 Default quantity",
|
||||
"conf_size_label": "📦 Each package contains:",
|
||||
"conf_size_placeholder": "e.g. 300",
|
||||
"notes_label": "📝 Notes",
|
||||
"notes_placeholder": "E.g.: lactose free, organic, store in fridge after opening...",
|
||||
"barcode_label": "🔖 Barcode",
|
||||
"barcode_placeholder": "Barcode (if available)",
|
||||
"barcode_hint": "⚠️ Add the barcode so next time you just need to scan it!",
|
||||
"submit": "💾 Save Product",
|
||||
"name_required": "Enter the product name",
|
||||
"conf_size_required": "Specify the package content",
|
||||
"expiry_estimated": "Estimated expiry:",
|
||||
"scan_expiry": "Scan expiry date",
|
||||
"expiry_hint": "📝 You can edit the date or scan it with the camera",
|
||||
"add_batch": "📦 + Batch with different expiry",
|
||||
"package_info": "📦 Package: {info}",
|
||||
"edit_catalog": "⚙️ Edit product info (name, brand, category…)",
|
||||
"not_recognized": "⚠️ Product not recognized",
|
||||
"edit_info": "✏️ Edit information",
|
||||
"modify_details": "EDIT\nexpiry, location…"
|
||||
},
|
||||
"products": {
|
||||
"title": "📦 All Products",
|
||||
"search_placeholder": "🔍 Search product...",
|
||||
"empty": "No products in database.\nScan a product to get started!",
|
||||
"no_category": "No products in this category"
|
||||
},
|
||||
"recipes": {
|
||||
"title": "🍳 Recipes",
|
||||
"generate": "✨ Generate new recipe"
|
||||
},
|
||||
"shopping": {
|
||||
"title": "🛒 Shopping List",
|
||||
"bring_loading": "Connecting to Bring!...",
|
||||
"tab_to_buy": "🛍️ To buy",
|
||||
"tab_forecast": "🧠 Forecast",
|
||||
"total_label": "💰 Estimated total",
|
||||
"section_to_buy": "🛍️ To buy",
|
||||
"suggestions_title": "💡 AI Suggestions",
|
||||
"suggestions_add": "✅ Add selected to Bring!",
|
||||
"search_prices": "🔍 Search all prices",
|
||||
"suggest_btn": "🤖 Suggest what to buy",
|
||||
"smart_title": "🧠 Smart Predictions",
|
||||
"smart_empty": "No predictions available.<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"
|
||||
"app": {
|
||||
"name": "EverShelf",
|
||||
"loading": "Loading..."
|
||||
},
|
||||
"bring": {
|
||||
"title": "🛒 Bring! Shopping List",
|
||||
"hint": "Credentials for the Bring! shopping list integration.",
|
||||
"email_label": "📧 Bring! Email",
|
||||
"password_label": "🔒 Bring! Password"
|
||||
"nav": {
|
||||
"title": "🏠 EverShelf",
|
||||
"home": "Home",
|
||||
"inventory": "Pantry",
|
||||
"recipes": "Recipes",
|
||||
"shopping": "Shopping",
|
||||
"log": "Log"
|
||||
},
|
||||
"recipe": {
|
||||
"title": "🍳 Recipe Preferences",
|
||||
"hint": "Configure the default options for recipe generation.",
|
||||
"persons_label": "👥 Default servings",
|
||||
"options_label": "🎯 Default recipe options",
|
||||
"fast": "⚡ Quick Meal",
|
||||
"light": "🥗 Light Meal",
|
||||
"expiry": "⏰ Expiry Priority",
|
||||
"healthy": "💚 Extra Healthy",
|
||||
"opened": "📦 Open Items Priority",
|
||||
"zerowaste": "♻️ Zero Waste",
|
||||
"dietary_label": "🚫 Intolerances / Restrictions",
|
||||
"dietary_placeholder": "E.g.: gluten free, lactose free, vegetarian..."
|
||||
"btn": {
|
||||
"back": "← Back",
|
||||
"save": "💾 Save",
|
||||
"cancel": "✕ Cancel",
|
||||
"close": "Close",
|
||||
"add": "✅ Add",
|
||||
"delete": "Delete",
|
||||
"edit": "✏️ Edit",
|
||||
"search": "🔍 Search",
|
||||
"go": "✅ Go",
|
||||
"toggle_password": "👁️ Show/Hide",
|
||||
"load_more": "Load more...",
|
||||
"save_config": "💾 Save Configuration",
|
||||
"save_product": "💾 Save Product",
|
||||
"restart": "↺ Restart",
|
||||
"reset_default": "↺ Reset to default"
|
||||
},
|
||||
"mealplan": {
|
||||
"title": "📅 Weekly Meal Plan",
|
||||
"hint": "Set the meal type for each day. It will be used as a guide in recipe generation.",
|
||||
"enabled": "✅ Enable weekly meal plan",
|
||||
"legend": "🌤️ = Lunch · 🌙 = Dinner · Tap a badge to change it.",
|
||||
"types_title": "📋 Available types"
|
||||
"locations": {
|
||||
"dispensa": "Pantry",
|
||||
"frigo": "Fridge",
|
||||
"freezer": "Freezer",
|
||||
"altro": "Other"
|
||||
},
|
||||
"appliances": {
|
||||
"title": "🔌 Available Appliances",
|
||||
"hint": "Indicate the appliances you have. They will be considered in recipe generation.",
|
||||
"new_placeholder": "E.g.: Bread machine, Thermomix, Air fryer...",
|
||||
"quick_title": "Quick add:",
|
||||
"oven": "🔥 Oven",
|
||||
"microwave": "📡 Microwave",
|
||||
"air_fryer": "🍟 Air fryer",
|
||||
"bread_maker": "🍞 Bread maker",
|
||||
"bimby": "🤖 Thermomix/Cookeo",
|
||||
"mixer": "🌀 Stand mixer",
|
||||
"steamer": "♨️ Steamer",
|
||||
"pressure_cooker": "🫕 Pressure cooker",
|
||||
"toaster": "🍞 Toaster",
|
||||
"blender": "🍹 Blender",
|
||||
"empty": "No appliances added"
|
||||
"categories": {
|
||||
"latticini": "Dairy",
|
||||
"carne": "Meat",
|
||||
"pesce": "Fish",
|
||||
"frutta": "Fruit",
|
||||
"verdura": "Vegetables",
|
||||
"pasta": "Pasta & Rice",
|
||||
"pane": "Bread & Bakery",
|
||||
"surgelati": "Frozen",
|
||||
"bevande": "Beverages",
|
||||
"condimenti": "Condiments",
|
||||
"snack": "Snacks & Sweets",
|
||||
"conserve": "Canned Goods",
|
||||
"cereali": "Cereals & Legumes",
|
||||
"igiene": "Hygiene",
|
||||
"pulizia": "Household",
|
||||
"altro": "Other",
|
||||
"select": "-- Select --"
|
||||
},
|
||||
"spesa": {
|
||||
"title": "🛍️ Online Shopping",
|
||||
"hint": "Configure the online shopping provider.",
|
||||
"provider_label": "🏪 Provider",
|
||||
"email_label": "📧 Email",
|
||||
"password_label": "🔒 Password",
|
||||
"login_btn": "🔐 Login",
|
||||
"ai_prompt_label": "🤖 AI product selection prompt",
|
||||
"ai_prompt_placeholder": "Instructions for AI when choosing between multiple products...",
|
||||
"ai_prompt_hint": "AI uses this prompt to choose the most appropriate product from results. Leave empty for default behavior.",
|
||||
"configure_first": "Configure Online Shopping in settings first"
|
||||
"units": {
|
||||
"pz": "pcs",
|
||||
"conf": "pkg",
|
||||
"g": "g",
|
||||
"ml": "ml",
|
||||
"pieces": "Pieces",
|
||||
"grams": "Grams",
|
||||
"box": "Package",
|
||||
"boxes": "Packages"
|
||||
},
|
||||
"camera": {
|
||||
"title": "📷 Camera",
|
||||
"hint": "Choose which camera to use for barcode scanning and AI identification.",
|
||||
"device_label": "📸 Default camera",
|
||||
"back": "📱 Rear (default)",
|
||||
"front": "🤳 Front",
|
||||
"devices_hint": "If you have multiple cameras, you can select a specific one from the list above after granting permissions.",
|
||||
"detect_btn": "🔄 Detect cameras"
|
||||
"shopping_sections": {
|
||||
"frutta_verdura": "Fruits & Vegetables",
|
||||
"carne_pesce": "Meat & Fish",
|
||||
"latticini": "Dairy & Fresh",
|
||||
"pane_dolci": "Bread & Sweets",
|
||||
"pasta": "Pasta & Cereals",
|
||||
"conserve": "Canned & Sauces",
|
||||
"surgelati": "Frozen",
|
||||
"bevande": "Beverages",
|
||||
"pulizia_igiene": "Cleaning & Hygiene",
|
||||
"altro": "Other"
|
||||
},
|
||||
"security": {
|
||||
"title": "🔒 HTTPS Certificate",
|
||||
"hint": "If the browser shows the error \"Your connection is not private\" (ERR_CERT_AUTHORITY_INVALID), you need to install the CA certificate on the device.",
|
||||
"download_btn": "📥 Download CA Certificate"
|
||||
"dashboard": {
|
||||
"expired_title": "🚫 Expired",
|
||||
"expiring_title": "⏰ Expiring Soon",
|
||||
"stats_period": "📊 Last 30 days",
|
||||
"opened_title": "📦 Opened Products",
|
||||
"review_title": "🔍 To Review",
|
||||
"review_hint": "Quantities that seem unusual. Confirm if correct or modify.",
|
||||
"quick_recipe": "🍳 Quick recipe with expiring products",
|
||||
"banner_review_title": "Anomalous quantity",
|
||||
"banner_review_action_ok": "It's correct",
|
||||
"banner_review_action_edit": "Edit",
|
||||
"banner_review_action_weigh": "Weigh",
|
||||
"banner_review_dismiss": "Dismiss",
|
||||
"banner_prediction_title": "Anomalous consumption",
|
||||
"banner_prediction_hint": "Based on predictions, this quantity doesn't match expected consumption.",
|
||||
"banner_prediction_action_confirm": "Confirm quantity",
|
||||
"banner_prediction_action_weigh": "Weigh with scale",
|
||||
"banner_prediction_action_edit": "Correct"
|
||||
},
|
||||
"tts": {
|
||||
"title": "🔊 Voice & TTS",
|
||||
"hint": "Configure text-to-speech via any external REST API. Recipe steps and expired timers will be sent to the configured endpoint.",
|
||||
"enabled": "✅ Enable TTS",
|
||||
"url_label": "🌐 Endpoint URL",
|
||||
"method_label": "📡 HTTP Method",
|
||||
"auth_label": "🔐 Authentication",
|
||||
"auth_bearer": "Bearer Token",
|
||||
"auth_custom": "Custom Header",
|
||||
"auth_none": "None",
|
||||
"token_label": "🔑 Bearer Token",
|
||||
"custom_header_name": "📋 Header name",
|
||||
"custom_header_value": "📋 Header value",
|
||||
"content_type_label": "📄 Content-Type",
|
||||
"payload_key_label": "🗝️ Text field in payload",
|
||||
"payload_key_hint": "Name of the JSON field that will contain the text to read (e.g.: message, text).",
|
||||
"extra_fields_label": "➕ Extra fields (JSON)",
|
||||
"extra_fields_placeholder": "{\"entity_id\": \"media_player.living_room\"}",
|
||||
"extra_fields_hint": "Additional fields to include in the payload, in JSON format. Leave empty if not needed.",
|
||||
"test_btn": "🔊 Send Test Voice"
|
||||
"inventory": {
|
||||
"title": "Pantry",
|
||||
"filter_all": "All",
|
||||
"search_placeholder": "🔍 Search product...",
|
||||
"empty": "No products here.\nScan a product to add it!",
|
||||
"no_items_found": "No inventory items found"
|
||||
},
|
||||
"language": {
|
||||
"title": "🌐 Language",
|
||||
"hint": "Select the interface language.",
|
||||
"label": "🌐 Language",
|
||||
"restart_notice": "The page will reload to apply the new language."
|
||||
"scan": {
|
||||
"title": "Scan Product",
|
||||
"mode_shopping": "🛒 Shopping Mode",
|
||||
"mode_shopping_end": "✅ End shopping",
|
||||
"zoom": "Zoom",
|
||||
"barcode_placeholder": "Enter barcode...",
|
||||
"quick_name_divider": "or type the name",
|
||||
"quick_name_placeholder": "E.g.: Apples, Zucchini, Bread...",
|
||||
"manual_entry": "✏️ Manual Entry",
|
||||
"ai_identify": "🤖 Identify with AI",
|
||||
"hint": "Scan the barcode, type the product name, or use AI to identify it",
|
||||
"debug_toggle": "🐛 Debug Log",
|
||||
"barcode_acquired": "🔖 Barcode scanned: {code}",
|
||||
"scan_barcode": "🔖 Scan Barcode"
|
||||
},
|
||||
"action": {
|
||||
"title": "What do you want to do?",
|
||||
"add_btn": "📥 ADD",
|
||||
"add_sub": "to pantry/fridge",
|
||||
"use_btn": "📤 USE / CONSUME",
|
||||
"use_sub": "from pantry/fridge"
|
||||
},
|
||||
"add": {
|
||||
"title": "Add to Pantry",
|
||||
"location_label": "📍 Where do you put it?",
|
||||
"quantity_label": "📦 Quantity",
|
||||
"conf_size_label": "📦 Each package contains:",
|
||||
"conf_size_placeholder": "e.g. 300",
|
||||
"vacuum_label": "🫙 Vacuum sealed",
|
||||
"vacuum_hint": "Expiry date will be extended automatically",
|
||||
"submit": "✅ Add"
|
||||
},
|
||||
"use": {
|
||||
"title": "Use / Consume",
|
||||
"location_label": "📍 From where?",
|
||||
"quantity_label": "How much did you use?",
|
||||
"partial_hint": "Or specify the quantity used:",
|
||||
"use_all": "🗑️ Used ALL / Finished",
|
||||
"submit": "📤 Use this quantity",
|
||||
"available": "📦 Available:",
|
||||
"not_in_inventory": "⚠️ Product not in inventory.",
|
||||
"expiry_warning": "⚠️ Use first the one{loc} that expires on {date} — {when}!"
|
||||
},
|
||||
"product": {
|
||||
"title_new": "New Product",
|
||||
"title_edit": "Edit Product",
|
||||
"ai_fill": "📷 Take photo and identify with AI",
|
||||
"ai_fill_hint": "AI will automatically fill in the product fields",
|
||||
"name_label": "🏷️ Product Name *",
|
||||
"name_placeholder": "E.g.: Whole milk, Penne pasta...",
|
||||
"brand_label": "🏢 Brand",
|
||||
"brand_placeholder": "E.g.: Barilla, Granarolo, Mutti...",
|
||||
"category_label": "📂 Category",
|
||||
"unit_label": "📏 Unit of measure",
|
||||
"default_qty_label": "🔢 Default quantity",
|
||||
"conf_size_label": "📦 Each package contains:",
|
||||
"conf_size_placeholder": "e.g. 300",
|
||||
"notes_label": "📝 Notes",
|
||||
"notes_placeholder": "E.g.: lactose free, organic, store in fridge after opening...",
|
||||
"barcode_label": "🔖 Barcode",
|
||||
"barcode_placeholder": "Barcode (if available)",
|
||||
"barcode_hint": "⚠️ Add the barcode so next time you just need to scan it!",
|
||||
"submit": "💾 Save Product",
|
||||
"name_required": "Enter the product name",
|
||||
"conf_size_required": "Specify the package content",
|
||||
"expiry_estimated": "Estimated expiry:",
|
||||
"scan_expiry": "Scan expiry date",
|
||||
"expiry_hint": "📝 You can edit the date or scan it with the camera",
|
||||
"add_batch": "📦 + Batch with different expiry",
|
||||
"package_info": "📦 Package: {info}",
|
||||
"edit_catalog": "⚙️ Edit product info (name, brand, category…)",
|
||||
"not_recognized": "⚠️ Product not recognized",
|
||||
"edit_info": "✏️ Edit information",
|
||||
"modify_details": "EDIT\nexpiry, location…"
|
||||
},
|
||||
"products": {
|
||||
"title": "📦 All Products",
|
||||
"search_placeholder": "🔍 Search product...",
|
||||
"empty": "No products in database.\nScan a product to get started!",
|
||||
"no_category": "No products in this category"
|
||||
},
|
||||
"recipes": {
|
||||
"title": "🍳 Recipes",
|
||||
"generate": "✨ Generate new recipe"
|
||||
},
|
||||
"shopping": {
|
||||
"title": "🛒 Shopping List",
|
||||
"bring_loading": "Connecting to Bring!...",
|
||||
"tab_to_buy": "🛍️ To buy",
|
||||
"tab_forecast": "🧠 Forecast",
|
||||
"total_label": "💰 Estimated total",
|
||||
"section_to_buy": "🛍️ To buy",
|
||||
"suggestions_title": "💡 AI Suggestions",
|
||||
"suggestions_add": "✅ Add selected to Bring!",
|
||||
"search_prices": "🔍 Search all prices",
|
||||
"suggest_btn": "🤖 Suggest what to buy",
|
||||
"smart_title": "🧠 Smart Predictions",
|
||||
"smart_empty": "No predictions available.<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": {
|
||||
"title": "⚖️ Smart Scale",
|
||||
"hint": "Connect a Bluetooth scale via the Android gateway to automatically read weight.",
|
||||
"tab": "Smart Scale",
|
||||
"enabled": "✅ Enable smart scale",
|
||||
"url_label": "🌐 WebSocket Gateway URL",
|
||||
"url_placeholder": "ws://192.168.1.x:8765",
|
||||
"url_hint": "URL shown by the Android app (same Wi-Fi network). E.g.:",
|
||||
"test_btn": "🔗 Test connection",
|
||||
"download_btn": "📥 Download Android Gateway (APK)",
|
||||
"download_hint": "Android app that bridges your BLE scale and EverShelf.",
|
||||
"download_sub": "Source: evershelf-scale-gateway/ in the project root"
|
||||
"status_connected": "Scale connected",
|
||||
"status_searching": "Gateway connected, waiting for scale…",
|
||||
"status_disconnected": "Scale gateway unreachable",
|
||||
"status_error": "Gateway connection error",
|
||||
"not_connected": "Scale gateway not connected",
|
||||
"read_btn": "⚖️ Read from scale",
|
||||
"reading_title": "Scale reading",
|
||||
"place_on_scale": "Place the product on the scale…",
|
||||
"waiting_stable": "Weight will be captured automatically once the reading is stable.",
|
||||
"no_url": "Enter the gateway URL",
|
||||
"testing": "⏳ Testing connection…",
|
||||
"connected_ok": "Gateway connection successful!",
|
||||
"timeout": "Timeout: no response from gateway",
|
||||
"error_connect": "Cannot connect to gateway",
|
||||
"tab": "Smart Scale",
|
||||
"low_weight": "Weight < 10 g · enter manually\n(auto-reading requires at least 10 g)"
|
||||
},
|
||||
"saved": "✅ Configuration saved!",
|
||||
"saved_local": "✅ Configuration saved locally",
|
||||
"saved_local_error": "⚠️ Saved locally, server error: {error}"
|
||||
},
|
||||
"expiry": {
|
||||
"today": "TODAY",
|
||||
"tomorrow": "Tomorrow",
|
||||
"days": "{days} days",
|
||||
"expired_days": "{days}d ago",
|
||||
"expired_yesterday": "Yesterday",
|
||||
"expired_today": "Today"
|
||||
},
|
||||
"status": {
|
||||
"ok": "OK",
|
||||
"check": "Check",
|
||||
"discard": "Discard"
|
||||
},
|
||||
"toast": {
|
||||
"product_saved": "Product saved!",
|
||||
"product_created": "Product created!",
|
||||
"product_updated": "✅ Product updated!",
|
||||
"product_removed": "Product removed",
|
||||
"updated": "Updated!",
|
||||
"quantity_confirmed": "✓ Quantity confirmed",
|
||||
"added_to_inventory": "✅ {name} added!",
|
||||
"removed_from_list": "✅ {name} removed from the list!",
|
||||
"removed_from_list_short": "Removed from the list",
|
||||
"added_to_shopping": "🛒 Added to the shopping list!",
|
||||
"removed_from_shopping": "🛒 Removed from the shopping list",
|
||||
"finished_to_bring": "🛒 Product finished → added to Bring!",
|
||||
"thrown_away": "🗑️ {name} thrown away!",
|
||||
"thrown_away_partial": "🗑️ Thrown away {qty} {unit} of {name}",
|
||||
"appliance_added": "Appliance added",
|
||||
"item_added": "{name} added"
|
||||
},
|
||||
"error": {
|
||||
"generic": "Error",
|
||||
"loading": "Error loading product",
|
||||
"not_found": "Product not found",
|
||||
"not_found_manual": "Product not found. Enter it manually.",
|
||||
"search": "Search error. Try again.",
|
||||
"search_short": "Search error",
|
||||
"save": "Error saving",
|
||||
"connection": "Connection error",
|
||||
"camera": "Cannot access camera",
|
||||
"bring_add": "Error adding to Bring!",
|
||||
"bring_connection": "Bring! connection error",
|
||||
"identification": "Identification error",
|
||||
"barcode_empty": "Enter a barcode",
|
||||
"barcode_format": "Barcode must contain only numbers (4-14 digits)",
|
||||
"min_chars": "Type at least 2 characters",
|
||||
"not_in_inventory": "Product not in inventory",
|
||||
"appliance_exists": "Appliance already exists",
|
||||
"already_exists": "Already exists"
|
||||
},
|
||||
"confirm": {
|
||||
"remove_item": "Do you really want to remove this product from inventory?"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Edit {name}"
|
||||
},
|
||||
"screensaver": {
|
||||
"recipe_btn": "Recipes",
|
||||
"scan_btn": "Scan product"
|
||||
},
|
||||
"days": {
|
||||
"mon": "Monday",
|
||||
"tue": "Tuesday",
|
||||
"wed": "Wednesday",
|
||||
"thu": "Thursday",
|
||||
"fri": "Friday",
|
||||
"sat": "Saturday",
|
||||
"sun": "Sunday"
|
||||
},
|
||||
"meal_types": {
|
||||
"lunch": "Lunch",
|
||||
"dinner": "Dinner"
|
||||
},
|
||||
"scale": {
|
||||
"status_connected": "Scale connected",
|
||||
"status_searching": "Gateway connected, waiting for scale…",
|
||||
"status_disconnected": "Scale gateway unreachable",
|
||||
"status_error": "Gateway connection error",
|
||||
"not_connected": "Scale gateway not connected",
|
||||
"read_btn": "⚖️ Read from scale",
|
||||
"reading_title": "Scale reading",
|
||||
"place_on_scale": "Place the product on the scale…",
|
||||
"waiting_stable": "Weight will be captured automatically once the reading is stable.",
|
||||
"no_url": "Enter the gateway URL",
|
||||
"testing": "⏳ Testing connection…",
|
||||
"connected_ok": "Gateway connection successful!",
|
||||
"timeout": "Timeout: no response from gateway",
|
||||
"error_connect": "Cannot connect to gateway",
|
||||
"tab": "Smart Scale"
|
||||
}
|
||||
}
|
||||
"prediction": {
|
||||
"expected_qty": "Expected: {expected} {unit}",
|
||||
"actual_qty": "Current: {actual} {unit}",
|
||||
"check_suggestion": "Check or weigh the remaining quantity"
|
||||
}
|
||||
}
|
||||
+465
-449
@@ -1,462 +1,478 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "EverShelf",
|
||||
"loading": "Caricamento..."
|
||||
},
|
||||
"nav": {
|
||||
"title": "🏠 EverShelf",
|
||||
"home": "Home",
|
||||
"inventory": "Dispensa",
|
||||
"recipes": "Ricette",
|
||||
"shopping": "Spesa",
|
||||
"log": "Log"
|
||||
},
|
||||
"btn": {
|
||||
"back": "← Indietro",
|
||||
"save": "💾 Salva",
|
||||
"cancel": "✕ Annulla",
|
||||
"close": "Chiudi",
|
||||
"add": "✅ Aggiungi",
|
||||
"delete": "Elimina",
|
||||
"edit": "✏️ Modifica",
|
||||
"search": "🔍 Cerca",
|
||||
"go": "✅ Vai",
|
||||
"toggle_password": "👁️ Mostra/Nascondi",
|
||||
"load_more": "Carica altri...",
|
||||
"save_config": "💾 Salva Configurazione",
|
||||
"save_product": "💾 Salva Prodotto",
|
||||
"restart": "↺ Ricomincia",
|
||||
"reset_default": "↺ Ripristina default"
|
||||
},
|
||||
"locations": {
|
||||
"dispensa": "Dispensa",
|
||||
"frigo": "Frigo",
|
||||
"freezer": "Freezer",
|
||||
"altro": "Altro"
|
||||
},
|
||||
"categories": {
|
||||
"latticini": "Latticini",
|
||||
"carne": "Carne",
|
||||
"pesce": "Pesce",
|
||||
"frutta": "Frutta",
|
||||
"verdura": "Verdura",
|
||||
"pasta": "Pasta & Riso",
|
||||
"pane": "Pane & Forno",
|
||||
"surgelati": "Surgelati",
|
||||
"bevande": "Bevande",
|
||||
"condimenti": "Condimenti",
|
||||
"snack": "Snack & Dolci",
|
||||
"conserve": "Conserve",
|
||||
"cereali": "Cereali & Legumi",
|
||||
"igiene": "Igiene",
|
||||
"pulizia": "Pulizia Casa",
|
||||
"altro": "Altro",
|
||||
"select": "-- Seleziona --"
|
||||
},
|
||||
"units": {
|
||||
"pz": "pz",
|
||||
"conf": "conf",
|
||||
"g": "g",
|
||||
"ml": "ml",
|
||||
"pieces": "Pezzi",
|
||||
"grams": "Grammi",
|
||||
"box": "Confezione",
|
||||
"boxes": "Confezioni"
|
||||
},
|
||||
"shopping_sections": {
|
||||
"frutta_verdura": "Frutta & Verdura",
|
||||
"carne_pesce": "Carne & Pesce",
|
||||
"latticini": "Latticini & Fresco",
|
||||
"pane_dolci": "Pane & Dolci",
|
||||
"pasta": "Pasta & Cereali",
|
||||
"conserve": "Conserve & Salse",
|
||||
"surgelati": "Surgelati",
|
||||
"bevande": "Bevande",
|
||||
"pulizia_igiene": "Pulizia & Igiene",
|
||||
"altro": "Altro"
|
||||
},
|
||||
"dashboard": {
|
||||
"expired_title": "🚫 Scaduti",
|
||||
"expiring_title": "⏰ Prossime Scadenze",
|
||||
"stats_period": "📊 Ultimi 30 giorni",
|
||||
"opened_title": "📦 Prodotti Aperti",
|
||||
"review_title": "🔍 Da revisionare",
|
||||
"review_hint": "Quantità che sembrano anomale. Conferma se corrette o modifica.",
|
||||
"quick_recipe": "🍳 Ricetta veloce con prodotti in scadenza"
|
||||
},
|
||||
"inventory": {
|
||||
"title": "Dispensa",
|
||||
"filter_all": "Tutti",
|
||||
"search_placeholder": "🔍 Cerca prodotto...",
|
||||
"empty": "Nessun prodotto qui.\nScansiona un prodotto per aggiungerlo!",
|
||||
"no_items_found": "Nessuna voce di inventario trovata"
|
||||
},
|
||||
"scan": {
|
||||
"title": "Scansiona Prodotto",
|
||||
"mode_shopping": "🛒 Modalità Spesa",
|
||||
"mode_shopping_end": "✅ Fine spesa",
|
||||
"zoom": "Zoom",
|
||||
"barcode_placeholder": "Inserisci codice a barre...",
|
||||
"quick_name_divider": "oppure scrivi il nome",
|
||||
"quick_name_placeholder": "Es: Mele, Zucchine, Pane...",
|
||||
"manual_entry": "✏️ Inserimento Manuale",
|
||||
"ai_identify": "🤖 Identifica con AI",
|
||||
"hint": "Scansiona il barcode, scrivi il nome del prodotto, oppure usa l'AI per identificarlo",
|
||||
"debug_toggle": "🐛 Debug Log",
|
||||
"barcode_acquired": "🔖 Barcode acquisito: {code}",
|
||||
"scan_barcode": "🔖 Scansiona Barcode"
|
||||
},
|
||||
"action": {
|
||||
"title": "Cosa vuoi fare?",
|
||||
"add_btn": "📥 AGGIUNGI",
|
||||
"add_sub": "in dispensa/frigo",
|
||||
"use_btn": "📤 USA / CONSUMA",
|
||||
"use_sub": "dalla dispensa/frigo"
|
||||
},
|
||||
"add": {
|
||||
"title": "Aggiungi alla Dispensa",
|
||||
"location_label": "📍 Dove lo metti?",
|
||||
"quantity_label": "📦 Quantità",
|
||||
"conf_size_label": "📦 Ogni confezione contiene:",
|
||||
"conf_size_placeholder": "es. 300",
|
||||
"vacuum_label": "🫙 Sotto vuoto",
|
||||
"vacuum_hint": "La scadenza verrà estesa automaticamente",
|
||||
"submit": "✅ Aggiungi"
|
||||
},
|
||||
"use": {
|
||||
"title": "Usa / Consuma",
|
||||
"location_label": "📍 Da dove?",
|
||||
"quantity_label": "Quanto hai usato?",
|
||||
"partial_hint": "Oppure specifica la quantità usata:",
|
||||
"use_all": "🗑️ Usato TUTTO / Finito",
|
||||
"submit": "📤 Usa questa quantità",
|
||||
"available": "📦 Disponibile:",
|
||||
"not_in_inventory": "⚠️ Prodotto non presente nell'inventario.",
|
||||
"expiry_warning": "⚠️ Usa prima quella{loc} che scade il {date} — {when}!"
|
||||
},
|
||||
"product": {
|
||||
"title_new": "Nuovo Prodotto",
|
||||
"title_edit": "Modifica Prodotto",
|
||||
"ai_fill": "📷 Scatta foto e identifica con AI",
|
||||
"ai_fill_hint": "L'AI compilerà automaticamente i campi del prodotto",
|
||||
"name_label": "🏷️ Nome Prodotto *",
|
||||
"name_placeholder": "Es: Latte intero, Pasta penne rigate...",
|
||||
"brand_label": "🏢 Marca",
|
||||
"brand_placeholder": "Es: Barilla, Granarolo, Mutti...",
|
||||
"category_label": "📂 Categoria",
|
||||
"unit_label": "📏 Unità di misura",
|
||||
"default_qty_label": "🔢 Quantità default",
|
||||
"conf_size_label": "📦 Ogni confezione contiene:",
|
||||
"conf_size_placeholder": "es. 300",
|
||||
"notes_label": "📝 Note",
|
||||
"notes_placeholder": "Es: senza lattosio, bio, conservare in frigo dopo apertura...",
|
||||
"barcode_label": "🔖 Barcode",
|
||||
"barcode_placeholder": "Codice a barre (se disponibile)",
|
||||
"barcode_hint": "⚠️ Aggiungi il barcode così al prossimo acquisto basta scansionarlo!",
|
||||
"submit": "💾 Salva Prodotto",
|
||||
"name_required": "Inserisci il nome del prodotto",
|
||||
"conf_size_required": "Specifica il contenuto di ogni confezione",
|
||||
"expiry_estimated": "Scadenza stimata:",
|
||||
"scan_expiry": "Scansiona data scadenza",
|
||||
"expiry_hint": "📝 Puoi modificare la data o scansionarla con la fotocamera",
|
||||
"add_batch": "📦 + Lotto con scadenza diversa",
|
||||
"package_info": "📦 Confezione: {info}",
|
||||
"edit_catalog": "⚙️ Modifica scheda prodotto (nome, marca, categoria…)",
|
||||
"not_recognized": "⚠️ Prodotto non riconosciuto",
|
||||
"edit_info": "✏️ Modifica informazioni",
|
||||
"modify_details": "MODIFICA\nscadenza, luogo…"
|
||||
},
|
||||
"products": {
|
||||
"title": "📦 Tutti i Prodotti",
|
||||
"search_placeholder": "🔍 Cerca prodotto...",
|
||||
"empty": "Nessun prodotto nel database.\nScansiona un prodotto per iniziare!",
|
||||
"no_category": "Nessun prodotto in questa categoria"
|
||||
},
|
||||
"recipes": {
|
||||
"title": "🍳 Ricette",
|
||||
"generate": "✨ Genera nuova ricetta"
|
||||
},
|
||||
"shopping": {
|
||||
"title": "🛒 Lista della Spesa",
|
||||
"bring_loading": "Connessione a Bring!...",
|
||||
"tab_to_buy": "🛍️ Da comprare",
|
||||
"tab_forecast": "🧠 In previsione",
|
||||
"total_label": "💰 Totale stimato",
|
||||
"section_to_buy": "🛍️ Da comprare",
|
||||
"suggestions_title": "💡 Suggerimenti AI",
|
||||
"suggestions_add": "✅ Aggiungi selezionati a Bring!",
|
||||
"search_prices": "🔍 Cerca tutti i prezzi",
|
||||
"suggest_btn": "🤖 Suggerisci cosa comprare",
|
||||
"smart_title": "🧠 Previsioni intelligenti",
|
||||
"smart_empty": "Nessuna previsione disponibile.<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"
|
||||
"app": {
|
||||
"name": "EverShelf",
|
||||
"loading": "Caricamento..."
|
||||
},
|
||||
"bring": {
|
||||
"title": "🛒 Bring! Shopping List",
|
||||
"hint": "Credenziali per l'integrazione con la lista della spesa Bring!",
|
||||
"email_label": "📧 Email Bring!",
|
||||
"password_label": "🔒 Password Bring!"
|
||||
"nav": {
|
||||
"title": "🏠 EverShelf",
|
||||
"home": "Home",
|
||||
"inventory": "Dispensa",
|
||||
"recipes": "Ricette",
|
||||
"shopping": "Spesa",
|
||||
"log": "Log"
|
||||
},
|
||||
"recipe": {
|
||||
"title": "🍳 Preferenze Ricette",
|
||||
"hint": "Configura le opzioni predefinite per la generazione delle ricette.",
|
||||
"persons_label": "👥 Persone predefinite",
|
||||
"options_label": "🎯 Opzioni ricetta predefinite",
|
||||
"fast": "⚡ Pasto Veloce",
|
||||
"light": "🥗 Poca Fame",
|
||||
"expiry": "⏰ Priorità Scadenze",
|
||||
"healthy": "💚 Extra Salutare",
|
||||
"opened": "📦 Priorità Cose Aperte",
|
||||
"zerowaste": "♻️ Zero Sprechi",
|
||||
"dietary_label": "🚫 Intolleranze / Restrizioni",
|
||||
"dietary_placeholder": "Es: senza glutine, senza lattosio, vegetariano..."
|
||||
"btn": {
|
||||
"back": "← Indietro",
|
||||
"save": "💾 Salva",
|
||||
"cancel": "✕ Annulla",
|
||||
"close": "Chiudi",
|
||||
"add": "✅ Aggiungi",
|
||||
"delete": "Elimina",
|
||||
"edit": "✏️ Modifica",
|
||||
"search": "🔍 Cerca",
|
||||
"go": "✅ Vai",
|
||||
"toggle_password": "👁️ Mostra/Nascondi",
|
||||
"load_more": "Carica altri...",
|
||||
"save_config": "💾 Salva Configurazione",
|
||||
"save_product": "💾 Salva Prodotto",
|
||||
"restart": "↺ Ricomincia",
|
||||
"reset_default": "↺ Ripristina default"
|
||||
},
|
||||
"mealplan": {
|
||||
"title": "📅 Piano Pasti Settimanale",
|
||||
"hint": "Imposta la tipologia di pasto per ogni giorno. Sarà usata come guida nella generazione delle ricette.",
|
||||
"enabled": "✅ Attiva piano pasti settimanale",
|
||||
"legend": "🌤️ = Pranzo · 🌙 = Cena · Tocca un badge per cambiarlo.",
|
||||
"types_title": "📋 Tipologie disponibili"
|
||||
"locations": {
|
||||
"dispensa": "Dispensa",
|
||||
"frigo": "Frigo",
|
||||
"freezer": "Freezer",
|
||||
"altro": "Altro"
|
||||
},
|
||||
"appliances": {
|
||||
"title": "🔌 Elettrodomestici Disponibili",
|
||||
"hint": "Indica gli elettrodomestici che hai a disposizione. Saranno considerati nella generazione delle ricette.",
|
||||
"new_placeholder": "Es: Macchina del pane, Bimby, Friggitrice ad aria...",
|
||||
"quick_title": "Aggiungi velocemente:",
|
||||
"oven": "🔥 Forno",
|
||||
"microwave": "📡 Microonde",
|
||||
"air_fryer": "🍟 Friggitrice ad aria",
|
||||
"bread_maker": "🍞 Macchina pane",
|
||||
"bimby": "🤖 Bimby/Cookeo",
|
||||
"mixer": "🌀 Planetaria",
|
||||
"steamer": "♨️ Vaporiera",
|
||||
"pressure_cooker": "🫕 Pentola pressione",
|
||||
"toaster": "🍞 Tostapane",
|
||||
"blender": "🍹 Frullatore",
|
||||
"empty": "Nessun elettrodomestico aggiunto"
|
||||
"categories": {
|
||||
"latticini": "Latticini",
|
||||
"carne": "Carne",
|
||||
"pesce": "Pesce",
|
||||
"frutta": "Frutta",
|
||||
"verdura": "Verdura",
|
||||
"pasta": "Pasta & Riso",
|
||||
"pane": "Pane & Forno",
|
||||
"surgelati": "Surgelati",
|
||||
"bevande": "Bevande",
|
||||
"condimenti": "Condimenti",
|
||||
"snack": "Snack & Dolci",
|
||||
"conserve": "Conserve",
|
||||
"cereali": "Cereali & Legumi",
|
||||
"igiene": "Igiene",
|
||||
"pulizia": "Pulizia Casa",
|
||||
"altro": "Altro",
|
||||
"select": "-- Seleziona --"
|
||||
},
|
||||
"spesa": {
|
||||
"title": "🛍️ Spesa Online",
|
||||
"hint": "Configura il provider per la spesa online.",
|
||||
"provider_label": "🏪 Provider",
|
||||
"email_label": "📧 Email",
|
||||
"password_label": "🔒 Password",
|
||||
"login_btn": "🔐 Accedi",
|
||||
"ai_prompt_label": "🤖 Prompt AI selezione prodotto",
|
||||
"ai_prompt_placeholder": "Istruzioni per l'AI quando deve scegliere tra più prodotti...",
|
||||
"ai_prompt_hint": "L'AI usa questo prompt per scegliere il prodotto più appropriato tra i risultati. Lascia vuoto per il comportamento predefinito.",
|
||||
"configure_first": "Configura prima la Spesa Online nelle impostazioni"
|
||||
"units": {
|
||||
"pz": "pz",
|
||||
"conf": "conf",
|
||||
"g": "g",
|
||||
"ml": "ml",
|
||||
"pieces": "Pezzi",
|
||||
"grams": "Grammi",
|
||||
"box": "Confezione",
|
||||
"boxes": "Confezioni"
|
||||
},
|
||||
"camera": {
|
||||
"title": "📷 Fotocamera",
|
||||
"hint": "Scegli quale fotocamera utilizzare per la scansione barcode e l'identificazione AI.",
|
||||
"device_label": "📸 Fotocamera predefinita",
|
||||
"back": "📱 Posteriore (default)",
|
||||
"front": "🤳 Anteriore",
|
||||
"devices_hint": "Se hai più fotocamere, puoi selezionarne una specifica dall'elenco sopra dopo aver concesso i permessi.",
|
||||
"detect_btn": "🔄 Rileva fotocamere"
|
||||
"shopping_sections": {
|
||||
"frutta_verdura": "Frutta & Verdura",
|
||||
"carne_pesce": "Carne & Pesce",
|
||||
"latticini": "Latticini & Fresco",
|
||||
"pane_dolci": "Pane & Dolci",
|
||||
"pasta": "Pasta & Cereali",
|
||||
"conserve": "Conserve & Salse",
|
||||
"surgelati": "Surgelati",
|
||||
"bevande": "Bevande",
|
||||
"pulizia_igiene": "Pulizia & Igiene",
|
||||
"altro": "Altro"
|
||||
},
|
||||
"security": {
|
||||
"title": "🔒 Certificato HTTPS",
|
||||
"hint": "Se il browser mostra l'errore \"La connessione non è privata\" (ERR_CERT_AUTHORITY_INVALID), devi installare il certificato CA nel dispositivo.",
|
||||
"download_btn": "📥 Scarica Certificato CA"
|
||||
"dashboard": {
|
||||
"expired_title": "🚫 Scaduti",
|
||||
"expiring_title": "⏰ Prossime Scadenze",
|
||||
"stats_period": "📊 Ultimi 30 giorni",
|
||||
"opened_title": "📦 Prodotti Aperti",
|
||||
"review_title": "🔍 Da revisionare",
|
||||
"review_hint": "Quantità che sembrano anomale. Conferma se corrette o modifica.",
|
||||
"quick_recipe": "🍳 Ricetta veloce con prodotti in scadenza",
|
||||
"banner_review_title": "Quantità anomala",
|
||||
"banner_review_action_ok": "È corretto",
|
||||
"banner_review_action_edit": "Modifica",
|
||||
"banner_review_action_weigh": "Pesa",
|
||||
"banner_review_dismiss": "Ignora",
|
||||
"banner_prediction_title": "Consumo anomalo",
|
||||
"banner_prediction_hint": "Secondo le previsioni, questa quantità non corrisponde al consumo previsto.",
|
||||
"banner_prediction_action_confirm": "Confermo quantità",
|
||||
"banner_prediction_action_weigh": "Pesa con bilancia",
|
||||
"banner_prediction_action_edit": "Correggi"
|
||||
},
|
||||
"tts": {
|
||||
"title": "🔊 Voce & TTS",
|
||||
"hint": "Configura la sintesi vocale tramite qualsiasi API REST esterna. I passi della ricetta e i timer scaduti verranno inviati all'endpoint configurato.",
|
||||
"enabled": "✅ Attiva TTS",
|
||||
"url_label": "🌐 URL Endpoint",
|
||||
"method_label": "📡 Metodo HTTP",
|
||||
"auth_label": "🔐 Autenticazione",
|
||||
"auth_bearer": "Bearer Token",
|
||||
"auth_custom": "Header personalizzato",
|
||||
"auth_none": "Nessuna",
|
||||
"token_label": "🔑 Bearer Token",
|
||||
"custom_header_name": "📋 Nome header",
|
||||
"custom_header_value": "📋 Valore header",
|
||||
"content_type_label": "📄 Content-Type",
|
||||
"payload_key_label": "🗝️ Campo testo nel payload",
|
||||
"payload_key_hint": "Nome del campo JSON che conterrà il testo da leggere (es: message, text).",
|
||||
"extra_fields_label": "➕ Campi extra (JSON)",
|
||||
"extra_fields_placeholder": "{\"entity_id\": \"media_player.living_room\"}",
|
||||
"extra_fields_hint": "Campi aggiuntivi da includere nel payload, in formato JSON. Lascia vuoto se non necessario.",
|
||||
"test_btn": "🔊 Invia Test Vocale"
|
||||
"inventory": {
|
||||
"title": "Dispensa",
|
||||
"filter_all": "Tutti",
|
||||
"search_placeholder": "🔍 Cerca prodotto...",
|
||||
"empty": "Nessun prodotto qui.\nScansiona un prodotto per aggiungerlo!",
|
||||
"no_items_found": "Nessuna voce di inventario trovata"
|
||||
},
|
||||
"language": {
|
||||
"title": "🌐 Lingua / Language",
|
||||
"hint": "Seleziona la lingua dell'interfaccia. Select the interface language.",
|
||||
"label": "🌐 Lingua",
|
||||
"restart_notice": "La pagina verrà ricaricata per applicare la nuova lingua."
|
||||
"scan": {
|
||||
"title": "Scansiona Prodotto",
|
||||
"mode_shopping": "🛒 Modalità Spesa",
|
||||
"mode_shopping_end": "✅ Fine spesa",
|
||||
"zoom": "Zoom",
|
||||
"barcode_placeholder": "Inserisci codice a barre...",
|
||||
"quick_name_divider": "oppure scrivi il nome",
|
||||
"quick_name_placeholder": "Es: Mele, Zucchine, Pane...",
|
||||
"manual_entry": "✏️ Inserimento Manuale",
|
||||
"ai_identify": "🤖 Identifica con AI",
|
||||
"hint": "Scansiona il barcode, scrivi il nome del prodotto, oppure usa l'AI per identificarlo",
|
||||
"debug_toggle": "🐛 Debug Log",
|
||||
"barcode_acquired": "🔖 Barcode acquisito: {code}",
|
||||
"scan_barcode": "🔖 Scansiona Barcode"
|
||||
},
|
||||
"action": {
|
||||
"title": "Cosa vuoi fare?",
|
||||
"add_btn": "📥 AGGIUNGI",
|
||||
"add_sub": "in dispensa/frigo",
|
||||
"use_btn": "📤 USA / CONSUMA",
|
||||
"use_sub": "dalla dispensa/frigo"
|
||||
},
|
||||
"add": {
|
||||
"title": "Aggiungi alla Dispensa",
|
||||
"location_label": "📍 Dove lo metti?",
|
||||
"quantity_label": "📦 Quantità",
|
||||
"conf_size_label": "📦 Ogni confezione contiene:",
|
||||
"conf_size_placeholder": "es. 300",
|
||||
"vacuum_label": "🫙 Sotto vuoto",
|
||||
"vacuum_hint": "La scadenza verrà estesa automaticamente",
|
||||
"submit": "✅ Aggiungi"
|
||||
},
|
||||
"use": {
|
||||
"title": "Usa / Consuma",
|
||||
"location_label": "📍 Da dove?",
|
||||
"quantity_label": "Quanto hai usato?",
|
||||
"partial_hint": "Oppure specifica la quantità usata:",
|
||||
"use_all": "🗑️ Usato TUTTO / Finito",
|
||||
"submit": "📤 Usa questa quantità",
|
||||
"available": "📦 Disponibile:",
|
||||
"not_in_inventory": "⚠️ Prodotto non presente nell'inventario.",
|
||||
"expiry_warning": "⚠️ Usa prima quella{loc} che scade il {date} — {when}!"
|
||||
},
|
||||
"product": {
|
||||
"title_new": "Nuovo Prodotto",
|
||||
"title_edit": "Modifica Prodotto",
|
||||
"ai_fill": "📷 Scatta foto e identifica con AI",
|
||||
"ai_fill_hint": "L'AI compilerà automaticamente i campi del prodotto",
|
||||
"name_label": "🏷️ Nome Prodotto *",
|
||||
"name_placeholder": "Es: Latte intero, Pasta penne rigate...",
|
||||
"brand_label": "🏢 Marca",
|
||||
"brand_placeholder": "Es: Barilla, Granarolo, Mutti...",
|
||||
"category_label": "📂 Categoria",
|
||||
"unit_label": "📏 Unità di misura",
|
||||
"default_qty_label": "🔢 Quantità default",
|
||||
"conf_size_label": "📦 Ogni confezione contiene:",
|
||||
"conf_size_placeholder": "es. 300",
|
||||
"notes_label": "📝 Note",
|
||||
"notes_placeholder": "Es: senza lattosio, bio, conservare in frigo dopo apertura...",
|
||||
"barcode_label": "🔖 Barcode",
|
||||
"barcode_placeholder": "Codice a barre (se disponibile)",
|
||||
"barcode_hint": "⚠️ Aggiungi il barcode così al prossimo acquisto basta scansionarlo!",
|
||||
"submit": "💾 Salva Prodotto",
|
||||
"name_required": "Inserisci il nome del prodotto",
|
||||
"conf_size_required": "Specifica il contenuto di ogni confezione",
|
||||
"expiry_estimated": "Scadenza stimata:",
|
||||
"scan_expiry": "Scansiona data scadenza",
|
||||
"expiry_hint": "📝 Puoi modificare la data o scansionarla con la fotocamera",
|
||||
"add_batch": "📦 + Lotto con scadenza diversa",
|
||||
"package_info": "📦 Confezione: {info}",
|
||||
"edit_catalog": "⚙️ Modifica scheda prodotto (nome, marca, categoria…)",
|
||||
"not_recognized": "⚠️ Prodotto non riconosciuto",
|
||||
"edit_info": "✏️ Modifica informazioni",
|
||||
"modify_details": "MODIFICA\nscadenza, luogo…"
|
||||
},
|
||||
"products": {
|
||||
"title": "📦 Tutti i Prodotti",
|
||||
"search_placeholder": "🔍 Cerca prodotto...",
|
||||
"empty": "Nessun prodotto nel database.\nScansiona un prodotto per iniziare!",
|
||||
"no_category": "Nessun prodotto in questa categoria"
|
||||
},
|
||||
"recipes": {
|
||||
"title": "🍳 Ricette",
|
||||
"generate": "✨ Genera nuova ricetta"
|
||||
},
|
||||
"shopping": {
|
||||
"title": "🛒 Lista della Spesa",
|
||||
"bring_loading": "Connessione a Bring!...",
|
||||
"tab_to_buy": "🛍️ Da comprare",
|
||||
"tab_forecast": "🧠 In previsione",
|
||||
"total_label": "💰 Totale stimato",
|
||||
"section_to_buy": "🛍️ Da comprare",
|
||||
"suggestions_title": "💡 Suggerimenti AI",
|
||||
"suggestions_add": "✅ Aggiungi selezionati a Bring!",
|
||||
"search_prices": "🔍 Cerca tutti i prezzi",
|
||||
"suggest_btn": "🤖 Suggerisci cosa comprare",
|
||||
"smart_title": "🧠 Previsioni intelligenti",
|
||||
"smart_empty": "Nessuna previsione disponibile.<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": {
|
||||
"title": "⚖️ Bilancia Smart",
|
||||
"hint": "Collega una bilancia Bluetooth tramite il gateway Android per leggere il peso automaticamente.",
|
||||
"tab": "Bilancia Smart",
|
||||
"enabled": "✅ Abilita bilancia smart",
|
||||
"url_label": "🌐 URL Gateway WebSocket",
|
||||
"url_placeholder": "ws://192.168.1.x:8765",
|
||||
"url_hint": "URL mostrato dall'app Android (stessa rete Wi-Fi). Es:",
|
||||
"test_btn": "🔗 Testa connessione",
|
||||
"download_btn": "📥 Scarica Gateway Android (APK)",
|
||||
"download_hint": "App Android che fa da ponte tra la bilancia BLE e questo sito.",
|
||||
"download_sub": "Sorgente: evershelf-scale-gateway/ nella root del progetto"
|
||||
"status_connected": "Bilancia connessa",
|
||||
"status_searching": "Connesso al gateway, attesa bilancia…",
|
||||
"status_disconnected": "Gateway bilancia non raggiungibile",
|
||||
"status_error": "Errore connessione gateway",
|
||||
"not_connected": "Gateway bilancia non connesso",
|
||||
"read_btn": "⚖️ Leggi dalla bilancia",
|
||||
"reading_title": "Lettura bilancia",
|
||||
"place_on_scale": "Metti il prodotto sulla bilancia…",
|
||||
"waiting_stable": "Il peso venire rilevato automaticamente quando la lettura sarà stabile.",
|
||||
"no_url": "Inserisci l'URL del gateway",
|
||||
"testing": "⏳ Test connessione…",
|
||||
"connected_ok": "Connessione gateway riuscita!",
|
||||
"timeout": "Timeout: nessuna risposta dal gateway",
|
||||
"error_connect": "Impossibile connettersi al gateway",
|
||||
"tab": "Bilancia Smart",
|
||||
"low_weight": "Peso < 10 g · inserisci manualmente\n(la lettura automatica richiede almeno 10 g)"
|
||||
},
|
||||
"saved": "✅ Configurazione salvata!",
|
||||
"saved_local": "✅ Configurazione salvata localmente",
|
||||
"saved_local_error": "⚠️ Salvato localmente, errore server: {error}"
|
||||
},
|
||||
"expiry": {
|
||||
"today": "OGGI",
|
||||
"tomorrow": "Domani",
|
||||
"days": "{days} giorni",
|
||||
"expired_days": "Da {days}g",
|
||||
"expired_yesterday": "Da ieri",
|
||||
"expired_today": "Oggi"
|
||||
},
|
||||
"status": {
|
||||
"ok": "OK",
|
||||
"check": "Controlla",
|
||||
"discard": "Buttare"
|
||||
},
|
||||
"toast": {
|
||||
"product_saved": "Prodotto salvato!",
|
||||
"product_created": "Prodotto creato!",
|
||||
"product_updated": "✅ Prodotto aggiornato!",
|
||||
"product_removed": "Prodotto rimosso",
|
||||
"updated": "Aggiornato!",
|
||||
"quantity_confirmed": "✓ Quantità confermata",
|
||||
"added_to_inventory": "✅ {name} aggiunto!",
|
||||
"removed_from_list": "✅ {name} rimosso dalla lista!",
|
||||
"removed_from_list_short": "Rimosso dalla lista",
|
||||
"added_to_shopping": "🛒 Aggiunto alla lista della spesa!",
|
||||
"removed_from_shopping": "🛒 Rimosso dalla lista della spesa",
|
||||
"finished_to_bring": "🛒 Prodotto finito → aggiunto a Bring!",
|
||||
"thrown_away": "🗑️ {name} buttato!",
|
||||
"thrown_away_partial": "🗑️ Buttato {qty} {unit} di {name}",
|
||||
"appliance_added": "Elettrodomestico aggiunto",
|
||||
"item_added": "{name} aggiunto"
|
||||
},
|
||||
"error": {
|
||||
"generic": "Errore",
|
||||
"loading": "Errore nel caricamento del prodotto",
|
||||
"not_found": "Prodotto non trovato",
|
||||
"not_found_manual": "Prodotto non trovato. Inseriscilo manualmente.",
|
||||
"search": "Errore nella ricerca. Riprova.",
|
||||
"search_short": "Errore nella ricerca",
|
||||
"save": "Errore nel salvataggio",
|
||||
"connection": "Errore di connessione",
|
||||
"camera": "Impossibile accedere alla fotocamera",
|
||||
"bring_add": "Errore nell'aggiunta a Bring!",
|
||||
"bring_connection": "Errore connessione Bring!",
|
||||
"identification": "Errore nell'identificazione",
|
||||
"barcode_empty": "Inserisci un codice a barre",
|
||||
"barcode_format": "Il codice a barre deve contenere solo numeri (4-14 cifre)",
|
||||
"min_chars": "Scrivi almeno 2 caratteri",
|
||||
"not_in_inventory": "Prodotto non nell'inventario",
|
||||
"appliance_exists": "Elettrodomestico già presente",
|
||||
"already_exists": "Già presente"
|
||||
},
|
||||
"confirm": {
|
||||
"remove_item": "Vuoi davvero rimuovere questo prodotto dall'inventario?"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Modifica {name}"
|
||||
},
|
||||
"screensaver": {
|
||||
"recipe_btn": "Ricette",
|
||||
"scan_btn": "Scansiona prodotto"
|
||||
},
|
||||
"days": {
|
||||
"mon": "Lunedì",
|
||||
"tue": "Martedì",
|
||||
"wed": "Mercoledì",
|
||||
"thu": "Giovedì",
|
||||
"fri": "Venerdì",
|
||||
"sat": "Sabato",
|
||||
"sun": "Domenica"
|
||||
},
|
||||
"meal_types": {
|
||||
"lunch": "Pranzo",
|
||||
"dinner": "Cena"
|
||||
},
|
||||
"scale": {
|
||||
"status_connected": "Bilancia connessa",
|
||||
"status_searching": "Connesso al gateway, attesa bilancia…",
|
||||
"status_disconnected": "Gateway bilancia non raggiungibile",
|
||||
"status_error": "Errore connessione gateway",
|
||||
"not_connected": "Gateway bilancia non connesso",
|
||||
"read_btn": "⚖️ Leggi dalla bilancia",
|
||||
"reading_title": "Lettura bilancia",
|
||||
"place_on_scale": "Metti il prodotto sulla bilancia…",
|
||||
"waiting_stable": "Il peso venire rilevato automaticamente quando la lettura sarà stabile.",
|
||||
"no_url": "Inserisci l'URL del gateway",
|
||||
"testing": "⏳ Test connessione…",
|
||||
"connected_ok": "Connessione gateway riuscita!",
|
||||
"timeout": "Timeout: nessuna risposta dal gateway",
|
||||
"error_connect": "Impossibile connettersi al gateway",
|
||||
"tab": "Bilancia Smart"
|
||||
}
|
||||
}
|
||||
"prediction": {
|
||||
"expected_qty": "Previsto: {expected} {unit}",
|
||||
"actual_qty": "Attuale: {actual} {unit}",
|
||||
"check_suggestion": "Verifica o pesa la quantità residua"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user