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

- Banner notification system: suspicious quantities + consumption prediction alerts
- Consumption predictions API: tracks 90-day usage patterns, flags >30% deviations
- Scale stability timeout: 5s → 10s, auto-confirm remains 5s
- Scale integration in edit form: weigh button with inline live display
- Banner edit/weigh actions open edit form directly with scale activation
- Cooking mode: Italian aliases + stem-prefix matching for ingredients
- Recipe regeneration: tracks rejected ingredients for diversity
- Settings migration: localStorage → .env server-side storage
- Expiry priority: mandatory ≤3 days, recommended ≤7 days in recipes
- Scale bug fixes: clear stale weight, double-submit guard, cap deduction
- Android kiosk app (evershelf-kiosk): WebView + embedded BLE scale gateway
- Version bump to 1.4.0
This commit is contained in:
dadaloop82
2026-04-16 14:46:30 +00:00
parent 3ff91b3018
commit 3e25fcd5df
25 changed files with 3431 additions and 1500 deletions
+276 -23
View File
@@ -189,6 +189,10 @@ try {
getStats($db); getStats($db);
break; break;
case 'consumption_predictions':
getConsumptionPredictions($db);
break;
// ===== AI ===== // ===== AI =====
case 'gemini_expiry': case 'gemini_expiry':
geminiReadExpiry(); geminiReadExpiry();
@@ -936,6 +940,8 @@ function useFromInventory(PDO $db): void {
} }
$newQty = max(0, $existing['quantity'] - $quantity); $newQty = max(0, $existing['quantity'] - $quantity);
// Cap actual deducted quantity to what was available (prevent phantom over-deduction)
$actualDeducted = min($quantity, $existing['quantity']);
if ($newQty <= 0) { if ($newQty <= 0) {
$stmt = $db->prepare("DELETE FROM inventory WHERE id = ?"); $stmt = $db->prepare("DELETE FROM inventory WHERE id = ?");
@@ -974,10 +980,10 @@ function useFromInventory(PDO $db): void {
} }
} }
// Log transaction // Log transaction (actual amount removed, not requested)
$type = ($notes === 'Buttato') ? 'waste' : 'out'; $type = ($notes === 'Buttato') ? 'waste' : 'out';
$stmt = $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, ?, ?, ?, ?)"); $stmt = $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, ?, ?, ?, ?)");
$stmt->execute([$productId, $type, $quantity, $location, $notes]); $stmt->execute([$productId, $type, $actualDeducted, $location, $notes]);
$remaining = $newQty; $remaining = $newQty;
@@ -1269,10 +1275,115 @@ function getStats(PDO $db): void {
]); ]);
} }
// ===== CONSUMPTION PREDICTIONS =====
/**
* Analyze transaction history to predict expected quantity of each product
* and flag items whose current quantity deviates significantly from the prediction.
*/
function getConsumptionPredictions(PDO $db): void {
// Get all current inventory items with their consumption history
$items = $db->query("
SELECT i.id AS inventory_id, i.product_id, i.quantity, i.location,
p.name, p.brand, p.unit, p.default_quantity, p.package_unit,
i.updated_at
FROM inventory i
JOIN products p ON p.id = i.product_id
WHERE i.quantity > 0
")->fetchAll(PDO::FETCH_ASSOC);
$predictions = [];
foreach ($items as $item) {
$pid = $item['product_id'];
$loc = $item['location'];
// Get last 90 days of 'out' transactions for this product+location
$txns = $db->prepare("
SELECT quantity, created_at
FROM transactions
WHERE product_id = ? AND location = ? AND type = 'out'
AND created_at >= datetime('now', '-90 days')
ORDER BY created_at ASC
");
$txns->execute([$pid, $loc]);
$rows = $txns->fetchAll(PDO::FETCH_ASSOC);
if (count($rows) < 3) continue; // Need at least 3 data points
// Calculate average daily consumption
$totalUsed = 0;
foreach ($rows as $r) $totalUsed += abs(floatval($r['quantity']));
$firstDate = strtotime($rows[0]['created_at']);
$lastDate = strtotime($rows[count($rows) - 1]['created_at']);
$daySpan = max(1, ($lastDate - $firstDate) / 86400);
$dailyRate = $totalUsed / $daySpan;
if ($dailyRate < 0.01) continue; // negligible consumption
// Get the most recent restock (last 'in' transaction)
$lastIn = $db->prepare("
SELECT quantity, created_at
FROM transactions
WHERE product_id = ? AND location = ? AND type = 'in'
ORDER BY created_at DESC
LIMIT 1
");
$lastIn->execute([$pid, $loc]);
$restock = $lastIn->fetch(PDO::FETCH_ASSOC);
if (!$restock) continue;
$restockDate = strtotime($restock['created_at']);
$restockQty = floatval($restock['quantity']);
$daysSinceRestock = max(1, (time() - $restockDate) / 86400);
// Predicted remaining qty = restock qty - (daily rate * days since restock)
$expectedQty = max(0, $restockQty - ($dailyRate * $daysSinceRestock));
$actualQty = floatval($item['quantity']);
// Flag if deviation > 30% and absolute diff > meaningful threshold
$deviation = abs($actualQty - $expectedQty);
$threshold = max($dailyRate * 3, 0.5); // at least 3 days worth or 0.5 units
$pctDev = $expectedQty > 0 ? ($deviation / $expectedQty) : ($actualQty > 0 ? 1 : 0);
if ($pctDev > 0.30 && $deviation > $threshold) {
$unit = $item['unit'];
// Format expected/actual in human units
if ($unit === 'conf' && $item['default_quantity'] > 0 && $item['package_unit']) {
$pu = $item['package_unit'];
$sz = floatval($item['default_quantity']);
$expDisplay = round($expectedQty * $sz);
$actDisplay = round($actualQty * $sz);
$displayUnit = $pu;
} else {
$expDisplay = round($expectedQty, 1);
$actDisplay = round($actualQty, 1);
$displayUnit = $unit;
}
$predictions[] = [
'inventory_id' => (int)$item['inventory_id'],
'product_id' => (int)$item['product_id'],
'name' => $item['name'],
'brand' => $item['brand'],
'location' => $item['location'],
'unit' => $displayUnit,
'expected_qty' => $expDisplay,
'actual_qty' => $actDisplay,
'daily_rate' => round($dailyRate, 3),
'deviation_pct'=> round($pctDev * 100),
];
}
}
echo json_encode(['success' => true, 'predictions' => $predictions]);
}
// ===== SETTINGS ===== // ===== SETTINGS =====
function getServerSettings(): void { function getServerSettings(): void {
// Return values for client — passwords are never exposed
$geminiKey = env('GEMINI_API_KEY'); $geminiKey = env('GEMINI_API_KEY');
$bringEmail = env('BRING_EMAIL'); $bringEmail = env('BRING_EMAIL');
@@ -1288,6 +1399,22 @@ function getServerSettings(): void {
'tts_content_type' => env('TTS_CONTENT_TYPE', 'application/json'), 'tts_content_type' => env('TTS_CONTENT_TYPE', 'application/json'),
'tts_payload_key' => env('TTS_PAYLOAD_KEY', 'message'), 'tts_payload_key' => env('TTS_PAYLOAD_KEY', 'message'),
'tts_enabled' => env('TTS_ENABLED', 'false') === 'true', 'tts_enabled' => env('TTS_ENABLED', 'false') === 'true',
// User preferences (now server-side)
'default_persons' => intval(env('DEFAULT_PERSONS', '1')),
'pref_veloce' => env('PREF_VELOCE', 'false') === 'true',
'pref_pocafame' => env('PREF_POCAFAME', 'false') === 'true',
'pref_scadenze' => env('PREF_SCADENZE', 'false') === 'true',
'pref_healthy' => env('PREF_HEALTHY', 'false') === 'true',
'pref_opened' => env('PREF_OPENED', 'false') === 'true',
'pref_zerowaste' => env('PREF_ZEROWASTE', 'false') === 'true',
'dietary' => env('DIETARY', ''),
'appliances' => env('APPLIANCES', '') ? explode(',', env('APPLIANCES', '')) : [],
'camera_facing' => env('CAMERA_FACING', 'environment'),
'scale_enabled' => env('SCALE_ENABLED', 'false') === 'true',
'scale_gateway_url' => env('SCALE_GATEWAY_URL', ''),
'spesa_provider' => env('SPESA_PROVIDER', 'bring'),
'spesa_ai_prompt' => env('SPESA_AI_PROMPT', ''),
'meal_plan_enabled' => env('MEAL_PLAN_ENABLED', 'false') === 'true',
]); ]);
} }
@@ -1296,15 +1423,58 @@ function saveSettings(): void {
$envFile = __DIR__ . '/../.env'; $envFile = __DIR__ . '/../.env';
$envVars = loadEnv(); $envVars = loadEnv();
// Update values from input — only overwrite if new value is non-empty // Map of input key → .env key — only update if present in input
if (!empty($input['gemini_key'])) { $keyMap = [
$envVars['GEMINI_API_KEY'] = $input['gemini_key']; 'gemini_key' => 'GEMINI_API_KEY',
'bring_email' => 'BRING_EMAIL',
'bring_password' => 'BRING_PASSWORD',
'tts_url' => 'TTS_URL',
'tts_token' => 'TTS_TOKEN',
'tts_method' => 'TTS_METHOD',
'tts_auth_type' => 'TTS_AUTH_TYPE',
'tts_content_type'=> 'TTS_CONTENT_TYPE',
'tts_payload_key' => 'TTS_PAYLOAD_KEY',
'camera_facing' => 'CAMERA_FACING',
'dietary' => 'DIETARY',
'scale_gateway_url' => 'SCALE_GATEWAY_URL',
'spesa_provider' => 'SPESA_PROVIDER',
'spesa_ai_prompt' => 'SPESA_AI_PROMPT',
];
// Boolean keys
$boolMap = [
'tts_enabled' => 'TTS_ENABLED',
'pref_veloce' => 'PREF_VELOCE',
'pref_pocafame' => 'PREF_POCAFAME',
'pref_scadenze' => 'PREF_SCADENZE',
'pref_healthy' => 'PREF_HEALTHY',
'pref_opened' => 'PREF_OPENED',
'pref_zerowaste' => 'PREF_ZEROWASTE',
'scale_enabled' => 'SCALE_ENABLED',
'meal_plan_enabled' => 'MEAL_PLAN_ENABLED',
];
// Integer keys
$intMap = [
'default_persons' => 'DEFAULT_PERSONS',
];
foreach ($keyMap as $inKey => $envKey) {
if (array_key_exists($inKey, $input)) {
$envVars[$envKey] = (string)$input[$inKey];
} }
if (!empty($input['bring_email'])) {
$envVars['BRING_EMAIL'] = $input['bring_email'];
} }
if (!empty($input['bring_password'])) { foreach ($boolMap as $inKey => $envKey) {
$envVars['BRING_PASSWORD'] = $input['bring_password']; if (array_key_exists($inKey, $input)) {
$envVars[$envKey] = $input[$inKey] ? 'true' : 'false';
}
}
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 // Write .env file
@@ -1314,6 +1484,10 @@ function saveSettings(): void {
} }
$result = file_put_contents($envFile, implode("\n", $lines) . "\n"); $result = file_put_contents($envFile, implode("\n", $lines) . "\n");
// Clear cached env
static $cache = null;
$cache = null;
if ($result !== false) { if ($result !== false) {
echo json_encode(['success' => true]); echo json_encode(['success' => true]);
} else { } else {
@@ -1579,6 +1753,7 @@ function generateRecipe(PDO $db): void {
$todayRecipes = $input['today_recipes'] ?? []; $todayRecipes = $input['today_recipes'] ?? [];
$mealPlanType = $input['meal_plan_type'] ?? ''; // e.g. 'pasta', 'pesce', 'legumi', ... $mealPlanType = $input['meal_plan_type'] ?? ''; // e.g. 'pasta', 'pesce', 'legumi', ...
$variation = max(0, intval($input['variation'] ?? 0)); // 0=first attempt, 1+=re-generation $variation = max(0, intval($input['variation'] ?? 0)); // 0=first attempt, 1+=re-generation
$rejectedIngredients = $input['rejected_ingredients'] ?? []; // ingredient names from previous rejected recipes
// Fetch all inventory items with expiry info // Fetch all inventory items with expiry info
$stmt = $db->query(" $stmt = $db->query("
@@ -1689,14 +1864,16 @@ function generateRecipe(PDO $db): void {
$label = $item['name'] . ($item['brand'] ? " ({$item['brand']})" : '') . $openNote . $expiryNote; $label = $item['name'] . ($item['brand'] ? " ({$item['brand']})" : '') . $openNote . $expiryNote;
if ($wantsExpiryPriority) { if ($wantsExpiryPriority) {
if ($g === 1 || ($g === 2 && $daysLeft <= 1)) { // Expired or expiring within 3 days → mandatory
if ($g === 1 || $g === 2) {
$mandatoryItems[] = $label; $mandatoryItems[] = $label;
} elseif ($g === 2) { // Expiring within 7 days → strongly recommended
} elseif ($g === 3) {
$recommendedItems[] = $label; $recommendedItems[] = $label;
} }
} }
if (($wantsOpenedPriority || $wantsExpiryPriority) && $isOpen && $daysLeft <= 5 && $daysLeft >= 0) { if (($wantsOpenedPriority || $wantsExpiryPriority) && $isOpen && $daysLeft <= 7 && $daysLeft >= 0) {
// Opened items expiring within 5 days but not already in mandatory/recommended // Opened items expiring within 7 days
if (!in_array($label, $mandatoryItems) && !in_array($label, $recommendedItems)) { if (!in_array($label, $mandatoryItems) && !in_array($label, $recommendedItems)) {
$recommendedItems[] = $label; $recommendedItems[] = $label;
} }
@@ -1870,6 +2047,13 @@ function generateRecipe(PDO $db): void {
"Devi proporre qualcosa di COMPLETAMENTE DIVERSO: stile di cucina diverso, ingrediente principale diverso, " . "Devi proporre qualcosa di COMPLETAMENTE DIVERSO: stile di cucina diverso, ingrediente principale diverso, " .
"tecnica di cottura diversa, piatto di un'altra tradizione culinaria o di un'altra categoria. " . "tecnica di cottura diversa, piatto di un'altra tradizione culinaria o di un'altra categoria. " .
"Non basta cambiare il nome della stessa idea. Sorprendi! Sii creativo!"; "Non basta cambiare il nome della stessa idea. Sorprendi! Sii creativo!";
if (!empty($rejectedIngredients)) {
$rejList = implode(', ', array_map(fn($n) => '"' . $n . '"', $rejectedIngredients));
$regenText .= "\n\n🚫 INGREDIENTI PRINCIPALI GIÀ RIFIUTATI DALL'UTENTE: {$rejList}\n" .
"NON usare NESSUNO di questi come ingrediente PRINCIPALE della nuova ricetta. " .
"Puoi usarli come ingrediente secondario solo se indispensabile. " .
"Scegli ingredienti principali completamente diversi dalla lista della dispensa!";
}
} }
$prompt = <<<PROMPT $prompt = <<<PROMPT
@@ -1894,6 +2078,8 @@ REGOLE IMPORTANTI:
6. La ricetta deve essere adatta al pasto: $mealLabel 6. La ricetta deve essere adatta al pasto: $mealLabel
7. IMPORTANTE - QUANTITÀ NUMERICHE: per ogni ingrediente dalla dispensa, il campo "qty_number" DEVE contenere il valore NUMERICO da scalare dall'inventario, espresso nella STESSA unità di misura della dispensa. Le unità ammesse sono SOLO: g (grammi), ml (millilitri), pz (pezzi), conf (confezioni). NON usare mai kg o litri. Esempio: se in dispensa c'è "Farina: 1000 g" e la ricetta richiede 200g, qty_number = 200. Se "Riso: 2000 g" e servono 300g, qty_number = 300. Per ingredienti non dalla dispensa, qty_number = 0. 7. IMPORTANTE - QUANTITÀ NUMERICHE: per ogni ingrediente dalla dispensa, il campo "qty_number" DEVE contenere il valore NUMERICO da scalare dall'inventario, espresso nella STESSA unità di misura della dispensa. Le unità ammesse sono SOLO: g (grammi), ml (millilitri), pz (pezzi), conf (confezioni). NON usare mai kg o litri. Esempio: se in dispensa c'è "Farina: 1000 g" e la ricetta richiede 200g, qty_number = 200. Se "Riso: 2000 g" e servono 300g, qty_number = 300. Per ingredienti non dalla dispensa, qty_number = 0.
8. GESTIONE SMART QUANTITÀ: NON lasciare rimasugli poco usabili in dispensa. Se un ingrediente ha una quantità piccola (es. 50g di formaggio, 1 uovo, 100ml di latte), preferisci usarlo TUTTO piuttosto che lasciarne una quantità inutilizzabile. Se invece la quantità è abbondante, usa solo il necessario lasciando abbastanza per un altro pasto. Pensa sempre: "quello che resta sarà sufficiente per un altro utilizzo?" 8. GESTIONE SMART QUANTITÀ: NON lasciare rimasugli poco usabili in dispensa. Se un ingrediente ha una quantità piccola (es. 50g di formaggio, 1 uovo, 100ml di latte), preferisci usarlo TUTTO piuttosto che lasciarne una quantità inutilizzabile. Se invece la quantità è abbondante, usa solo il necessario lasciando abbastanza per un altro pasto. Pensa sempre: "quello che resta sarà sufficiente per un altro utilizzo?"
9. NOMI INGREDIENTI: nel campo "name" di ogni ingrediente dalla dispensa, usa ESATTAMENTE lo stesso nome riportato nella lista sotto (copia-incolla). NON riformulare, NON abbreviare, NON tradurre. Il sistema usa il nome per collegare l'ingrediente all'inventario. Se il nome non corrisponde, l'ingrediente non viene scalato correttamente.
10. COMPLETEZZA: la lista ingredienti DEVE includere TUTTI gli ingredienti necessari citati nei passi della ricetta. Se un passo dice "aggiungere il latte", il latte DEVE comparire nella lista ingredienti. Non dare per scontato nessun ingrediente tranne acqua, sale, pepe e olio.
INGREDIENTI DISPONIBILI IN DISPENSA: INGREDIENTI DISPONIBILI IN DISPENSA:
$ingredientsText $ingredientsText
@@ -1966,14 +2152,49 @@ PROMPT;
if ($recipe && !empty($recipe['title'])) { if ($recipe && !empty($recipe['title'])) {
// Enrich from_pantry ingredients with product_id and location for "use" feature // Enrich from_pantry ingredients with product_id and location for "use" feature
if (!empty($recipe['ingredients'])) { if (!empty($recipe['ingredients'])) {
// Build a category map for better fuzzy matching
$itemsLookup = [];
foreach ($items as $item) {
$itemsLookup[] = [
'item' => $item,
'lower' => mb_strtolower(trim($item['name']), 'UTF-8'),
'words' => preg_split('/[\s,.\-\/]+/', mb_strtolower(trim($item['name']), 'UTF-8')),
'cat' => mb_strtolower($item['category'] ?? '', 'UTF-8'),
];
}
// Common Italian food name aliases for better matching
$aliases = [
'uovo' => ['uova','uovo','egg'],
'uova' => ['uovo','uova','egg'],
'latte' => ['latte','milk'],
'formaggio' => ['formaggio','cheese','philadelphia','mozzarella','parmigiano','grana','pecorino','ricotta','mascarpone','stracchino','gorgonzola'],
'pasta' => ['pasta','spaghetti','penne','fusilli','rigatoni','farfalle','tagliatelle','linguine','bucatini','orecchiette','paccheri','maccheroni'],
'pomodoro' => ['pomodoro','pomodori','tomato','passata','pelati','polpa'],
'cipolla' => ['cipolla','cipolle','onion'],
'aglio' => ['aglio','garlic'],
'burro' => ['burro','butter'],
'panna' => ['panna','cream','crema'],
'zucchero' => ['zucchero','sugar'],
'farina' => ['farina','flour'],
'olio' => ['olio','oil'],
'patata' => ['patata','patate','potato'],
'carota' => ['carota','carote','carrot'],
'sedano' => ['sedano','celery'],
'prezzemolo' => ['prezzemolo','parsley'],
'basilico' => ['basilico','basil'],
];
foreach ($recipe['ingredients'] as &$ing) { foreach ($recipe['ingredients'] as &$ing) {
if (!empty($ing['from_pantry'])) { if (!empty($ing['from_pantry'])) {
$ingNameLower = mb_strtolower(trim($ing['name']), 'UTF-8'); $ingNameLower = mb_strtolower(trim($ing['name']), 'UTF-8');
$ingWords = preg_split('/[\s,.\-\/]+/', $ingNameLower);
$bestMatch = null; $bestMatch = null;
$bestScore = 0; $bestScore = 0;
foreach ($items as $item) { foreach ($itemsLookup as $entry) {
$itemNameLower = mb_strtolower(trim($item['name']), 'UTF-8'); $itemNameLower = $entry['lower'];
$itemWords = $entry['words'];
$score = 0; $score = 0;
// Exact match // Exact match
@@ -1988,19 +2209,51 @@ PROMPT;
elseif (mb_strpos($ingNameLower, $itemNameLower) !== false) { elseif (mb_strpos($ingNameLower, $itemNameLower) !== false) {
$score = 70; $score = 70;
} }
// Word-level matching: check if key words overlap
else { else {
$ingWords = preg_split('/\s+/', $ingNameLower); // Word-level matching with alias expansion
$itemWords = preg_split('/\s+/', $itemNameLower); $expandedIngWords = $ingWords;
$common = array_intersect($ingWords, $itemWords); foreach ($ingWords as $w) {
if (count($common) > 0) { foreach ($aliases as $key => $group) {
$score = (count($common) / max(count($ingWords), 1)) * 60; if (in_array($w, $group) || mb_strpos($w, $key) === 0 || mb_strpos($key, $w) === 0) {
$expandedIngWords = array_merge($expandedIngWords, $group);
}
}
}
$expandedIngWords = array_unique($expandedIngWords);
$common = 0;
foreach ($expandedIngWords as $ew) {
foreach ($itemWords as $iw) {
// Partial stem match (min 4 chars shared prefix)
$minLen = min(mb_strlen($ew), mb_strlen($iw));
if ($minLen >= 3) {
$prefixLen = 0;
for ($c = 0; $c < $minLen; $c++) {
if (mb_substr($ew, $c, 1) === mb_substr($iw, $c, 1)) $prefixLen++;
else break;
}
if ($prefixLen >= min(4, $minLen)) { $common++; break; }
}
if ($ew === $iw) { $common++; break; }
}
}
if ($common > 0) {
$score = ($common / max(count($ingWords), 1)) * 65;
// Bonus: if the main/first ingredient word matches
if (count($ingWords) > 0 && $common > 0) {
foreach ($itemWords as $iw) {
if (mb_strpos($iw, $ingWords[0]) === 0 || mb_strpos($ingWords[0], $iw) === 0) {
$score += 10;
break;
}
}
}
} }
} }
if ($score > $bestScore) { if ($score > $bestScore) {
$bestScore = $score; $bestScore = $score;
$bestMatch = $item; $bestMatch = $entry['item'];
} }
} }
+109 -10
View File
@@ -879,24 +879,19 @@ body {
} }
.scale-live-box.scale-low-weight { .scale-live-box.scale-low-weight {
border-color: #dc2626; border-color: #dc2626;
background: #fef2f2;
animation: scaleLowWeightBlink 0.8s ease-in-out infinite alternate;
}
@media (prefers-color-scheme: dark) {
.scale-live-box.scale-low-weight {
background: #3b0000;
}
} }
.scale-low-weight .scale-live-val { .scale-low-weight .scale-live-val {
color: #dc2626 !important; color: #dc2626 !important;
animation: scaleLowTextBlink 0.65s ease-in-out infinite alternate;
} }
.scale-low-weight .scale-live-label { .scale-low-weight .scale-live-label {
color: #dc2626 !important; color: #dc2626 !important;
font-weight: 600; font-weight: 600;
animation: scaleLowTextBlink 0.65s ease-in-out infinite alternate;
} }
@keyframes scaleLowWeightBlink { @keyframes scaleLowTextBlink {
from { border-color: #dc2626; box-shadow: none; } from { opacity: 1; }
to { border-color: #dc2626; box-shadow: 0 0 0 3px rgba(220,38,38,0.25); } to { opacity: 0.2; }
} }
.btn-accent { .btn-accent {
@@ -4421,6 +4416,110 @@ body {
} }
/* ===== REVIEW SECTION ===== */ /* ===== REVIEW SECTION ===== */
/* ===== ALERT TOP BANNER ===== */
.alert-banner {
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
border: 1.5px solid #f59e0b;
border-radius: var(--radius);
margin-bottom: 12px;
overflow: hidden;
animation: bannerSlideIn 0.35s ease-out;
}
@keyframes bannerSlideIn {
from { opacity: 0; transform: translateY(-12px); }
to { opacity: 1; transform: translateY(0); }
}
.alert-banner.banner-prediction {
background: linear-gradient(135deg, #ede9fe 0%, #ddd6fe 100%);
border-color: #8b5cf6;
}
.alert-banner-inner {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 12px 8px;
}
.alert-banner-icon {
font-size: 1.5rem;
flex-shrink: 0;
line-height: 1;
}
.alert-banner-body {
flex: 1;
min-width: 0;
}
.alert-banner-title {
font-weight: 700;
font-size: 0.95rem;
color: #92400e;
line-height: 1.3;
}
.banner-prediction .alert-banner-title {
color: #5b21b6;
}
.alert-banner-detail {
font-size: 0.82rem;
color: #78716c;
margin-top: 2px;
line-height: 1.4;
}
.alert-banner-close {
flex-shrink: 0;
width: 28px;
height: 28px;
border-radius: 50%;
border: none;
background: rgba(0,0,0,0.08);
font-size: 0.9rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #78716c;
}
.alert-banner-actions {
display: flex;
gap: 8px;
padding: 0 12px 10px;
flex-wrap: wrap;
}
.alert-banner-actions .btn-banner {
flex: 1;
min-width: 80px;
padding: 8px 12px;
border-radius: 8px;
border: none;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
text-align: center;
}
.btn-banner-ok {
background: #d1fae5;
color: #059669;
}
.btn-banner-edit {
background: #e0e7ff;
color: #4338ca;
}
.btn-banner-weigh {
background: #f3e8ff;
color: #7c3aed;
}
.btn-banner-confirm {
background: #d1fae5;
color: #059669;
}
.alert-banner-counter {
font-size: 0.72rem;
color: #a1977a;
text-align: center;
padding: 0 12px 8px;
}
.banner-prediction .alert-banner-counter {
color: #7c6cb0;
}
.alert-review { .alert-review {
background: #fffbeb; background: #fffbeb;
border-color: #f59e0b; border-color: #f59e0b;
+324 -101
View File
@@ -70,7 +70,7 @@ let _scaleWeightCallback = null; // pending on-demand weight request callback
let _scaleLatestWeight = null; // last received weight message let _scaleLatestWeight = null; // last received weight message
let _scaleAutoConfirmTimer = null; // countdown timer for auto-confirm after stable weight let _scaleAutoConfirmTimer = null; // countdown timer for auto-confirm after stable weight
let _scaleAutoConfirmRAF = null; // rAF handle for auto-confirm progress bar animation let _scaleAutoConfirmRAF = null; // rAF handle for auto-confirm progress bar animation
let _scaleStabilityTimer = null; // setTimeout: wait 5 s stable before starting confirm bar let _scaleStabilityTimer = null; // setTimeout: wait 10 s stable before starting confirm bar
let _scaleStabilityRAF = null; // rAF handle for stability progress bar in the live box let _scaleStabilityRAF = null; // rAF handle for stability progress bar in the live box
let _scaleStabilityVal = null; // value we are currently timing for stability let _scaleStabilityVal = null; // value we are currently timing for stability
let _scaleUserDismissed = false; // user tapped or edited → don't retrigger for same value let _scaleUserDismissed = false; // user tapped or edited → don't retrigger for same value
@@ -120,6 +120,9 @@ function _scaleOnMessage(msg) {
// Update live reading modal overlay if visible (scale-read modal) // Update live reading modal overlay if visible (scale-read modal)
const live = document.getElementById('scale-reading-live'); const live = document.getElementById('scale-reading-live');
if (live) live.textContent = `${msg.value} ${msg.unit || 'kg'}${msg.stable ? ' ✓' : ' …'}`; if (live) live.textContent = `${msg.value} ${msg.unit || 'kg'}${msg.stable ? ' ✓' : ' …'}`;
// Also update edit-form inline scale reading if visible
const editLive = document.getElementById('edit-scale-reading');
if (editLive) editLive.textContent = `${msg.value} ${msg.unit || 'kg'}${msg.stable ? ' ✓' : ' …'}`;
// Always update the persistent live box on the use page (every message, stable or not) // Always update the persistent live box on the use page (every message, stable or not)
_scaleUpdateLiveBox(msg); _scaleUpdateLiveBox(msg);
// If weight is NOT stable: stop any running timer/bar but keep the sentinel value. // If weight is NOT stable: stop any running timer/bar but keep the sentinel value.
@@ -204,7 +207,7 @@ function _scaleUpdateLiveBox(msg) {
// Weight too low — show red flashing warning // Weight too low — show red flashing warning
box.classList.add('scale-low-weight'); box.classList.add('scale-low-weight');
if (valEl) valEl.textContent = `${raw} ${msg.unit || 'kg'}`; if (valEl) valEl.textContent = `${raw} ${msg.unit || 'kg'}`;
if (lblEl) lblEl.textContent = '< 10 g · inserisci manualmente'; if (lblEl) lblEl.textContent = t('scale.low_weight');
} else { } else {
box.classList.remove('scale-low-weight'); box.classList.remove('scale-low-weight');
const stIcon = msg.stable ? ' ✓' : ' …'; const stIcon = msg.stable ? ' ✓' : ' …';
@@ -436,12 +439,12 @@ function _cancelScaleStabilityWait() {
} }
/** /**
* Start a 5-second stability wait with an animated progress bar in the live box. * Start a 10-second stability wait with an animated progress bar in the live box.
* Calls onStable() when weight unchanged for 5 s. * Calls onStable() when weight unchanged for 10 s.
*/ */
function _startScaleStabilityWait(onStable) { function _startScaleStabilityWait(onStable) {
_cancelScaleStabilityWait(); _cancelScaleStabilityWait();
const duration = 5000; const duration = 10000;
const start = performance.now(); const start = performance.now();
const bar = document.getElementById('scale-live-progress-bar'); const bar = document.getElementById('scale-live-progress-bar');
@@ -535,6 +538,50 @@ function readScaleWeight(targetInputId, getUnit) {
// Weight data streams continuously via SSE; _scaleWeightCallback fires on the next stable reading // Weight data streams continuously via SSE; _scaleWeightCallback fires on the next stable reading
} }
/**
* Inline scale reading for the edit-inventory modal.
* Shows a live weight display inside the form and fills edit-qty on stable reading.
*/
function readScaleForEdit() {
if (!_scaleConnected) { showToast('⚖️ ' + t('scale.not_connected'), 'error'); return; }
const section = document.getElementById('edit-scale-section');
const btn = document.getElementById('btn-scale-edit');
if (section) section.style.display = '';
if (btn) btn.style.display = 'none';
_scaleWeightCallback = (msg) => {
const editQty = document.getElementById('edit-qty');
const editUnit = document.getElementById('edit-unit');
if (!editQty || !editUnit) return;
let unit = editUnit.value;
const isConf = unit === 'conf';
let confSize = 0;
if (isConf) confSize = parseFloat(document.getElementById('edit-conf-size')?.value) || 0;
let raw = parseFloat(msg.value);
const srcUnit = (msg.unit || 'kg').toLowerCase();
let grams;
if (srcUnit === 'kg') grams = raw * 1000;
else if (srcUnit === 'lbs' || srcUnit === 'lb') grams = raw * 453.592;
else if (srcUnit === 'oz') grams = raw * 28.3495;
else grams = raw; // g or ml
let val;
if (isConf && confSize > 0) {
val = Math.round((grams / confSize) * 100) / 100;
} else {
val = Math.round(grams);
}
editQty.value = val;
editQty.dispatchEvent(new Event('input'));
if (section) section.style.display = 'none';
if (btn) btn.style.display = '';
showToast(`⚖️ ${val} ${unit}`, 'success');
};
}
function _scaleShowReadingModal(targetInputId, unit) { function _scaleShowReadingModal(targetInputId, unit) {
document.getElementById('modal-content').innerHTML = ` document.getElementById('modal-content').innerHTML = `
<div class="modal-header"> <div class="modal-header">
@@ -1388,16 +1435,29 @@ function debounce(fn, ms) {
async function syncSettingsFromDB() { async function syncSettingsFromDB() {
try { try {
// Primary: load from server .env
const serverSettings = await api('get_settings');
const s = getSettings();
const serverKeys = ['default_persons','pref_veloce','pref_pocafame','pref_scadenze',
'pref_healthy','pref_opened','pref_zerowaste','dietary','appliances',
'camera_facing','scale_enabled','scale_gateway_url','spesa_provider',
'spesa_ai_prompt','meal_plan_enabled','tts_enabled','tts_url','tts_token',
'tts_method','tts_auth_type','tts_content_type','tts_payload_key'];
for (const key of serverKeys) {
if (serverSettings[key] !== undefined && serverSettings[key] !== null && serverSettings[key] !== '') {
s[key] = serverSettings[key];
}
}
_settingsCache = s;
localStorage.setItem('evershelf_settings', JSON.stringify(s));
// Also load review_confirmed from DB
const res = await api('app_settings_get'); const res = await api('app_settings_get');
if (res.success && res.settings) { if (res.success && res.settings) {
// Spesa credentials still come from DB (not .env)
if (res.settings.user_prefs) { if (res.settings.user_prefs) {
const db = res.settings.user_prefs; const db = res.settings.user_prefs;
const s = getSettings(); for (const key of ['spesa_email','spesa_password','spesa_logged_in',
// Merge DB settings into local (DB wins for shared prefs) 'spesa_user','spesa_data','spesa_token']) {
for (const key of ['default_persons','pref_veloce','pref_pocafame','pref_scadenze',
'pref_healthy','pref_opened','pref_zerowaste','dietary','appliances',
'spesa_provider','spesa_ai_prompt','spesa_email','spesa_password',
'spesa_logged_in','spesa_user','spesa_data','spesa_token']) {
if (db[key] !== undefined) s[key] = db[key]; if (db[key] !== undefined) s[key] = db[key];
} }
_settingsCache = s; _settingsCache = s;
@@ -1489,31 +1549,56 @@ async function loadSettingsUI() {
const ttsExtraEl = document.getElementById('setting-tts-extra-fields'); const ttsExtraEl = document.getElementById('setting-tts-extra-fields');
if (ttsExtraEl) ttsExtraEl.value = s.tts_extra_fields || ''; if (ttsExtraEl) ttsExtraEl.value = s.tts_extra_fields || '';
// Load server-side settings if not already set locally // Load server-side settings as primary source
try { try {
const serverSettings = await api('get_settings'); const serverSettings = await api('get_settings');
if (!s.gemini_key && serverSettings.gemini_key) { // Merge all server settings into local cache (server wins)
document.getElementById('setting-gemini-key').value = serverSettings.gemini_key; const serverKeys = ['gemini_key','bring_email','bring_password',
'default_persons','pref_veloce','pref_pocafame','pref_scadenze',
'pref_healthy','pref_opened','pref_zerowaste','dietary','appliances',
'camera_facing','scale_enabled','scale_gateway_url','spesa_provider',
'spesa_ai_prompt','meal_plan_enabled',
'tts_enabled','tts_url','tts_token','tts_method','tts_auth_type',
'tts_content_type','tts_payload_key'];
let changed = false;
for (const key of serverKeys) {
if (serverSettings[key] !== undefined && serverSettings[key] !== null && serverSettings[key] !== '') {
s[key] = serverSettings[key];
changed = true;
} }
if (!s.bring_email && serverSettings.bring_email) {
document.getElementById('setting-bring-email').value = serverSettings.bring_email;
} }
// Load TTS defaults from server .env if not set locally if (changed) {
if (!s.tts_url && serverSettings.tts_url) { _settingsCache = s;
s.tts_url = serverSettings.tts_url; localStorage.setItem('evershelf_settings', JSON.stringify(s));
s.tts_token = serverSettings.tts_token || ''; // Re-populate UI with merged values
s.tts_method = serverSettings.tts_method || 'POST'; document.getElementById('setting-gemini-key').value = s.gemini_key || '';
s.tts_auth_type = serverSettings.tts_auth_type || 'bearer'; document.getElementById('setting-bring-email').value = s.bring_email || '';
s.tts_content_type = serverSettings.tts_content_type || 'application/json'; document.getElementById('setting-bring-password').value = s.bring_password || '';
s.tts_payload_key = serverSettings.tts_payload_key || 'message'; document.getElementById('setting-default-persons').value = s.default_persons || 1;
s.tts_enabled = serverSettings.tts_enabled || false; document.getElementById('setting-pref-veloce').checked = !!s.pref_veloce;
saveSettingsToStorage(s); document.getElementById('setting-pref-pocafame').checked = !!s.pref_pocafame;
// Update UI fields with server values document.getElementById('setting-pref-scadenze').checked = !!s.pref_scadenze;
if (ttsUrlEl) ttsUrlEl.value = s.tts_url; document.getElementById('setting-pref-healthy').checked = !!s.pref_healthy;
if (ttsTokenEl) ttsTokenEl.value = s.tts_token; document.getElementById('setting-pref-opened').checked = !!s.pref_opened;
if (ttsEnabledEl) ttsEnabledEl.checked = s.tts_enabled; 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';
} }
} catch(e) { /* ignore */ } } catch(e) { /* offline, use local */ }
// Scale settings // Scale settings
const scaleEnabledUiEl = document.getElementById('setting-scale-enabled'); const scaleEnabledUiEl = document.getElementById('setting-scale-enabled');
if (scaleEnabledUiEl) scaleEnabledUiEl.checked = !!s.scale_enabled; if (scaleEnabledUiEl) scaleEnabledUiEl.checked = !!s.scale_enabled;
@@ -1647,12 +1732,34 @@ async function saveSettings() {
if (scaleUrlEl) s.scale_gateway_url = scaleUrlEl.value.trim(); if (scaleUrlEl) s.scale_gateway_url = scaleUrlEl.value.trim();
saveSettingsToStorage(s); saveSettingsToStorage(s);
// Also save to server .env // Save ALL settings to server .env
try { try {
const result = await api('save_settings', {}, 'POST', { const result = await api('save_settings', {}, 'POST', {
gemini_key: s.gemini_key, gemini_key: s.gemini_key,
bring_email: s.bring_email, bring_email: s.bring_email,
bring_password: s.bring_password bring_password: s.bring_password,
default_persons: s.default_persons,
pref_veloce: s.pref_veloce,
pref_pocafame: s.pref_pocafame,
pref_scadenze: s.pref_scadenze,
pref_healthy: s.pref_healthy,
pref_opened: s.pref_opened,
pref_zerowaste: s.pref_zerowaste,
dietary: s.dietary,
appliances: s.appliances,
camera_facing: s.camera_facing,
scale_enabled: s.scale_enabled,
scale_gateway_url: s.scale_gateway_url,
spesa_provider: s.spesa_provider,
spesa_ai_prompt: s.spesa_ai_prompt,
meal_plan_enabled: s.meal_plan_enabled,
tts_enabled: s.tts_enabled,
tts_url: s.tts_url,
tts_token: s.tts_token,
tts_method: s.tts_method,
tts_auth_type: s.tts_auth_type,
tts_content_type: s.tts_content_type,
tts_payload_key: s.tts_payload_key,
}); });
const statusEl = document.getElementById('settings-status'); const statusEl = document.getElementById('settings-status');
if (result.success) { if (result.success) {
@@ -1871,8 +1978,8 @@ async function loadDashboard() {
expiredSection.style.display = 'none'; expiredSection.style.display = 'none';
} }
// Review suspicious quantities // Banner alerts (suspicious quantities + consumption predictions)
loadReviewItems(); loadBannerAlerts();
// Waste vs consumption chart // Waste vs consumption chart
const wasteSection = document.getElementById('waste-chart-section'); const wasteSection = document.getElementById('waste-chart-section');
@@ -2007,7 +2114,7 @@ function quickRecipeSuggestion() {
}, 500); }, 500);
} }
// === SUSPICIOUS QUANTITY REVIEW === // === SUSPICIOUS QUANTITY THRESHOLDS ===
const QTY_THRESHOLDS = { const QTY_THRESHOLDS = {
'pz': { min: 0.3, max: 50 }, 'pz': { min: 0.3, max: 50 },
'conf': { min: 0.3, max: 50 }, 'conf': { min: 0.3, max: 50 },
@@ -2018,17 +2125,16 @@ const QTY_THRESHOLDS = {
function isSuspiciousQty(qty, unit) { function isSuspiciousQty(qty, unit) {
const n = parseFloat(qty); const n = parseFloat(qty);
if (isNaN(n) || n <= 0) return false; if (isNaN(n) || n <= 0) return false;
const t = QTY_THRESHOLDS[unit] || QTY_THRESHOLDS['pz']; const th = QTY_THRESHOLDS[unit] || QTY_THRESHOLDS['pz'];
return n < t.min || n > t.max; return n < th.min || n > th.max;
} }
function isSuspiciousDefaultQty(defaultQty, unit, packageUnit) { function isSuspiciousDefaultQty(defaultQty, unit, packageUnit) {
const n = parseFloat(defaultQty); const n = parseFloat(defaultQty);
if (!n || n <= 0) return false; if (!n || n <= 0) return false;
// For conf products, default_quantity is in package_unit (g, ml, etc.)
const checkUnit = (unit === 'conf' && packageUnit) ? packageUnit : unit; const checkUnit = (unit === 'conf' && packageUnit) ? packageUnit : unit;
const t = QTY_THRESHOLDS[checkUnit] || QTY_THRESHOLDS['pz']; const th = QTY_THRESHOLDS[checkUnit] || QTY_THRESHOLDS['pz'];
return n > t.max; return n > th.max;
} }
function getReviewConfirmed() { function getReviewConfirmed() {
@@ -2040,87 +2146,170 @@ function setReviewConfirmed(inventoryId) {
const c = getReviewConfirmed(); const c = getReviewConfirmed();
c[inventoryId] = Date.now(); c[inventoryId] = Date.now();
_reviewConfirmedCache = c; _reviewConfirmedCache = c;
// Persist to shared DB
api('app_settings_save', {}, 'POST', { settings: { review_confirmed: c } }).catch(() => {}); api('app_settings_save', {}, 'POST', { settings: { review_confirmed: c } }).catch(() => {});
} }
async function loadReviewItems() { // === ALERT BANNER SYSTEM (replaces old review table) ===
const section = document.getElementById('alert-review'); let _bannerQueue = []; // array of { type, data } — 'review' or 'prediction'
const list = document.getElementById('review-list'); let _bannerIndex = 0;
/**
* Load suspicious quantities + consumption predictions, merge into a single
* banner queue and show the first item.
*/
async function loadBannerAlerts() {
_bannerQueue = [];
_bannerIndex = 0;
const banner = document.getElementById('alert-banner');
if (!banner) { console.warn('[Banner] #alert-banner not found'); return; }
try { try {
const data = await api('inventory_list'); const [invData, predData] = await Promise.all([
const items = data.inventory || []; api('inventory_list'),
api('consumption_predictions').catch(err => { console.warn('[Banner] predictions fetch failed:', err); return { predictions: [] }; }),
]);
const items = invData.inventory || [];
const confirmed = getReviewConfirmed(); const confirmed = getReviewConfirmed();
const suspicious = items.filter(item => { // 1. Suspicious quantities
if (confirmed[item.id]) return false; items.forEach(item => {
return isSuspiciousQty(item.quantity, item.unit) || isSuspiciousDefaultQty(item.default_quantity, item.unit, item.package_unit); if (confirmed[item.id]) return;
}); if (isSuspiciousQty(item.quantity, item.unit) || isSuspiciousDefaultQty(item.default_quantity, item.unit, item.package_unit)) {
const t_ = QTY_THRESHOLDS[item.unit] || QTY_THRESHOLDS['pz'];
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 suspQty = isSuspiciousQty(item.quantity, item.unit);
const suspDq = isSuspiciousDefaultQty(item.default_quantity, item.unit, item.package_unit); const suspDq = isSuspiciousDefaultQty(item.default_quantity, item.unit, item.package_unit);
let warning; let warning;
if (suspDq && !suspQty) warning = '📦 Conf. sospetta'; if (suspDq && !suspQty) warning = '📦 Conf. sospetta';
else if (parseFloat(item.quantity) < t.min) warning = '⬇️ Troppo poco'; else if (parseFloat(item.quantity) < t_.min) warning = '⬇️ Troppo poco';
else warning = '⬆️ Troppo'; else warning = '⬆️ Troppo';
_bannerQueue.push({ type: 'review', data: { ...item, warning } });
}
});
// 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)`);
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) { } catch (e) {
section.style.display = 'none'; console.error('[Banner] loadBannerAlerts error:', e);
}
if (_bannerQueue.length > 0) {
_bannerIndex = 0;
renderBannerItem();
} else {
banner.style.display = 'none';
} }
} }
function confirmReviewItem(inventoryId) { function renderBannerItem() {
setReviewConfirmed(inventoryId); const banner = document.getElementById('alert-banner');
const el = document.getElementById(`review-item-${inventoryId}`); if (!banner || _bannerQueue.length === 0) { if (banner) banner.style.display = 'none'; return; }
if (el) { if (_bannerIndex >= _bannerQueue.length) _bannerIndex = 0;
el.style.transition = 'opacity 0.3s, transform 0.3s';
el.style.opacity = '0'; const entry = _bannerQueue[_bannerIndex];
el.style.transform = 'translateX(60px)'; const iconEl = document.getElementById('alert-banner-icon');
setTimeout(() => { const titleEl = document.getElementById('alert-banner-title');
el.remove(); const detailEl = document.getElementById('alert-banner-detail');
// Hide section if empty const actionsEl = document.getElementById('alert-banner-actions');
const list = document.getElementById('review-list'); const counterEl = document.getElementById('alert-banner-counter');
if (!list.children.length) { const s = getSettings();
document.getElementById('alert-review').style.display = 'none'; const hasScale = s.scale_enabled && s.scale_gateway_url && _scaleConnected;
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>`;
} }
}, 300); actionsEl.innerHTML = btns;
} else if (entry.type === 'prediction') {
const pred = entry.data;
banner.className = 'alert-banner banner-prediction';
iconEl.textContent = '📊';
titleEl.textContent = `${t('dashboard.banner_prediction_title')}: ${pred.name}`;
const expTxt = t('prediction.expected_qty').replace('{expected}', pred.expected_qty).replace('{unit}', pred.unit);
const actTxt = t('prediction.actual_qty').replace('{actual}', pred.actual_qty).replace('{unit}', pred.unit);
detailEl.innerHTML = `${expTxt} · ${actTxt}<br><small>${t('prediction.check_suggestion')}</small>`;
let btns = `<button class="btn-banner btn-banner-confirm" onclick="confirmBannerPrediction()">${t('dashboard.banner_prediction_action_confirm')}</button>`;
btns += `<button class="btn-banner btn-banner-edit" onclick="editBannerPrediction()">${t('dashboard.banner_prediction_action_edit')}</button>`;
if (hasScale) {
btns += `<button class="btn-banner btn-banner-weigh" onclick="weighBannerItem()">⚖️ ${t('dashboard.banner_prediction_action_weigh')}</button>`;
} }
actionsEl.innerHTML = btns;
}
counterEl.textContent = _bannerQueue.length > 1 ? `${_bannerIndex + 1} / ${_bannerQueue.length}` : '';
banner.style.display = '';
}
function dismissBannerItem() {
_bannerQueue.splice(_bannerIndex, 1);
if (_bannerQueue.length === 0) {
document.getElementById('alert-banner').style.display = 'none';
return;
}
if (_bannerIndex >= _bannerQueue.length) _bannerIndex = 0;
renderBannerItem();
}
function confirmBannerReview() {
const entry = _bannerQueue[_bannerIndex];
if (!entry || entry.type !== 'review') return;
setReviewConfirmed(entry.data.id);
showToast(t('toast.quantity_confirmed'), 'success'); showToast(t('toast.quantity_confirmed'), 'success');
dismissBannerItem();
}
function editBannerReview() {
const entry = _bannerQueue[_bannerIndex];
if (!entry || entry.type !== 'review') return;
editReviewItem(entry.data.id, entry.data.product_id);
}
function confirmBannerPrediction() {
const entry = _bannerQueue[_bannerIndex];
if (!entry || entry.type !== 'prediction') return;
setReviewConfirmed('pred_' + entry.data.inventory_id);
showToast(t('toast.quantity_confirmed'), 'success');
dismissBannerItem();
}
function editBannerPrediction() {
const entry = _bannerQueue[_bannerIndex];
if (!entry || entry.type !== 'prediction') return;
editReviewItem(entry.data.inventory_id, entry.data.product_id);
}
function weighBannerItem() {
const entry = _bannerQueue[_bannerIndex];
if (!entry) return;
const item = entry.data;
const targetId = entry.type === 'prediction' ? item.inventory_id : item.id;
// Navigate to edit form and auto-start scale reading
api('inventory_list').then(data => {
currentInventory = data.inventory || [];
editInventoryItem(targetId);
setTimeout(() => readScaleForEdit(), 200);
});
} }
function editReviewItem(inventoryId, productId) { function editReviewItem(inventoryId, productId) {
api('inventory_list').then(data => { api('inventory_list').then(data => {
currentInventory = data.inventory || []; currentInventory = data.inventory || [];
showItemDetail(inventoryId, productId); editInventoryItem(inventoryId);
}); });
} }
@@ -2468,6 +2657,7 @@ function closeModal() {
_cancelScaleAutoConfirm(false); _cancelScaleAutoConfirm(false);
_scaleRecipeAutoFillPaused = false; _scaleRecipeAutoFillPaused = false;
_scaleUserDismissed = false; _scaleUserDismissed = false;
_scaleWeightCallback = null;
} }
async function quickUse(productId, location) { async function quickUse(productId, location) {
@@ -2540,6 +2730,12 @@ function editInventoryItem(id) {
const confSizeVal = (isConf && item.default_quantity > 0) ? item.default_quantity : ''; const confSizeVal = (isConf && item.default_quantity > 0) ? item.default_quantity : '';
const confUnitVal = (isConf && item.package_unit) ? item.package_unit : 'g'; const confUnitVal = (isConf && item.package_unit) ? item.package_unit : 'g';
// Determine if scale is available for this item's unit
const s = getSettings();
const effectiveUnit = isConf ? (item.package_unit || 'g') : (item.unit || 'pz');
const scaleEditReady = s.scale_enabled && s.scale_gateway_url && _scaleConnected &&
(effectiveUnit === 'g' || effectiveUnit === 'ml');
window._editingProduct = { name: item.name, category: item.category || '', _isOpened: !!item.opened_at }; window._editingProduct = { name: item.name, category: item.category || '', _isOpened: !!item.opened_at };
// Rebuild modal content for editing (don't close and reopen - just replace content) // Rebuild modal content for editing (don't close and reopen - just replace content)
@@ -2556,6 +2752,14 @@ function editInventoryItem(id) {
<input type="number" id="edit-qty" value="${item.quantity}" min="0" step="any" class="qty-input"> <input type="number" id="edit-qty" value="${item.quantity}" min="0" step="any" class="qty-input">
<button type="button" class="qty-btn" onclick="adjustQty('edit-qty', 1)">+</button> <button type="button" class="qty-btn" onclick="adjustQty('edit-qty', 1)">+</button>
</div> </div>
${scaleEditReady ? `
<div id="edit-scale-section" style="display:none;text-align:center;padding:10px;background:linear-gradient(135deg,#f3e8ff,#ede9fe);border-radius:10px;margin-top:8px">
<div style="font-size:1.8rem;font-weight:bold;color:#5b21b6" id="edit-scale-reading"> </div>
<div style="font-size:0.78rem;color:#7c6cb0;margin-top:2px">${t('scale.place_on_scale')}</div>
</div>
<button type="button" id="btn-scale-edit" class="btn btn-secondary scale-read-btn" style="margin-top:8px;width:100%"
onclick="readScaleForEdit()"> ${t('scale.read_btn')}</button>
` : ''}
</div> </div>
<div class="form-group"> <div class="form-group">
<label>📏 Unità di misura</label> <label>📏 Unità di misura</label>
@@ -4723,11 +4927,14 @@ async function submitAdd(e) {
} }
// ===== USE FROM INVENTORY ===== // ===== USE FROM INVENTORY =====
let _useSubmitting = false; // double-submit guard
function showUseForm() { function showUseForm() {
renderUsePreview(); renderUsePreview();
_useConfMode = null; // reset _useConfMode = null; // reset
_useSubmitting = false;
_scaleUserDismissed = false; _scaleUserDismissed = false;
_scaleStabilityVal = null; _scaleStabilityVal = null;
_scaleLatestWeight = null; // clear stale weight from previous product
_cancelScaleAutoConfirm(false); _cancelScaleAutoConfirm(false);
document.getElementById('use-quantity').value = 1; document.getElementById('use-quantity').value = 1;
document.getElementById('use-location').value = 'dispensa'; document.getElementById('use-location').value = 'dispensa';
@@ -5294,6 +5501,9 @@ async function submitUseAll() {
async function submitUse(e) { async function submitUse(e) {
e.preventDefault(); e.preventDefault();
if (_useSubmitting) return; // prevent double-submit from scale auto-confirm
_useSubmitting = true;
_cancelScaleAutoConfirm(false); // stop any running auto-confirm
showLoading(true); showLoading(true);
try { try {
let qty = parseFloat(document.getElementById('use-quantity').value) || 1; let qty = parseFloat(document.getElementById('use-quantity').value) || 1;
@@ -5314,6 +5524,7 @@ async function submitUse(e) {
location: document.getElementById('use-location').value, location: document.getElementById('use-location').value,
}); });
showLoading(false); showLoading(false);
_useSubmitting = false;
if (result.success) { if (result.success) {
const usedText = displayUnit ? `${displayQty}${displayUnit}` : displayQty; const usedText = displayUnit ? `${displayQty}${displayUnit}` : displayQty;
showToast(`📤 Usato ${usedText} di ${currentProduct.name}`, 'success'); showToast(`📤 Usato ${usedText} di ${currentProduct.name}`, 'success');
@@ -5332,6 +5543,7 @@ async function submitUse(e) {
} }
} catch (err) { } catch (err) {
showLoading(false); showLoading(false);
_useSubmitting = false;
showToast(t('error.connection'), 'error'); showToast(t('error.connection'), 'error');
} }
} }
@@ -7660,6 +7872,7 @@ function viewArchivedRecipe(idx) {
let _cachedRecipe = null; let _cachedRecipe = null;
let _generatedTodayTitles = []; // client-side list, robust vs race conditions let _generatedTodayTitles = []; // client-side list, robust vs race conditions
let _recipeVariationCount = {}; // { 'pranzo': 0, 'cena': 1, ... } let _recipeVariationCount = {}; // { 'pranzo': 0, 'cena': 1, ... }
let _rejectedRecipeIngredients = []; // ingredient names from previously rejected recipes
function openRecipeDialog() { function openRecipeDialog() {
const meal = getMealType(); const meal = getMealType();
@@ -8701,14 +8914,18 @@ function _renderMealPlanHint(mealSlot) {
} }
function regenerateRecipe() { function regenerateRecipe() {
// Collect main ingredients from the rejected recipe to exclude them
if (_cachedRecipe && _cachedRecipe.recipe && _cachedRecipe.recipe.ingredients) {
const mainIngs = _cachedRecipe.recipe.ingredients
.filter(i => i.from_pantry)
.map(i => i.name);
_rejectedRecipeIngredients = [...new Set([..._rejectedRecipeIngredients, ...mainIngs])];
}
_cachedRecipe = null; _cachedRecipe = null;
// Use the meal the user currently has selected (not the auto-detected one)
const meal = getSelectedMealType(); const meal = getSelectedMealType();
// increment variation counter for this meal slot
_recipeVariationCount[meal] = (_recipeVariationCount[meal] || 0) + 1; _recipeVariationCount[meal] = (_recipeVariationCount[meal] || 0) + 1;
document.getElementById('recipe-result').style.display = 'none'; document.getElementById('recipe-result').style.display = 'none';
document.getElementById('recipe-loading').style.display = 'none'; document.getElementById('recipe-loading').style.display = 'none';
// Keep all existing form settings (persons, chips, meal) — just show the form again
document.getElementById('recipe-ask').style.display = ''; document.getElementById('recipe-ask').style.display = '';
} }
@@ -8717,6 +8934,11 @@ async function generateRecipe() {
const persons = parseInt(document.getElementById('recipe-persons').value) || 1; const persons = parseInt(document.getElementById('recipe-persons').value) || 1;
const settings = getSettings(); const settings = getSettings();
// Reset rejected ingredients on first generation (not regeneration)
if ((_recipeVariationCount[meal] || 0) === 0) {
_rejectedRecipeIngredients = [];
}
// Determine meal plan type for today's selected slot, // Determine meal plan type for today's selected slot,
// but only if the user has NOT unchecked the meal-plan chip // but only if the user has NOT unchecked the meal-plan chip
const mealPlanChipWrap = document.getElementById('recipe-opt-mealplan-wrap'); const mealPlanChipWrap = document.getElementById('recipe-opt-mealplan-wrap');
@@ -8755,6 +8977,7 @@ async function generateRecipe() {
today_recipes: [...new Set([...await getTodayRecipeTitles(), ..._generatedTodayTitles])], today_recipes: [...new Set([...await getTodayRecipeTitles(), ..._generatedTodayTitles])],
meal_plan_type: mealPlanType, meal_plan_type: mealPlanType,
variation: _recipeVariationCount[meal] || 0, variation: _recipeVariationCount[meal] || 0,
rejected_ingredients: _rejectedRecipeIngredients,
}); });
if (!result.success) { if (!result.success) {
+46
View File
@@ -0,0 +1,46 @@
# EverShelf Kiosk
Android kiosk app that displays the EverShelf web interface in full-screen mode while running the Smart Scale BLE Gateway as a background service.
## Features
- **Full-screen WebView** — displays EverShelf in immersive kiosk mode (no status bar, no navigation)
- **Built-in Scale Gateway** — BLE connection to smart scales with WebSocket server on port 8765
- **Auto-reconnect** — automatically reconnects to the last connected scale
- **Foreground service** — gateway runs even when the screen is off
- **Camera pass-through** — allows barcode scanning from within the WebView
- **Error recovery** — shows retry page when the server is unreachable
## Setup
1. Install the APK on your Android tablet/phone
2. On first launch, grant Bluetooth and Location permissions
3. Tap the subtle ⚙️ icon in the top-right corner to configure the EverShelf server URL
4. In EverShelf settings, set the Scale Gateway URL to `ws://localhost:8765`
## Architecture
```
KioskActivity (WebView — full-screen EverShelf)
↕ binds to
ScaleGatewayService (foreground service)
├── BleScaleManager (BLE scanning + connection)
│ └── ScaleProtocol (multi-protocol weight parser)
└── GatewayWebSocketServer (port 8765)
↕ WebSocket
WebView (EverShelf JavaScript connects to ws://localhost:8765)
```
## Building
```bash
cd evershelf-kiosk
./gradlew assembleDebug
# APK at app/build/outputs/apk/debug/app-debug.apk
```
## Requirements
- Android 7.0+ (API 24)
- Bluetooth Low Energy support
- Network access to EverShelf server
+48
View File
@@ -0,0 +1,48 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "it.dadaloop.evershelf.kiosk"
compileSdk = 34
defaultConfig {
applicationId = "it.dadaloop.evershelf.kiosk"
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "1.0.0"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
}
}
buildFeatures {
viewBinding = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.11.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
implementation("androidx.webkit:webkit:1.10.0")
// WebSocket server (for scale gateway)
implementation("org.java-websocket:Java-WebSocket:1.5.5")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
}
@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- BLE permissions for Android < 12 -->
<uses-permission android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<!-- BLE permissions for Android 12+ -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Location (required for BLE scanning on Android 611) -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- Network -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- Keep screen on / foreground service -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.MaterialComponents.Light.NoActionBar">
<activity
android:name=".KioskActivity"
android:exported="true"
android:configChanges="orientation|screenSize|keyboard|keyboardHidden"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".SettingsActivity"
android:exported="false"
android:theme="@style/Theme.MaterialComponents.Light.NoActionBar" />
<service
android:name=".ScaleGatewayService"
android:foregroundServiceType="connectedDevice"
android:exported="false" />
</application>
</manifest>
@@ -0,0 +1,320 @@
package it.dadaloop.evershelf.kiosk
import android.Manifest
import android.bluetooth.*
import android.bluetooth.le.*
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.core.content.ContextCompat
private const val TAG = "BleScaleManager"
private const val SCAN_PERIOD_MS = 15_000L
private const val PREFS_NAME = "evershelf_kiosk"
private const val PREF_LAST_DEVICE = "last_device_address"
data class BleDeviceInfo(
val device: BluetoothDevice,
val name: String,
val rssi: Int,
val proximity: String,
val scaleScore: Int,
)
interface BleScaleListener {
fun onDeviceFound(info: BleDeviceInfo)
fun onConnecting(device: BluetoothDevice)
fun onConnected(deviceName: String)
fun onDisconnected()
fun onWeightReceived(reading: WeightReading)
fun onBatteryReceived(level: Int)
fun onError(message: String)
fun onScanStopped()
fun onDebugEvent(message: String)
}
class BleScaleManager(
private val context: Context,
private val listener: BleScaleListener,
) {
private val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
private val bluetoothAdapter: BluetoothAdapter? get() = bluetoothManager.adapter
private val mainHandler = Handler(Looper.getMainLooper())
private var leScanner: BluetoothLeScanner? = null
private var gatt: BluetoothGatt? = null
private var isScanning = false
private var connectedDeviceName: String = ""
private var autoConnectAddress: String? = null
private val pendingSubscriptions = ArrayDeque<BluetoothGattCharacteristic>()
val isConnected: Boolean get() = gatt != null && connectedDeviceName.isNotEmpty()
fun getSavedDeviceAddress(): String? {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getString(PREF_LAST_DEVICE, null)
}
private fun saveDeviceAddress(address: String) {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit().putString(PREF_LAST_DEVICE, address).apply()
}
fun enableAutoConnect() {
autoConnectAddress = getSavedDeviceAddress()
}
fun hasRequiredPermissions(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED
} else {
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
}
}
fun startScan() {
val adapter = bluetoothAdapter ?: run {
listener.onError("Bluetooth not available.")
return
}
if (!adapter.isEnabled) {
listener.onError("Bluetooth is off.")
return
}
if (isScanning) stopScan()
leScanner = adapter.bluetoothLeScanner
val settings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.build()
isScanning = true
try {
leScanner?.startScan(null, settings, scanCallback)
} catch (e: Exception) {
leScanner?.startScan(scanCallback)
}
mainHandler.postDelayed({
stopScan()
listener.onScanStopped()
}, SCAN_PERIOD_MS)
}
fun stopScan() {
if (!isScanning) return
isScanning = false
try { leScanner?.stopScan(scanCallback) } catch (_: Exception) {}
leScanner = null
}
private val scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
val device = result.device
val name = result.scanRecord?.deviceName?.takeIf { it.isNotBlank() }
?: getDeviceName(device)
val proximity = rssiToProximity(result.rssi)
val score = scoreLikelyScale(name, result.scanRecord)
val info = BleDeviceInfo(device, name, result.rssi, proximity, score)
mainHandler.post { listener.onDeviceFound(info) }
if (autoConnectAddress != null && device.address == autoConnectAddress && !isConnected) {
autoConnectAddress = null
mainHandler.post { connect(device) }
}
}
override fun onScanFailed(errorCode: Int) {
isScanning = false
mainHandler.post { listener.onError("BLE scan failed (code: $errorCode)") }
}
}
private fun getDeviceName(device: BluetoothDevice): String {
return try { device.name?.takeIf { it.isNotBlank() } ?: "Unnamed" } catch (_: SecurityException) { "Unnamed" }
}
private fun rssiToProximity(rssi: Int) = when {
rssi >= -60 -> "Near"; rssi >= -80 -> "Medium"; else -> "Far"
}
private fun scoreLikelyScale(name: String, scanRecord: android.bluetooth.le.ScanRecord?): Int {
var score = 0
val lower = name.lowercase()
val foodKeywords = listOf("scale", "bilancia", "kitchen", "food", "cucina", "coffee", "caffe",
"balance", "weight", "waage", "arboleaf", "ck10", "ck20", "ek-", "acaia", "felicita",
"decent", "skale", "timemore", "brewista", "hario", "greater goods", "ozeri", "etekcity",
"nutri", "nicewell", "koios", "renpho", "eatsmart")
if (foodKeywords.any { lower.contains(it) }) score += 10
val bodyKeywords = listOf("body", "fat", "bmi", "composition", "fitness", "mi body", "lepulse", "qardio", "garmin", "withings")
if (bodyKeywords.any { lower.contains(it) }) score -= 5
scanRecord?.serviceUuids?.let { uuids ->
val us = uuids.map { it.uuid.toString().lowercase() }
if (us.any { it.startsWith("0000181d") }) score += 15
if (us.any { it.startsWith("0000ffe0") || it.startsWith("0000fff0") }) score += 10
if (us.any { it.startsWith("49535343") }) score += 20
if (us.any { it.startsWith("0000181b") }) score -= 10
}
return score
}
fun connect(device: BluetoothDevice) {
stopScan()
disconnect()
connectedDeviceName = ""
ScaleProtocol.resetState()
mainHandler.post { listener.onConnecting(device) }
try {
gatt = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
} else {
device.connectGatt(context, false, gattCallback)
}
} catch (e: SecurityException) {
mainHandler.post { listener.onError("Missing permission: ${e.message}") }
}
}
fun disconnect() {
pendingSubscriptions.clear()
try { gatt?.disconnect(); gatt?.close() } catch (_: Exception) {}
gatt = null
connectedDeviceName = ""
}
private val gattCallback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
when (newState) {
BluetoothProfile.STATE_CONNECTED -> {
mainHandler.postDelayed({ gatt.discoverServices() }, 500)
}
BluetoothProfile.STATE_DISCONNECTED -> {
this@BleScaleManager.gatt?.close()
this@BleScaleManager.gatt = null
connectedDeviceName = ""
mainHandler.post { listener.onDisconnected() }
}
}
}
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
if (status != BluetoothGatt.GATT_SUCCESS) {
mainHandler.post { listener.onError("GATT services not found (status=$status)") }
return
}
val targetChars = mutableListOf<BluetoothGattCharacteristic>()
gatt.getService(BleUuids.WEIGHT_SCALE_SERVICE)
?.getCharacteristic(BleUuids.WEIGHT_MEASUREMENT_CHAR)?.let { targetChars.add(it) }
gatt.getService(BleUuids.FFE0)?.let { svc ->
svc.getCharacteristic(BleUuids.FFE1)?.let { targetChars.add(it) }
}
gatt.getService(BleUuids.FFF0)?.let { svc ->
svc.getCharacteristic(BleUuids.FFF4)?.let { targetChars.add(it) }
?: svc.getCharacteristic(BleUuids.FFF1)?.let { targetChars.add(it) }
}
gatt.getService(BleUuids.ACAIA_SERVICE)?.let { svc ->
svc.getCharacteristic(BleUuids.ACAIA_CHAR)?.let { targetChars.add(it) }
}
if (targetChars.isEmpty()) {
for (service in gatt.services) {
if (service.uuid.toString().startsWith("00001800") ||
service.uuid.toString().startsWith("00001801")) continue
for (char in service.characteristics) {
val props = char.properties
if ((props and BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0 ||
(props and BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0) {
if (!targetChars.contains(char)) targetChars.add(char)
}
}
}
}
if (targetChars.isEmpty()) {
mainHandler.post { listener.onError("No weight characteristic found.") }
return
}
gatt.getService(BleUuids.BATTERY_SERVICE)
?.getCharacteristic(BleUuids.BATTERY_LEVEL_CHAR)?.let { targetChars.add(it) }
try { gatt.device?.address?.let { saveDeviceAddress(it) } } catch (_: SecurityException) {}
pendingSubscriptions.clear()
pendingSubscriptions.addAll(targetChars)
val deviceName = try { gatt.device?.name ?: "Scale" } catch (_: SecurityException) { "Scale" }
connectedDeviceName = deviceName
mainHandler.post { listener.onConnected(deviceName) }
subscribeNext(gatt)
}
override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
subscribeNext(gatt)
}
@Suppress("DEPRECATION")
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
val data = characteristic.value ?: return
processCharacteristicData(characteristic, data)
}
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) {
processCharacteristicData(characteristic, value)
}
@Suppress("DEPRECATION")
override fun onCharacteristicRead(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
if (status == BluetoothGatt.GATT_SUCCESS && characteristic.uuid == BleUuids.BATTERY_LEVEL_CHAR) {
val level = characteristic.value?.firstOrNull()?.toInt()?.and(0xFF)
if (level != null) mainHandler.post { listener.onBatteryReceived(level) }
}
}
}
private fun subscribeNext(gatt: BluetoothGatt) {
val char = pendingSubscriptions.removeFirstOrNull() ?: return
if (char.uuid == BleUuids.BATTERY_LEVEL_CHAR) {
try { gatt.readCharacteristic(char) } catch (_: SecurityException) {}
return
}
val props = char.properties
val notifyType = when {
(props and BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0 ->
BluetoothGattDescriptor.ENABLE_INDICATION_VALUE
else -> BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
}
try {
gatt.setCharacteristicNotification(char, true)
val descriptor = char.getDescriptor(CCCD_UUID) ?: run { subscribeNext(gatt); return }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
gatt.writeDescriptor(descriptor, notifyType)
} else {
@Suppress("DEPRECATION")
descriptor.value = notifyType
@Suppress("DEPRECATION")
gatt.writeDescriptor(descriptor)
}
} catch (e: SecurityException) {
Log.e(TAG, "SecurityException enabling notification", e)
}
}
private fun processCharacteristicData(char: BluetoothGattCharacteristic, data: ByteArray) {
if (char.uuid == BleUuids.BATTERY_LEVEL_CHAR && data.isNotEmpty()) {
val level = data[0].toInt() and 0xFF
mainHandler.post { listener.onBatteryReceived(level) }
return
}
val reading = ScaleProtocol.parse(char, data)
if (reading != null && reading.value > 0f) {
mainHandler.post { listener.onWeightReceived(reading) }
}
}
}
@@ -0,0 +1,100 @@
package it.dadaloop.evershelf.kiosk
import android.util.Log
import org.java_websocket.WebSocket
import org.java_websocket.handshake.ClientHandshake
import org.java_websocket.server.WebSocketServer
import org.json.JSONObject
import java.net.InetSocketAddress
import java.util.Collections
private const val TAG = "GatewayWsServer"
interface ServerEventListener {
fun onClientConnected(address: String)
fun onClientDisconnected(address: String)
fun onClientRequestedWeight()
}
class GatewayWebSocketServer(
port: Int,
private val eventListener: ServerEventListener?,
) : WebSocketServer(InetSocketAddress(port)) {
private val pendingWeightRequests: MutableSet<WebSocket> =
Collections.synchronizedSet(mutableSetOf())
@Volatile private var lastStatusJson: String = buildStatusJson("disconnected", null, null)
@Volatile private var lastWeightJson: String? = null
override fun onStart() {
Log.i(TAG, "WebSocket server started on port ${address.port}")
connectionLostTimeout = 30
}
override fun onOpen(conn: WebSocket, handshake: ClientHandshake) {
val addr = conn.remoteSocketAddress?.toString() ?: "?"
conn.send(lastStatusJson)
lastWeightJson?.let { conn.send(it) }
eventListener?.onClientConnected(addr)
}
override fun onClose(conn: WebSocket, code: Int, reason: String, remote: Boolean) {
val addr = conn.remoteSocketAddress?.toString() ?: "?"
pendingWeightRequests.remove(conn)
eventListener?.onClientDisconnected(addr)
}
override fun onMessage(conn: WebSocket, message: String) {
try {
val json = JSONObject(message)
when (json.optString("type")) {
"ping" -> conn.send("""{"type":"pong"}""")
"get_status" -> conn.send(lastStatusJson)
"get_weight" -> {
pendingWeightRequests.add(conn)
eventListener?.onClientRequestedWeight()
lastWeightJson?.let { conn.send(it) }
}
}
} catch (_: Exception) {}
}
override fun onError(conn: WebSocket?, ex: Exception) {
Log.e(TAG, "WebSocket error", ex)
}
fun publishStatus(state: String, deviceName: String?, battery: Int?) {
lastStatusJson = buildStatusJson(state, deviceName, battery)
broadcast(lastStatusJson)
}
fun publishWeight(value: Float, unit: String, stable: Boolean, battery: Int? = null) {
val json = buildWeightJson(value, unit, stable)
lastWeightJson = json
broadcast(json)
if (stable) {
synchronized(pendingWeightRequests) { pendingWeightRequests.clear() }
}
}
private fun buildStatusJson(state: String, device: String?, battery: Int?): String {
val obj = JSONObject()
obj.put("type", "status")
obj.put("state", state)
if (device != null) obj.put("device", device)
if (battery != null) obj.put("battery", battery)
return obj.toString()
}
private fun buildWeightJson(value: Float, unit: String, stable: Boolean): String {
val obj = JSONObject()
obj.put("type", "weight")
val rounded = Math.round(value * 10f) / 10.0
obj.put("value", rounded)
obj.put("unit", unit)
obj.put("stable", stable)
obj.put("timestamp", System.currentTimeMillis())
return obj.toString()
}
}
@@ -0,0 +1,241 @@
package it.dadaloop.evershelf.kiosk
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.content.*
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.view.View
import android.view.WindowInsets
import android.view.WindowInsetsController
import android.view.WindowManager
import android.webkit.*
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import it.dadaloop.evershelf.kiosk.databinding.ActivityKioskBinding
private const val PREFS_NAME = "evershelf_kiosk"
private const val PREF_URL = "evershelf_url"
private const val DEFAULT_URL = "http://evershelf.local"
class KioskActivity : AppCompatActivity() {
private lateinit var binding: ActivityKioskBinding
private var gatewayService: ScaleGatewayService? = null
private var bound = false
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, service: IBinder) {
val binder = service as ScaleGatewayService.LocalBinder
gatewayService = binder.getService()
bound = true
}
override fun onServiceDisconnected(name: ComponentName) {
gatewayService = null
bound = false
}
}
// Permission request launcher
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { results ->
val allGranted = results.all { it.value }
if (allGranted) {
startGatewayService()
} else {
Toast.makeText(this, "BLE permissions required for scale gateway", Toast.LENGTH_LONG).show()
// Start anyway without BLE
startGatewayService()
}
}
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityKioskBinding.inflate(layoutInflater)
setContentView(binding.root)
// Full-screen immersive mode
enterKioskMode()
// Keep screen on
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
// Setup WebView
setupWebView()
// Settings button (long press corner area)
binding.btnSettings.setOnClickListener {
startActivity(Intent(this, SettingsActivity::class.java))
}
// Request permissions and start gateway
requestPermissionsAndStart()
// Load the EverShelf URL
loadEverShelfUrl()
}
override fun onResume() {
super.onResume()
enterKioskMode()
// Reload URL in case it was changed in settings
val currentUrl = binding.webView.url ?: ""
val savedUrl = getSavedUrl()
if (currentUrl.isNotEmpty() && !currentUrl.startsWith(savedUrl)) {
loadEverShelfUrl()
}
}
override fun onDestroy() {
if (bound) {
unbindService(serviceConnection)
bound = false
}
super.onDestroy()
}
private fun enterKioskMode() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window.insetsController?.let {
it.hide(WindowInsets.Type.statusBars() or WindowInsets.Type.navigationBars())
it.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
} else {
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility = (
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
or View.SYSTEM_UI_FLAG_FULLSCREEN
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
)
}
}
@SuppressLint("SetJavaScriptEnabled")
private fun setupWebView() {
binding.webView.apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.databaseEnabled = true
settings.mediaPlaybackRequiresUserGesture = false
settings.allowFileAccess = false
settings.allowContentAccess = false
settings.mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
settings.cacheMode = WebSettings.LOAD_DEFAULT
settings.useWideViewPort = true
settings.loadWithOverviewMode = true
settings.setSupportZoom(false)
settings.builtInZoomControls = false
// Allow camera access for barcode scanning
webChromeClient = object : WebChromeClient() {
override fun onPermissionRequest(request: PermissionRequest) {
runOnUiThread {
request.grant(request.resources)
}
}
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
return true
}
}
webViewClient = object : WebViewClient() {
override fun onReceivedError(
view: WebView?,
request: WebResourceRequest?,
error: WebResourceError?
) {
// Show retry page on load error
if (request?.isForMainFrame == true) {
view?.loadData(
"""
<html><body style="font-family:sans-serif;text-align:center;padding:40px;background:#1a1a2e;color:#fff">
<h2>⚠️ Connection Error</h2>
<p>Cannot reach EverShelf server</p>
<p style="color:#888;font-size:14px">${getSavedUrl()}</p>
<button onclick="location.reload()" style="padding:12px 24px;font-size:16px;border:none;border-radius:8px;background:#7C3AED;color:#fff;cursor:pointer;margin-top:20px">Retry</button>
<br><br>
<button onclick="window.location='evershelf://settings'" style="padding:8px 16px;font-size:14px;border:1px solid #666;border-radius:8px;background:transparent;color:#aaa;cursor:pointer">Settings</button>
</body></html>
""".trimIndent(),
"text/html", "utf-8"
)
}
}
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
val url = request.url.toString()
if (url.startsWith("evershelf://settings")) {
startActivity(Intent(this@KioskActivity, SettingsActivity::class.java))
return true
}
// Keep navigation within the WebView for same-origin
return false
}
}
}
}
private fun loadEverShelfUrl() {
val url = getSavedUrl()
binding.webView.loadUrl(url)
}
private fun getSavedUrl(): String {
return getSharedPreferences(PREFS_NAME, MODE_PRIVATE)
.getString(PREF_URL, DEFAULT_URL) ?: DEFAULT_URL
}
private fun requestPermissionsAndStart() {
val needed = mutableListOf<String>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_SCAN)
!= PackageManager.PERMISSION_GRANTED) needed.add(Manifest.permission.BLUETOOTH_SCAN)
if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT)
!= PackageManager.PERMISSION_GRANTED) needed.add(Manifest.permission.BLUETOOTH_CONNECT)
} else {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION)
!= PackageManager.PERMISSION_GRANTED) needed.add(Manifest.permission.ACCESS_FINE_LOCATION)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED) needed.add(Manifest.permission.POST_NOTIFICATIONS)
}
if (needed.isNotEmpty()) {
permissionLauncher.launch(needed.toTypedArray())
} else {
startGatewayService()
}
}
private fun startGatewayService() {
val intent = Intent(this, ScaleGatewayService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent)
} else {
startService(intent)
}
bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
}
@Deprecated("Deprecated in Java")
override fun onBackPressed() {
if (binding.webView.canGoBack()) {
binding.webView.goBack()
}
// Don't call super — prevent exiting kiosk mode
}
}
@@ -0,0 +1,194 @@
package it.dadaloop.evershelf.kiosk
import android.app.*
import android.bluetooth.BluetoothDevice
import android.content.Context
import android.content.Intent
import android.os.Binder
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.util.Log
import androidx.core.app.NotificationCompat
private const val TAG = "ScaleGtwService"
private const val CHANNEL_ID = "scale_gateway"
private const val NOTIFICATION_ID = 1
private const val WS_PORT = 8765
private const val RECONNECT_DELAY_MS = 5000L
class ScaleGatewayService : Service(), BleScaleListener, ServerEventListener {
private var bleManager: BleScaleManager? = null
private var wsServer: GatewayWebSocketServer? = null
private var lastBattery: Int? = null
private var connectedDeviceName: String? = null
private val mainHandler = Handler(Looper.getMainLooper())
// Binder so KioskActivity can get status updates
inner class LocalBinder : Binder() {
fun getService(): ScaleGatewayService = this@ScaleGatewayService
}
private val binder = LocalBinder()
// Callbacks for the activity
var statusCallback: ((String, String?, Int?) -> Unit)? = null // state, device, battery
var weightCallback: ((Float, String, Boolean) -> Unit)? = null // value, unit, stable
override fun onBind(intent: Intent?): IBinder = binder
override fun onCreate() {
super.onCreate()
createNotificationChannel()
startForeground(NOTIFICATION_ID, buildNotification("Starting..."))
// Start WebSocket server
wsServer = GatewayWebSocketServer(WS_PORT, this).also {
try { it.start() } catch (e: Exception) {
Log.e(TAG, "Failed to start WS server", e)
}
}
// Start BLE manager
bleManager = BleScaleManager(this, this).also {
if (it.hasRequiredPermissions()) {
it.enableAutoConnect()
it.startScan()
}
}
}
override fun onDestroy() {
bleManager?.disconnect()
bleManager?.stopScan()
try { wsServer?.stop(1000) } catch (_: Exception) {}
super.onDestroy()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
return START_STICKY
}
fun startScaleScan() {
bleManager?.let {
if (it.hasRequiredPermissions()) {
it.enableAutoConnect()
it.startScan()
}
}
}
fun disconnectScale() {
bleManager?.disconnect()
connectedDeviceName = null
wsServer?.publishStatus("disconnected", null, null)
updateNotification("Gateway active — no scale")
statusCallback?.invoke("disconnected", null, null)
}
fun connectDevice(device: BluetoothDevice) {
bleManager?.connect(device)
}
val isScaleConnected: Boolean get() = bleManager?.isConnected == true
// ─── BleScaleListener ──────────────────────────────────────────────────
override fun onDeviceFound(info: BleDeviceInfo) {}
override fun onConnecting(device: BluetoothDevice) {
updateNotification("Connecting...")
statusCallback?.invoke("connecting", null, null)
}
override fun onConnected(deviceName: String) {
connectedDeviceName = deviceName
wsServer?.publishStatus("connected", deviceName, lastBattery)
updateNotification("Connected: $deviceName")
statusCallback?.invoke("connected", deviceName, lastBattery)
}
override fun onDisconnected() {
connectedDeviceName = null
wsServer?.publishStatus("disconnected", null, null)
updateNotification("Scale disconnected — reconnecting...")
statusCallback?.invoke("disconnected", null, null)
// Auto-reconnect
mainHandler.postDelayed({
bleManager?.let {
if (!it.isConnected && it.hasRequiredPermissions()) {
it.enableAutoConnect()
it.startScan()
}
}
}, RECONNECT_DELAY_MS)
}
override fun onWeightReceived(reading: WeightReading) {
wsServer?.publishWeight(reading.value, reading.unit, reading.stable, lastBattery)
weightCallback?.invoke(reading.value, reading.unit, reading.stable)
}
override fun onBatteryReceived(level: Int) {
lastBattery = level
wsServer?.publishStatus("connected", connectedDeviceName, level)
}
override fun onError(message: String) {
Log.w(TAG, "BLE error: $message")
}
override fun onScanStopped() {}
override fun onDebugEvent(message: String) {}
// ─── ServerEventListener ───────────────────────────────────────────────
override fun onClientConnected(address: String) {
Log.d(TAG, "WS client connected: $address")
}
override fun onClientDisconnected(address: String) {
Log.d(TAG, "WS client disconnected: $address")
}
override fun onClientRequestedWeight() {}
// ─── Notification ──────────────────────────────────────────────────────
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"Scale Gateway",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "EverShelf Scale Gateway running"
setShowBadge(false)
}
(getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
.createNotificationChannel(channel)
}
}
private fun buildNotification(text: String): Notification {
val intent = Intent(this, KioskActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
}
val pendingIntent = PendingIntent.getActivity(
this, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("EverShelf Gateway")
.setContentText(text)
.setSmallIcon(android.R.drawable.stat_sys_data_bluetooth)
.setContentIntent(pendingIntent)
.setOngoing(true)
.build()
}
private fun updateNotification(text: String) {
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.notify(NOTIFICATION_ID, buildNotification(text))
}
}
@@ -0,0 +1,113 @@
package it.dadaloop.evershelf.kiosk
import android.bluetooth.BluetoothGattCharacteristic
import java.util.UUID
data class WeightReading(
val value: Float,
val unit: String,
val stable: Boolean,
)
val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
object BleUuids {
val WEIGHT_SCALE_SERVICE = UUID.fromString("0000181d-0000-1000-8000-00805f9b34fb")
val WEIGHT_MEASUREMENT_CHAR = UUID.fromString("00002a9d-0000-1000-8000-00805f9b34fb")
val BATTERY_SERVICE = UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb")
val BATTERY_LEVEL_CHAR = UUID.fromString("00002a19-0000-1000-8000-00805f9b34fb")
val FFE0 = UUID.fromString("0000ffe0-0000-1000-8000-00805f9b34fb")
val FFE1 = UUID.fromString("0000ffe1-0000-1000-8000-00805f9b34fb")
val FFF0 = UUID.fromString("0000fff0-0000-1000-8000-00805f9b34fb")
val FFF1 = UUID.fromString("0000fff1-0000-1000-8000-00805f9b34fb")
val FFF4 = UUID.fromString("0000fff4-0000-1000-8000-00805f9b34fb")
val ACAIA_SERVICE = UUID.fromString("49535343-fe7d-4ae5-8fa9-9fafd205e455")
val ACAIA_CHAR = UUID.fromString("49535343-8841-43f4-a8d4-ecbe34729bb3")
val QN_AE00 = UUID.fromString("0000ae00-0000-1000-8000-00805f9b34fb")
val QN_AE02 = UUID.fromString("0000ae02-0000-1000-8000-00805f9b34fb")
}
object ScaleProtocol {
private const val MAX_GRAMS = 15000f
private const val MIN_GRAMS = 0.5f
fun resetState() {}
fun parse(
char: BluetoothGattCharacteristic,
data: ByteArray,
debug: ((String) -> Unit)? = null,
): WeightReading? {
if (data.size < 2) return null
when (char.uuid) {
BleUuids.WEIGHT_MEASUREMENT_CHAR -> return parseSigWeight(data, debug)
}
if (data.size == 18
&& (data[0].toInt() and 0xFF) == 0x10
&& (data[1].toInt() and 0xFF) == 0x12) {
return parseQNFood(data, debug)
}
return parseGeneric(data, debug)
}
private fun parseSigWeight(data: ByteArray, debug: ((String) -> Unit)? = null): WeightReading? {
if (data.size < 3) return null
val flags = data[0].toInt() and 0xFF
val isImperial = (flags and 0x01) != 0
val raw = u16le(data, 1)
return if (isImperial) {
val lb = raw * 0.01f
if (lb < 0.01f || lb > 33f) null else WeightReading(lb, "lb", stable = true)
} else {
val g = raw * 5f
if (g < MIN_GRAMS || g > MAX_GRAMS) null else WeightReading(g, "g", stable = true)
}
}
private fun parseQNFood(data: ByteArray, debug: ((String) -> Unit)? = null): WeightReading? {
val calc = data.take(17).sumOf { it.toInt() and 0xFF } and 0xFF
if (calc != (data[17].toInt() and 0xFF)) return null
val rawValue = u16be(data, 9)
val stable = (data[8].toInt() and 0x08) != 0
val unit = when (data[4].toInt() and 0xFF) {
0x01 -> "g"; 0x02 -> "oz"; 0x03 -> "ml"; 0x04 -> "ml"; else -> "g"
}
val value = rawValue / 10f
if (rawValue == 0) return null
val valueG = if (unit == "oz") value * 28.3495f else value
if (valueG < MIN_GRAMS || valueG > MAX_GRAMS) return null
return WeightReading(value, unit, stable)
}
private fun parseGeneric(data: ByteArray, debug: ((String) -> Unit)? = null): WeightReading? {
if (data.size < 3) return null
data class C(val pos: Int, val be: Boolean, val div: Float, val label: String)
val candidates = listOf(
C(1, false, 1f, "p1LEg"), C(1, true, 1f, "p1BEg"),
C(2, false, 1f, "p2LEg"), C(2, true, 1f, "p2BEg"),
C(3, false, 1f, "p3LEg"), C(3, true, 1f, "p3BEg"),
C(1, false, 10f, "p1LE.1g"), C(1, true, 10f, "p1BE.1g"),
C(2, false, 10f, "p2LE.1g"), C(2, true, 10f, "p2BE.1g"),
C(3, false, 10f, "p3LE.1g"), C(3, true, 10f, "p3BE.1g"),
C(1, false, 2f, "p1LE.5g"), C(1, true, 2f, "p1BE.5g"),
C(1, false, 0.1f, "p1LEcg"), C(1, true, 0.1f, "p1BEcg"),
C(3, false, 0.1f, "p3LEcg"), C(3, true, 0.1f, "p3BEcg"),
)
for (c in candidates) {
if (c.pos + 1 >= data.size) continue
val raw = if (c.be) u16be(data, c.pos) else u16le(data, c.pos)
if (raw == 0) continue
val g = raw / c.div
if (g in MIN_GRAMS..MAX_GRAMS) return WeightReading(g, "g", stable = false)
}
return null
}
private fun u16le(b: ByteArray, off: Int): Int =
(b[off].toInt() and 0xFF) or ((b[off + 1].toInt() and 0xFF) shl 8)
private fun u16be(b: ByteArray, off: Int): Int =
((b[off].toInt() and 0xFF) shl 8) or (b[off + 1].toInt() and 0xFF)
}
@@ -0,0 +1,39 @@
package it.dadaloop.evershelf.kiosk
import android.os.Bundle
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import it.dadaloop.evershelf.kiosk.databinding.ActivitySettingsBinding
private const val PREFS_NAME = "evershelf_kiosk"
private const val PREF_URL = "evershelf_url"
private const val DEFAULT_URL = "http://evershelf.local"
class SettingsActivity : AppCompatActivity() {
private lateinit var binding: ActivitySettingsBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivitySettingsBinding.inflate(layoutInflater)
setContentView(binding.root)
val prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE)
binding.editUrl.setText(prefs.getString(PREF_URL, DEFAULT_URL))
binding.btnSave.setOnClickListener {
val url = binding.editUrl.text.toString().trim()
if (url.isEmpty()) {
Toast.makeText(this, "URL cannot be empty", Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
prefs.edit().putString(PREF_URL, url).apply()
Toast.makeText(this, "Saved! Returning to kiosk...", Toast.LENGTH_SHORT).show()
finish()
}
binding.btnBack.setOnClickListener {
finish()
}
}
}
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#000000">
<WebView
android:id="@+id/webView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<!-- Settings button — small transparent gear icon in top-right corner -->
<ImageButton
android:id="@+id/btnSettings"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="top|end"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:background="@android:color/transparent"
android:src="@android:drawable/ic_menu_manage"
android:alpha="0.15"
android:contentDescription="Settings"
android:scaleType="centerInside" />
</FrameLayout>
@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="#1a1a2e"
android:padding="32dp"
android:gravity="center">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="⚙️ EverShelf Kiosk Settings"
android:textColor="#FFFFFF"
android:textSize="22sp"
android:textStyle="bold"
android:layout_marginBottom="32dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="EverShelf Server URL"
android:textColor="#AAAAAA"
android:textSize="14sp"
android:layout_marginBottom="8dp" />
<EditText
android:id="@+id/editUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="http://192.168.1.100"
android:inputType="textUri"
android:textColor="#FFFFFF"
android:textColorHint="#666666"
android:background="#2a2a3e"
android:padding="14dp"
android:textSize="16sp"
android:singleLine="true"
android:layout_marginBottom="24dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="The app will display this URL in full-screen kiosk mode.\nThe Scale Gateway runs on port 8765 (WebSocket).\nSet the gateway URL in EverShelf settings to:\nws://localhost:8765"
android:textColor="#888888"
android:textSize="13sp"
android:lineSpacingExtra="4dp"
android:layout_marginBottom="32dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSave"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Save &amp; Return"
android:textSize="16sp"
android:backgroundTint="#7C3AED"
android:layout_marginBottom="12dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnBack"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Cancel"
android:textSize="14sp"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:strokeColor="#666666"
android:textColor="#AAAAAA" />
</LinearLayout>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="accent">#7C3AED</color>
<color name="green">#059669</color>
<color name="red">#EF4444</color>
<color name="blue">#1D4ED8</color>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">EverShelf Kiosk</string>
</resources>
+5
View File
@@ -0,0 +1,5 @@
// Top-level build file
plugins {
id("com.android.application") version "8.2.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
}
+2
View File
@@ -0,0 +1,2 @@
android.useAndroidX=true
android.enableJetifier=true
@@ -0,0 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+17
View File
@@ -0,0 +1,17 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "EverShelf Kiosk"
include(":app")
+16 -9
View File
@@ -20,7 +20,7 @@
<!-- Top Header --> <!-- Top Header -->
<header class="app-header"> <header class="app-header">
<div class="header-content"> <div class="header-content">
<h1 class="header-title" onclick="showPage('dashboard')"><span data-i18n="nav.title">🏠 EverShelf</span><span class="header-version">v1.3.0</span></h1> <h1 class="header-title" onclick="showPage('dashboard')"><span data-i18n="nav.title">🏠 EverShelf</span><span class="header-version">v1.4.0</span></h1>
<div class="header-actions"> <div class="header-actions">
<span id="scale-status-indicator" class="scale-status-indicator scale-status-disconnected" style="display:none" data-i18n-title="scale.status_disconnected" title="⚖️ Bilancia">⚖️</span> <span id="scale-status-indicator" class="scale-status-indicator scale-status-disconnected" style="display:none" data-i18n-title="scale.status_disconnected" title="⚖️ Bilancia">⚖️</span>
<button class="header-scan-btn header-gemini-btn" onclick="showPage('chat')" title="Chat con Gemini" data-i18n-title="chat.title"> <button class="header-scan-btn header-gemini-btn" onclick="showPage('chat')" title="Chat con Gemini" data-i18n-title="chat.title">
@@ -62,6 +62,20 @@
</div> </div>
</div> </div>
<!-- Top notification banner (anomalous qty + consumption predictions) -->
<div id="alert-banner" class="alert-banner" style="display:none">
<div class="alert-banner-inner">
<div class="alert-banner-icon" id="alert-banner-icon">⚠️</div>
<div class="alert-banner-body">
<div class="alert-banner-title" id="alert-banner-title"></div>
<div class="alert-banner-detail" id="alert-banner-detail"></div>
</div>
<button class="alert-banner-close" id="alert-banner-close" onclick="dismissBannerItem()"></button>
</div>
<div class="alert-banner-actions" id="alert-banner-actions"></div>
<div class="alert-banner-counter" id="alert-banner-counter"></div>
</div>
<!-- Quick recipe suggestion --> <!-- Quick recipe suggestion -->
<div class="quick-recipe-bar" id="quick-recipe-bar" style="display:none"> <div class="quick-recipe-bar" id="quick-recipe-bar" style="display:none">
<button class="btn-quick-recipe" onclick="quickRecipeSuggestion()"> <button class="btn-quick-recipe" onclick="quickRecipeSuggestion()">
@@ -95,13 +109,6 @@
<div id="opened-list"></div> <div id="opened-list"></div>
</div> </div>
<!-- Review suspicious quantities -->
<div class="alert-section alert-review" id="alert-review" style="display:none">
<h3 data-i18n="dashboard.review_title">🔍 Da revisionare</h3>
<p class="review-hint" data-i18n="dashboard.review_hint">Quantità che sembrano anomale. Conferma se corrette o modifica.</p>
<div id="review-list"></div>
</div>
</section> </section>
<!-- ===== INVENTORY LIST ===== --> <!-- ===== INVENTORY LIST ===== -->
@@ -1247,6 +1254,6 @@
</div> </div>
</div> </div>
<script src="assets/js/app.js?v=20260415f"></script> <script src="assets/js/app.js?v=20260418a"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "EverShelf", "name": "EverShelf",
"short_name": "EverShelf", "short_name": "EverShelf",
"description": "Gestione completa della dispensa di casa con scansione barcode", "description": "Gestione completa della dispensa di casa con scansione barcode",
"version": "1.2.0", "version": "1.4.0",
"start_url": "/evershelf/", "start_url": "/evershelf/",
"display": "standalone", "display": "standalone",
"background_color": "#f0f4e8", "background_color": "#f0f4e8",
+18 -2
View File
@@ -82,7 +82,17 @@
"opened_title": "📦 Geöffnete Produkte", "opened_title": "📦 Geöffnete Produkte",
"review_title": "🔍 Zu prüfen", "review_title": "🔍 Zu prüfen",
"review_hint": "Mengen, die ungewöhnlich erscheinen. Bestätigen oder ändern.", "review_hint": "Mengen, die ungewöhnlich erscheinen. Bestätigen oder ändern.",
"quick_recipe": "🍳 Schnelles Rezept mit ablaufenden Produkten" "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"
}, },
"inventory": { "inventory": {
"title": "Vorrat", "title": "Vorrat",
@@ -457,6 +467,12 @@
"connected_ok": "Gateway-Verbindung erfolgreich!", "connected_ok": "Gateway-Verbindung erfolgreich!",
"timeout": "Timeout: keine Antwort vom Gateway", "timeout": "Timeout: keine Antwort vom Gateway",
"error_connect": "Verbindung zum Gateway nicht möglich", "error_connect": "Verbindung zum Gateway nicht möglich",
"tab": "Smart-Waage" "tab": "Smart-Waage",
"low_weight": "Gewicht < 10 g · manuell eingeben\n(Auto-Erkennung erfordert mind. 10 g)"
},
"prediction": {
"expected_qty": "Erwartet: {expected} {unit}",
"actual_qty": "Aktuell: {actual} {unit}",
"check_suggestion": "Überprüfe oder wiege die Restmenge"
} }
} }
+18 -2
View File
@@ -82,7 +82,17 @@
"opened_title": "📦 Opened Products", "opened_title": "📦 Opened Products",
"review_title": "🔍 To Review", "review_title": "🔍 To Review",
"review_hint": "Quantities that seem unusual. Confirm if correct or modify.", "review_hint": "Quantities that seem unusual. Confirm if correct or modify.",
"quick_recipe": "🍳 Quick recipe with expiring products" "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"
}, },
"inventory": { "inventory": {
"title": "Pantry", "title": "Pantry",
@@ -457,6 +467,12 @@
"connected_ok": "Gateway connection successful!", "connected_ok": "Gateway connection successful!",
"timeout": "Timeout: no response from gateway", "timeout": "Timeout: no response from gateway",
"error_connect": "Cannot connect to gateway", "error_connect": "Cannot connect to gateway",
"tab": "Smart Scale" "tab": "Smart Scale",
"low_weight": "Weight < 10 g · enter manually\n(auto-reading requires at least 10 g)"
},
"prediction": {
"expected_qty": "Expected: {expected} {unit}",
"actual_qty": "Current: {actual} {unit}",
"check_suggestion": "Check or weigh the remaining quantity"
} }
} }
+18 -2
View File
@@ -82,7 +82,17 @@
"opened_title": "📦 Prodotti Aperti", "opened_title": "📦 Prodotti Aperti",
"review_title": "🔍 Da revisionare", "review_title": "🔍 Da revisionare",
"review_hint": "Quantità che sembrano anomale. Conferma se corrette o modifica.", "review_hint": "Quantità che sembrano anomale. Conferma se corrette o modifica.",
"quick_recipe": "🍳 Ricetta veloce con prodotti in scadenza" "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"
}, },
"inventory": { "inventory": {
"title": "Dispensa", "title": "Dispensa",
@@ -457,6 +467,12 @@
"connected_ok": "Connessione gateway riuscita!", "connected_ok": "Connessione gateway riuscita!",
"timeout": "Timeout: nessuna risposta dal gateway", "timeout": "Timeout: nessuna risposta dal gateway",
"error_connect": "Impossibile connettersi al gateway", "error_connect": "Impossibile connettersi al gateway",
"tab": "Bilancia Smart" "tab": "Bilancia Smart",
"low_weight": "Peso < 10 g · inserisci manualmente\n(la lettura automatica richiede almeno 10 g)"
},
"prediction": {
"expected_qty": "Previsto: {expected} {unit}",
"actual_qty": "Attuale: {actual} {unit}",
"check_suggestion": "Verifica o pesa la quantità residua"
} }
} }