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