diff --git a/api/database.php b/api/database.php index badc715..fe3f6f7 100644 --- a/api/database.php +++ b/api/database.php @@ -165,6 +165,13 @@ function migrateDB(PDO $db): void { recalcSealedFridgeExpiry($db); $db->exec("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('migration_fridge_expiry_v1', '1')"); } + + // Add undone column to transactions if missing + $txCols = $db->query("PRAGMA table_info(transactions)")->fetchAll(); + $txColNames = array_column($txCols, 'name'); + if (!in_array('undone', $txColNames)) { + $db->exec("ALTER TABLE transactions ADD COLUMN undone INTEGER DEFAULT 0"); + } } /** diff --git a/api/index.php b/api/index.php index 9f8d846..24721c1 100644 --- a/api/index.php +++ b/api/index.php @@ -184,6 +184,10 @@ try { listTransactions($db); break; + case 'transaction_undo': + undoTransaction($db); + break; + // ===== STATS ===== case 'stats': getStats($db); @@ -846,6 +850,8 @@ function addToInventory(PDO $db): void { 'package_unit' => $prodInfo['package_unit'] ?? null, 'removed_from_bring' => $removedFromBring, ]); + // Inventory changed — force smart-shopping recompute on next request + invalidateSmartShoppingCache(); } function useFromInventory(PDO $db): void { @@ -1083,6 +1089,8 @@ function useFromInventory(PDO $db): void { } if ($openedId) $response['opened_id'] = $openedId; echo json_encode($response); + // Inventory changed — force smart-shopping recompute on next request + invalidateSmartShoppingCache(); } function updateInventory(PDO $db): void { @@ -1160,6 +1168,92 @@ function listTransactions(PDO $db): void { echo json_encode(['transactions' => $stmt->fetchAll()]); } +/** + * Undo a transaction (reverse its effect on inventory). + * Only available within 24 hours of the original transaction. + * - type='in' (add) → removes that quantity from inventory at the same location + * - type='out'/'waste' → adds that quantity back to inventory at the same location + * Marks the original as undone=1 and logs a counter-transaction with notes='[Annullato]'. + */ +function undoTransaction(PDO $db): void { + $input = json_decode(file_get_contents('php://input'), true); + $txId = (int)($input['id'] ?? 0); + if (!$txId) { + http_response_code(400); + echo json_encode(['error' => 'Transaction ID required']); + return; + } + + // Fetch original transaction + $stmt = $db->prepare("SELECT t.*, p.name FROM transactions t JOIN products p ON t.product_id = p.id WHERE t.id = ?"); + $stmt->execute([$txId]); + $tx = $stmt->fetch(); + if (!$tx) { + http_response_code(404); + echo json_encode(['error' => 'Transaction not found']); + return; + } + if ($tx['undone']) { + echo json_encode(['error' => 'Transaction already undone', 'already_undone' => true]); + return; + } + // Only allow within 24 hours + $ageSeconds = time() - strtotime($tx['created_at'] . ' UTC'); + if ($ageSeconds > 86400) { + echo json_encode(['error' => 'Can only undo transactions within 24 hours', 'too_old' => true]); + return; + } + + $db->beginTransaction(); + try { + $productId = (int)$tx['product_id']; + $quantity = (float)$tx['quantity']; + $location = $tx['location'] ?: 'dispensa'; + $type = $tx['type']; + + if ($type === 'in') { + // Reverse an ADD: remove quantity from inventory + $stmt2 = $db->prepare("SELECT id, quantity FROM inventory WHERE product_id = ? AND location = ? AND quantity > 0 ORDER BY quantity DESC LIMIT 1"); + $stmt2->execute([$productId, $location]); + $row = $stmt2->fetch(); + if ($row) { + $newQty = max(0, (float)$row['quantity'] - $quantity); + if ($newQty <= 0) { + $db->prepare("DELETE FROM inventory WHERE id = ?")->execute([$row['id']]); + } else { + $db->prepare("UPDATE inventory SET quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")->execute([$newQty, $row['id']]); + } + } + // Log counter-transaction + $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, 'out', ?, ?, '[Annullato]')")->execute([$productId, $quantity, $location]); + + } elseif ($type === 'out' || $type === 'waste') { + // Reverse a USE: add quantity back to inventory + $stmt2 = $db->prepare("SELECT id, quantity FROM inventory WHERE product_id = ? AND location = ? ORDER BY quantity DESC LIMIT 1"); + $stmt2->execute([$productId, $location]); + $row = $stmt2->fetch(); + if ($row) { + $db->prepare("UPDATE inventory SET quantity = quantity + ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")->execute([$quantity, $row['id']]); + } else { + // No row at this location — create one without expiry + $db->prepare("INSERT INTO inventory (product_id, location, quantity) VALUES (?, ?, ?)")->execute([$productId, $location, $quantity]); + } + // Log counter-transaction + $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, 'in', ?, ?, '[Annullato]')")->execute([$productId, $quantity, $location]); + } + + // Mark original as undone + $db->prepare("UPDATE transactions SET undone = 1 WHERE id = ?")->execute([$txId]); + $db->commit(); + echo json_encode(['success' => true, 'name' => $tx['name']]); + } catch (Exception $e) { + $db->rollBack(); + http_response_code(500); + echo json_encode(['error' => 'DB error: ' . $e->getMessage()]); + } +} + + // ===== STATS ===== function getStats(PDO $db): void { @@ -1812,6 +1906,7 @@ function generateRecipe(PDO $db): void { $input = json_decode(file_get_contents('php://input'), true); $mealType = $input['meal'] ?? 'pranzo'; $persons = max(1, intval($input['persons'] ?? 1)); + $subType = $input['sub_type'] ?? ''; $options = $input['options'] ?? []; $appliances = $input['appliances'] ?? []; $dietaryRestrictions = $input['dietary_restrictions'] ?? ''; @@ -1963,6 +2058,30 @@ function generateRecipe(PDO $db): void { ]; $mealLabel = $mealLabels[$mealType] ?? $mealType; + // Sub-type specialization for dolce/succo + $subTypeLabels = [ + 'dolce' => [ + 'torta' => 'Torta (soffice, da forno: torta di mele, ciambellone, plumcake, angel cake, ecc.)', + 'crema' => 'Crema o Budino (crema pasticcera, panna cotta, mousse, tiramisù, budino, semifreddo)', + 'crumble' => 'Crumble o Crostata (base croccante: crumble di frutta, crostata, sbriciolata)', + 'biscotti' => 'Biscotti o Pasticcini (biscotti, cookies, muffin, cupcake, pasticcini)', + 'frutta' => 'Dolce alla Frutta (macedonia creativa, frutta caramellata, sorbetto, frullato dolce)', + ], + 'succo' => [ + 'dolce' => 'Succo Dolce e Fruttato (mix di frutta dolce: pesca, mela, pera, fragola, banana)', + 'energizzante' => 'Succo Energizzante (con zenzero, curcuma, barbabietola, carota, mela verde)', + 'detox' => 'Succo Detox / Verde (cetriolo, sedano, spinaci, mela verde, limone)', + 'rinfrescante' => 'Succo Rinfrescante (anguria, menta, lime, cetriolo, acqua di cocco)', + 'vitaminico' => 'Succo Vitaminico / Agrumi (arancia, pompelmo, limone, kiwi, mandarino)', + ] + ]; + $subTypeText = ''; + if (!empty($subType) && isset($subTypeLabels[$mealType][$subType])) { + $subHint = $subTypeLabels[$mealType][$subType]; + $mealLabel .= " — tipo: $subHint"; + $subTypeText = "\n\n🎨 SOTTO-TIPO RICHIESTO:\nL'utente ha scelto specificamente: {$subHint}\nLa ricetta DEVE essere di questo tipo preciso. Non proporre un tipo diverso di {$mealType}."; + } + // Build extra rules from options $extraRules = []; $optionLabels = [ @@ -2123,7 +2242,7 @@ function generateRecipe(PDO $db): void { $prompt = << mb_strlen($w) >= 4 // skip very short words — too ambiguous + ); foreach ($catalog['it2de'] as $itLower => $deKey) { - if (str_contains($lower, $itLower) || str_contains($itLower, $lower)) { + if (str_contains($itLower, ' ')) continue; // multi-word key → exact-only + if (mb_strlen($itLower) < 4) continue; // too short → skip (gin, rum, etc.) + if (in_array($itLower, $inputWords, true)) { return $deKey; } } - - // Try matching first word: "Petto di pollo" → "Pollo" = Poulet - $words = explode(' ', $lower); - foreach ($words as $word) { - if (mb_strlen($word) < 3) continue; - foreach ($catalog['it2de'] as $itLower => $deKey) { - if ($itLower === $word) { - return $deKey; - } - } - } - - // No match - return original (Bring! will show as custom item) + + // No match — return the original Italian name so Bring! shows it as a custom item return $italianName; } @@ -2917,6 +3034,17 @@ function bringCleanSpecs(): void { * Serve smart shopping from cache (written by cron), falling back to live computation. * Cache is valid for up to 10 minutes; if stale or missing, compute on the fly. */ +/** + * Invalidate the smart shopping cache so the next request recomputes live. + * Call after any inventory_add or inventory_use that changes stock meaningfully. + */ +function invalidateSmartShoppingCache(): void { + $cacheFile = __DIR__ . '/../data/smart_shopping_cache.json'; + if (file_exists($cacheFile)) { + @unlink($cacheFile); + } +} + function smartShoppingCached(PDO $db): void { $cacheFile = __DIR__ . '/../data/smart_shopping_cache.json'; $maxAge = 10 * 60; // 10 minutes @@ -3140,11 +3268,16 @@ function smartShopping(PDO $db): void { // Out of stock if ($qty <= 0) { - // If ANY significant token of this depleted product also appears in an in-stock product, + // If ANY *specific* token of this depleted product also appears in an in-stock product, // the user's need is already covered — skip flagging it. - // Examples: 'Passata di pomodoro' depleted, 'Polpa di pomodoro' in stock → share 'pomodoro' → skip - // 'Aglio rosso' depleted, 'Aglio' in stock → share 'aglio' → skip - $pToks = $nameTokens($p['name']); + // Generic preparation/type words (succo, polpa, crema, ecc.) are excluded from this check + // to avoid false coverage: 'limmi succo di limone' must NOT be suppressed by 'Succo e polpa di pera'. + // A token must appear in both names AND be specific (not in the generic list) to count. + $coverageGeneric = ['succo','polpa','crema','salsa','frutta','verdura','intero', + 'parzialmente','scremato','biologico','naturale','integrale', + 'cotto','fresco','secco','arrostito','bollito','sgusciato', + 'bianco','rosso','nero','giallo','verde','misto','dolce','light']; + $pToks = array_diff($nameTokens($p['name']), $coverageGeneric); $coveredByEquivalent = false; foreach ($pToks as $tok) { if (($stockByAnyToken[$tok] ?? 0) > 0) { $coveredByEquivalent = true; break; } @@ -3157,10 +3290,18 @@ function smartShopping(PDO $db): void { $reasons[] = 'Esaurito'; $score += 100; if ($useCount >= 5) { $score += 20; $reasons[] = "Uso frequente ({$useCount}x)"; } + } elseif ($isFrequent && $isRecent && $buyCount == 1 && $useCount >= 3) { + // Bought once but used ≥3 times → proven consumption pattern → high + $urgency = 'high'; + $reasons[] = 'Esaurito'; + $score += 75; + if ($useCount >= 5) { $score += 10; $reasons[] = "Uso frequente ({$useCount}x)"; } } elseif ($isFrequent && $isRecent && $buyCount == 1) { - // Frequent use but only bought once — not yet a proven staple → skip - continue; - } elseif ($isRegular && $isRecent && ($useCount >= 4 || $buyCount >= 3)) { + // Frequent use, bought once, <3 uses — not yet proven → medium + $urgency = 'medium'; + $reasons[] = 'Esaurito'; + $score += 45; + } elseif ($isRegular && $isRecent && ($useCount >= 3 || $buyCount >= 2)) { // Regularly used, recently active → high $urgency = 'high'; $reasons[] = 'Esaurito'; diff --git a/assets/css/style.css b/assets/css/style.css index a54bebc..2108892 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -853,8 +853,8 @@ body { min-width: 0; } .scale-live-val { - font-size: 1.15rem; - font-weight: 700; + font-size: 1.6rem; + font-weight: 800; color: var(--text); line-height: 1.2; } @@ -2895,6 +2895,37 @@ body { text-underline-offset: 2px; } +/* ── Preferred use-location (collapsed row) ── */ +.pref-loc-info { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 10px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 10px; +} +.pref-loc-name { + font-size: 0.95rem; + font-weight: 600; + color: var(--text); +} +.btn-link { + background: none; + border: none; + color: var(--accent); + font-size: 0.82rem; + cursor: pointer; + padding: 2px 6px; + text-decoration: underline; + text-underline-offset: 2px; +} +.pref-loc-full-btns { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + /* ===== PRODUCT DETAILS CARD (Action Page) ===== */ .product-details-card { background: var(--bg-card); @@ -3855,6 +3886,43 @@ body { background: rgba(52, 120, 246, 0.06); } +.log-entry.log-undone { + opacity: 0.45; + filter: grayscale(60%); + text-decoration: line-through; +} + +.log-undone-badge { + display: inline-block; + font-size: 0.68rem; + font-weight: 600; + color: #fff; + background: var(--text-muted); + border-radius: 4px; + padding: 1px 5px; + text-decoration: none; + vertical-align: middle; + margin-left: 4px; +} + +.btn-log-undo { + flex-shrink: 0; + background: none; + border: 1px solid var(--border); + color: var(--text-muted); + border-radius: 6px; + padding: 3px 8px; + font-size: 0.85rem; + cursor: pointer; + line-height: 1.4; + transition: background 0.15s, color 0.15s; +} +.btn-log-undo:hover { + background: rgba(255,255,255,0.08); + color: var(--text); +} + + .log-icon { font-size: 1.2rem; flex-shrink: 0; @@ -4186,6 +4254,23 @@ body { display: none; } +/* Sub-type grid for dolce/succo */ +.recipe-subtype-grid { + margin-top: 8px; + grid-template-columns: 1fr 1fr; + animation: bannerSlideIn 0.25s ease-out; +} +.recipe-subtype-chip { + font-size: 0.75rem; + padding: 8px 6px; + background: #fef9ef; + border-color: #f0e4cc; +} +.recipe-subtype-chip:has(input:checked) { + background: #fff3d4; + border-color: #e6a817; +} + /* ===== LARGER PRODUCT PREVIEW (Action page) ===== */ .product-preview-large { flex-direction: column; diff --git a/assets/js/app.js b/assets/js/app.js index dbafd7b..6eb308d 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -116,6 +116,9 @@ function _scaleOnMessage(msg) { _scaleDevice = msg.device || null; _scaleBattery = msg.battery ?? null; _scaleUpdateStatus(_scaleConnected ? 'connected' : 'searching'); + // Refresh all scale UI elements immediately so buttons/live-box appear + // without requiring a manual page refresh + updateScaleReadButtons(); } else if (msg.type === 'weight') { // Ignore negative weight values (tare artifacts, sensor noise) if (parseFloat(msg.value) < 0) return; @@ -214,21 +217,26 @@ function _scaleUpdateLiveBox(msg) { } else { box.classList.remove('scale-low-weight'); const stIcon = msg.stable ? ' ✓' : ' …'; - if (valEl) valEl.textContent = `${isFinite(raw) ? raw : '—'} ${msg.unit || 'kg'}${stIcon}`; - // Show conversion hint when product unit is ml + // Show converted ML if target unit is ml (instead of raw grams) + let displayVal = `${isFinite(raw) ? raw : '—'} ${msg.unit || 'kg'}`; let targetUnit = null; if (_useConfMode && _useConfMode._activeUnit === 'sub') { targetUnit = (_useConfMode.packageUnit || '').toLowerCase(); } else { targetUnit = _useNormalUnit; } + if (targetUnit === 'ml' && rawUnit !== 'ml' && isFinite(raw) && raw > 0) { + let grams = raw; + if (rawUnit === 'kg') grams = raw * 1000; + else if (rawUnit === 'lbs' || rawUnit === 'lb') grams = raw * 453.592; + else if (rawUnit === 'oz') grams = raw * 28.3495; + const density = _scaleDensityForProduct(currentProduct); + const ml = Math.round(grams / density); + displayVal = `${ml} ml`; + } + if (valEl) valEl.textContent = displayVal + stIcon; if (lblEl) { - if (targetUnit === 'ml' && rawUnit !== 'ml') { - lblEl.textContent = '⚖️ Peso in grammi → verrà convertito in ml'; - lblEl.style.display = ''; - } else { - lblEl.textContent = ''; - } + lblEl.textContent = ''; } } } @@ -384,6 +392,21 @@ function _scaleAutoFillRecipeUse(msg) { } } + // Update live box in modal — show the already-converted value in the target unit + const livVal = document.getElementById('ruse-scale-live-val'); + const livLabel = document.getElementById('ruse-scale-live-label'); + const livStatus = document.getElementById('ruse-scale-live-status'); + if (livVal) { + // val is already converted to target unit (g or ml); show it directly + if (val >= 10) { + livVal.textContent = `${val} ${unit}`; + } else { + // val not usable yet — show raw reading + livVal.textContent = `${msg.value} ${msg.unit || 'kg'}`; + } + } + if (livStatus) livStatus.textContent = msg.stable ? '✓ Stabile' : '…'; + // Update live hint in modal with the raw scale reading always const hint = document.getElementById('ruse-scale-hint'); if (hint) { @@ -396,6 +419,7 @@ function _scaleAutoFillRecipeUse(msg) { if (val < 10) { _cancelScaleStabilityWait(); // stop bar only; keep sentinel + if (livLabel) livLabel.textContent = 'Peso troppo basso — attendi…'; return; } @@ -408,6 +432,10 @@ function _scaleAutoFillRecipeUse(msg) { _scaleStabilityVal = val; _scaleUserDismissed = false; _cancelScaleTimersOnly(); + if (livLabel) livLabel.textContent = 'Peso rilevato — attendi 10s di stabilità…'; + // Hide confirm bar when new value arrives + const confirmWrap = document.getElementById('ruse-scale-confirm-wrap'); + if (confirmWrap) confirmWrap.style.display = 'none'; _startScaleStabilityWait(() => { const inp = document.getElementById('ruse-quantity'); if (inp) inp.value = val; @@ -415,14 +443,35 @@ function _scaleAutoFillRecipeUse(msg) { hint.textContent = `⚖️ Peso bilancia: ${val} ${unit}${hintExtra}`; hint.style.display = ''; } - _startScaleAutoConfirm(() => { _scaleLastConfirmedGrams = grams; submitRecipeUse(false); }, 'btn-ruse-submit'); + if (livLabel) livLabel.textContent = `✅ ${val} ${unit} — conferma automatica tra 5s (tocca per annullare)`; + if (livVal) livVal.style.color = '#22c55e'; + const confirmWrap2 = document.getElementById('ruse-scale-confirm-wrap'); + if (confirmWrap2) { confirmWrap2.style.display = ''; } + const confirmBar = document.getElementById('ruse-scale-confirm-bar'); + if (confirmBar) confirmBar.style.width = '100%'; + _startScaleAutoConfirm(() => { + _scaleLastConfirmedGrams = grams; + if (livVal) livVal.style.color = ''; + submitRecipeUse(false); + }, 'btn-ruse-submit'); }); } else if (!_scaleUserDismissed && !_scaleStabilityTimer && !_scaleAutoConfirmTimer) { _cancelScaleTimersOnly(); + if (livLabel) livLabel.textContent = 'Peso rilevato — attendi 10s di stabilità…'; _startScaleStabilityWait(() => { const inp = document.getElementById('ruse-quantity'); if (inp) inp.value = val; - _startScaleAutoConfirm(() => { _scaleLastConfirmedGrams = grams; submitRecipeUse(false); }, 'btn-ruse-submit'); + if (livLabel) livLabel.textContent = `✅ ${val} ${unit} — conferma automatica tra 5s (tocca per annullare)`; + if (livVal) livVal.style.color = '#22c55e'; + const confirmWrap3 = document.getElementById('ruse-scale-confirm-wrap'); + if (confirmWrap3) confirmWrap3.style.display = ''; + const confirmBar2 = document.getElementById('ruse-scale-confirm-bar'); + if (confirmBar2) confirmBar2.style.width = '100%'; + _startScaleAutoConfirm(() => { + _scaleLastConfirmedGrams = grams; + if (livVal) livVal.style.color = ''; + submitRecipeUse(false); + }, 'btn-ruse-submit'); }); } } @@ -445,6 +494,17 @@ function _cancelScaleTimersOnly() { const ruseBtn = document.getElementById('btn-ruse-submit'); if (useBtn) useBtn.style.background = ''; if (ruseBtn) ruseBtn.style.background = ''; + // Reset modal confirm bar and live val colour + const confirmBar = document.getElementById('ruse-scale-confirm-bar'); + const livVal = document.getElementById('ruse-scale-live-val'); + const confirmWrap = document.getElementById('ruse-scale-confirm-wrap'); + if (confirmBar) { confirmBar.style.width = '100%'; } + if (confirmWrap) confirmWrap.style.display = 'none'; + if (livVal) livVal.style.color = ''; + const livLabel = document.getElementById('ruse-scale-live-label'); + if (livLabel && livLabel.textContent.startsWith('✅')) { + livLabel.textContent = 'Annullato — rimetti l\'ingrediente sulla bilancia per riprendere'; + } document.removeEventListener('pointerdown', _cancelScaleAutoConfirmOnTouch, true); } @@ -463,27 +523,32 @@ function _cancelScaleAutoConfirm(fromTouch) { } } -/** Stop the stability wait and reset its progress bar. */ +/** Stop the stability wait and reset its progress bar(s). */ function _cancelScaleStabilityWait() { if (_scaleStabilityTimer) { clearTimeout(_scaleStabilityTimer); _scaleStabilityTimer = null; } if (_scaleStabilityRAF) { cancelAnimationFrame(_scaleStabilityRAF); _scaleStabilityRAF = null; } - const bar = document.getElementById('scale-live-progress-bar'); - if (bar) bar.style.width = '0%'; + const bar = document.getElementById('scale-live-progress-bar'); + const bar2 = document.getElementById('ruse-scale-progress-bar'); + if (bar) bar.style.width = '0%'; + if (bar2) bar2.style.width = '0%'; } /** - * Start a 10-second stability wait with an animated progress bar in the live box. + * Start a 10-second stability wait with an animated progress bar. + * Updates both #scale-live-progress-bar (use page) and #ruse-scale-progress-bar (recipe modal). * Calls onStable() when weight unchanged for 10 s. */ function _startScaleStabilityWait(onStable) { _cancelScaleStabilityWait(); const duration = 10000; const start = performance.now(); - const bar = document.getElementById('scale-live-progress-bar'); + const bar = document.getElementById('scale-live-progress-bar'); + const bar2 = document.getElementById('ruse-scale-progress-bar'); function tick() { const pct = Math.min(100, ((performance.now() - start) / duration) * 100); - if (bar) bar.style.width = pct + '%'; + if (bar) bar.style.width = pct + '%'; + if (bar2) bar2.style.width = pct + '%'; if (pct < 100) { _scaleStabilityRAF = requestAnimationFrame(tick); } } _scaleStabilityRAF = requestAnimationFrame(tick); @@ -491,7 +556,8 @@ function _startScaleStabilityWait(onStable) { _scaleStabilityTimer = setTimeout(() => { _scaleStabilityTimer = null; if (_scaleStabilityRAF) { cancelAnimationFrame(_scaleStabilityRAF); _scaleStabilityRAF = null; } - if (bar) bar.style.width = '0%'; + if (bar) bar.style.width = '0%'; + if (bar2) bar2.style.width = '0%'; onStable(); }, duration); } @@ -500,16 +566,21 @@ function _startScaleAutoConfirm(onConfirm, btnId) { if (_scaleAutoConfirmRAF) { cancelAnimationFrame(_scaleAutoConfirmRAF); _scaleAutoConfirmRAF = null; } const btn = btnId ? document.getElementById(btnId) : null; const baseBg = btn ? getComputedStyle(btn).backgroundColor : ''; + // Also update the modal countdown bar if present + const ruseCountdownBar = document.getElementById('ruse-scale-confirm-bar'); const duration = 5000; const start = performance.now(); function tick() { const elapsed = performance.now() - start; const pct = Math.min(100, (elapsed / duration) * 100); + // Reverse (countdown): button fill shrinks from right to left if (btn) { btn.style.background = - `linear-gradient(to right, rgba(255,255,255,0.35) ${pct}%, rgba(255,255,255,0) ${pct}%), ${baseBg}`; + `linear-gradient(to left, rgba(255,255,255,0.35) ${100 - pct}%, rgba(255,255,255,0) ${100 - pct}%), ${baseBg}`; } + // Modal countdown progress bar shrinks + if (ruseCountdownBar) ruseCountdownBar.style.width = (100 - pct) + '%'; if (elapsed < duration) { _scaleAutoConfirmRAF = requestAnimationFrame(tick); } } _scaleAutoConfirmRAF = requestAnimationFrame(tick); @@ -517,6 +588,7 @@ function _startScaleAutoConfirm(onConfirm, btnId) { _scaleAutoConfirmTimer = setTimeout(() => { _scaleAutoConfirmTimer = null; if (btn) btn.style.background = ''; + if (ruseCountdownBar) ruseCountdownBar.style.width = '0%'; document.removeEventListener('pointerdown', _cancelScaleAutoConfirmOnTouch, true); onConfirm(); }, duration); @@ -2823,11 +2895,14 @@ function filterLocation(loc) { } function filterInventory() { - const q = document.getElementById('inventory-search').value.toLowerCase(); + const q = document.getElementById('inventory-search').value.toLowerCase().trim(); + const qas = document.getElementById('quick-access-section'); if (!q) { + if (qas) qas.style.display = ''; renderInventory(currentInventory); return; } + if (qas) qas.style.display = 'none'; const filtered = currentInventory.filter(i => i.name.toLowerCase().includes(q) || (i.brand && i.brand.toLowerCase().includes(q)) || @@ -5369,20 +5444,40 @@ async function loadUseInventoryInfo() { return false; }); const firstLoc = openedItem ? openedItem.location : items[0].location; - document.getElementById('use-location').value = firstLoc; // Build location buttons only for locations where the product exists const productLocations = [...new Set(items.map(i => i.location))]; const locSelector = document.getElementById('use-location-selector'); - locSelector.innerHTML = productLocations.map(loc => { + + // Prefer the remembered location (if confirmed), else use the opened-package heuristic + const prefLoc = _getPreferredUseLocation(currentProduct.id); + const activeLoc = (prefLoc && productLocations.includes(prefLoc)) ? prefLoc : firstLoc; + document.getElementById('use-location').value = activeLoc; + + // Builder for the full set of location buttons + const buildLocButtons = (active) => productLocations.map(loc => { const locInfo = LOCATIONS[loc] || { icon: '📦', label: loc }; const locItems = items.filter(i => i.location === loc); const locQty = locItems.reduce((s, i) => s + parseFloat(i.quantity), 0); const u = locItems[0].unit || 'pz'; const qtyLabel = formatQuantity(locQty, u, locItems[0].default_quantity, locItems[0].package_unit); - return ``; + return ``; }).join(''); + if (prefLoc && productLocations.includes(prefLoc) && productLocations.length > 1) { + // Confirmed preference → show collapsed row + hidden full picker + const locInfo = LOCATIONS[prefLoc] || { icon: '📦', label: prefLoc }; + locSelector.innerHTML = ` +
+ ${locInfo.icon} ${locInfo.label} + +
+ + `; + } else { + locSelector.innerHTML = buildLocButtons(activeLoc); + } + const unit = items[0].unit || 'pz'; const pkgSize = parseFloat(items[0].default_quantity) || 0; @@ -5526,6 +5621,47 @@ function selectUseLocation(btn, loc) { document.getElementById('use-location').value = loc; } +// ── PREFERRED USE LOCATION ─────────────────────────────────────────────── +// After 3+ consistent choices from the same location for a product, +// auto-selects it and hides the location picker (user can still tap "cambia"). +const _PREF_LOC_KEY = '_prefUseLoc'; +const _PREF_LOC_NEEDED = 3; // choices needed to confirm a preference + +function _getPrefLocHistory(productId) { + try { + const all = JSON.parse(localStorage.getItem(_PREF_LOC_KEY) || '{}'); + return all[String(productId)] || []; + } catch { return []; } +} + +function _recordUseLocationChoice(productId, loc) { + try { + const all = JSON.parse(localStorage.getItem(_PREF_LOC_KEY) || '{}'); + const key = String(productId); + const hist = all[key] || []; + hist.push(loc); + if (hist.length > 8) hist.splice(0, hist.length - 8); // keep last 8 + all[key] = hist; + localStorage.setItem(_PREF_LOC_KEY, JSON.stringify(all)); + } catch { } +} + +function _getPreferredUseLocation(productId) { + const hist = _getPrefLocHistory(productId); + if (hist.length < _PREF_LOC_NEEDED) return null; + const recent = hist.slice(-5); // look at last 5 + const counts = {}; + for (const loc of recent) counts[loc] = (counts[loc] || 0) + 1; + const [topLoc, topCount] = Object.entries(counts).sort((a, b) => b[1] - a[1])[0]; + return topCount >= _PREF_LOC_NEEDED ? topLoc : null; +} + +function _expandUseLocationSelector() { + document.getElementById('pref-loc-info')?.style.setProperty('display', 'none'); + document.getElementById('pref-loc-full')?.style.removeProperty('display'); +} +// ──────────────────────────────────────────────────────────────────────── + function setPzFraction(frac) { document.getElementById('use-quantity').value = frac; document.querySelectorAll('#pz-fraction-btns .frac-btn').forEach(b => { @@ -5537,7 +5673,7 @@ function setPzFraction(frac) { function isLowStock(totalRemaining, unit, defaultQty) { if (totalRemaining <= 0) return true; // fully depleted → definitely needs restocking if (unit === 'pz') return totalRemaining <= 1; // only 1 piece left - if (unit === 'conf') return totalRemaining <= 1; + if (unit === 'conf') return totalRemaining < 1; // only warn when less than 1 full pack remains (opened/partial) // Weight/volume: use percentage of default_qty or fixed threshold if (defaultQty > 0) return totalRemaining <= defaultQty * 0.25; // Fallback fixed thresholds @@ -5860,6 +5996,7 @@ async function submitUse(e) { } // If there's remaining quantity, offer to move to another location const usedFrom = document.getElementById('use-location').value; + _recordUseLocationChoice(currentProduct.id, usedFrom); // track for preferred-location feature const moveCallback = result.remaining > 0 ? () => showMoveAfterUseModal(currentProduct, usedFrom, result.remaining, result.opened_id) : () => showPage('dashboard'); @@ -6568,9 +6705,19 @@ function _markBringPurchased(names) { localStorage.setItem('_bringPurchasedBlocklist', JSON.stringify(map)); } -function _isBringPurchased(name) { +function _isBringPurchased(name, urgency) { + // Critical items: blocked only 30 min (enough to put groceries away). + // High: 90 min. Others: full 4 h. + const ttl = urgency === 'critical' ? 30 * 60 * 1000 + : urgency === 'high' ? 90 * 60 * 1000 + : _BRING_PURCHASED_TTL; const map = _getBringPurchasedBlocklist(); - return Object.keys(map).some(k => _nameTokens(name)[0] === _nameTokens(k)[0] || k === name.toLowerCase()); + const now = Date.now(); + return Object.keys(map).some(k => { + const matches = _nameTokens(name)[0] === _nameTokens(k)[0] || k === name.toLowerCase(); + if (!matches) return false; + return (now - map[k]) < ttl; + }); } async function autoAddCriticalItems() { @@ -6578,9 +6725,13 @@ async function autoAddCriticalItems() { const lastRun = parseInt(localStorage.getItem('_autoAddedCriticalTs') || '0'); if (Date.now() - lastRun < 10 * 60 * 1000) return; localStorage.setItem('_autoAddedCriticalTs', String(Date.now())); - const critical = smartShoppingItems.filter(i => i.urgency === 'critical' && !i.on_bring && !_isBringPurchased(i.name)); - if (critical.length === 0) return; - const itemsToAdd = critical.map(i => ({ name: i.name, specification: _urgencyToSpec(i.urgency, i.brand) })); + // Auto-add: critical urgency (always) + high urgency that are completely out of stock (qty=0) + const toAdd = smartShoppingItems.filter(i => + !i.on_bring && !_isBringPurchased(i.name, i.urgency) && + (i.urgency === 'critical' || (i.urgency === 'high' && i.current_qty === 0)) + ); + if (toAdd.length === 0) return; + const itemsToAdd = toAdd.map(i => ({ name: i.name, specification: _urgencyToSpec(i.urgency, i.brand) })); try { const result = await api('bring_add', {}, 'POST', { items: itemsToAdd, listUUID: shoppingListUUID }); if (result.success && result.added > 0) { @@ -6591,6 +6742,25 @@ async function autoAddCriticalItems() { } catch (e) { /* ignore */ } } +/** + * Manually force a full Bring! sync: clears the purchased blocklist and all + * auto-add/cleanup timers, then re-adds all urgent items from scratch. + * Triggered by the user pressing "Forza sincronizzazione Bring!". + */ +async function forceSyncBring() { + const btn = document.getElementById('btn-force-sync'); + if (btn) { btn.disabled = true; btn.textContent = '⏳ Sincronizzazione…'; } + // Clear all guards so the next run is unconditional + localStorage.removeItem('_bringPurchasedBlocklist'); + localStorage.removeItem('_autoAddedCriticalTs'); + localStorage.removeItem('_bringCleanupTs'); + logOperation('force_sync_bring', {}); + // Reload everything from scratch + await loadShoppingList(); + if (btn) { btn.disabled = false; btn.textContent = '🔄 Forza sincronizzazione Bring!'; } + showToast('🔄 Sincronizzazione completata', 'success'); +} + /** * One-time cleanup: remove items from Bring! that were auto-added but the algorithm no * longer considers relevant. CONSERVATIVE: only removes items that match a known product @@ -7934,21 +8104,30 @@ async function loadLog(more = false) { colorClass = 'log-in'; } else { icon = '➖'; - typeLabel = 'Usato'; + typeLabel = t.type === 'waste' ? 'Buttato' : 'Usato'; colorClass = 'log-out'; } const brand = t.brand ? ` (${t.brand})` : ''; const loc = t.location || ''; const locLabels = { 'frigo': '🧊 Frigo', 'freezer': '❄️ Freezer', 'dispensa': '🗄️ Dispensa' }; const locStr = t.type === 'bring' ? '' : (locLabels[loc] || ('📍 ' + loc)); - const notes = t.notes ? ` · ${t.notes}` : ''; + const isAnnotation = (t.notes || '').includes('[Annullato]'); + const notes = t.notes && !isAnnotation ? ` · ${t.notes}` : ''; + const undone = t.undone == 1 || isAnnotation; - html += `
`; + // Can undo if within 24h, not already undone, not a bring entry, not a counter-transaction + const ageMs = Date.now() - new Date(t.created_at + 'Z').getTime(); + const canUndo = !undone && t.type !== 'bring' && ageMs < 86400000; + + html += `
`; html += `${icon}`; html += `
`; - html += `
${t.name}${brand}
`; - html += `
${typeLabel} ${t.type !== 'bring' ? t.quantity + ' ' + (t.unit || '') + ' · ' : ''}${locStr}${notes} · ${timeStr}
`; + html += `
${escapeHtml(t.name)}${brand}${undone ? ' Annullato' : ''}
`; + html += `
${typeLabel} ${t.type !== 'bring' ? (t.quantity + ' ' + (t.unit || '')) + ' · ' : ''}${locStr}${notes} · ${timeStr}
`; html += `
`; + if (canUndo) { + html += ``; + } html += `
`; }); } @@ -7968,6 +8147,36 @@ async function loadLog(more = false) { } } +async function undoTransactionEntry(id, type, name) { + const action = type === 'in' ? 'rimozione di' : 'ripristino di'; + if (!confirm(`Annullare questa operazione?\n→ ${action} ${name}`)) return; + try { + const res = await api('transaction_undo', {}, 'POST', { id }); + if (res.success) { + showToast(`↩ Operazione annullata per ${res.name || name}`, 'success'); + // Mark the entry visually without reloading all + const el = document.getElementById(`log-entry-${id}`); + if (el) { + el.classList.add('log-undone'); + const undoBtn = el.querySelector('.btn-log-undo'); + if (undoBtn) undoBtn.remove(); + const nameEl = el.querySelector('.log-product strong'); + if (nameEl && !el.querySelector('.log-undone-badge')) { + nameEl.insertAdjacentHTML('afterend', ' Annullato'); + } + } + } else if (res.already_undone) { + showToast('Operazione già annullata', 'info'); + } else if (res.too_old) { + showToast('Non è possibile annullare operazioni più vecchie di 24 ore', 'error'); + } else { + showToast(res.error || 'Errore durante l\'annullamento', 'error'); + } + } catch (e) { + showToast('Errore di connessione', 'error'); + } +} + // ===== WEEKLY MEAL PLAN ===== /** @@ -8133,6 +8342,23 @@ const MEAL_TYPES = [ { id: 'succo', icon: '🧃', label: 'Succo di Frutta', from: -1, to: -1 }, ]; +const MEAL_SUB_TYPES = { + dolce: [ + { id: 'torta', icon: '🎂', label: 'Torta' }, + { id: 'crema', icon: '🍮', label: 'Crema / Budino' }, + { id: 'crumble', icon: '🥧', label: 'Crumble / Crostata' }, + { id: 'biscotti', icon: '🍪', label: 'Biscotti / Pasticcini' }, + { id: 'frutta', icon: '🍓', label: 'Dolce alla Frutta' }, + ], + succo: [ + { id: 'dolce', icon: '🍑', label: 'Dolce / Fruttato' }, + { id: 'energizzante', icon: '⚡', label: 'Energizzante' }, + { id: 'detox', icon: '🥬', label: 'Detox / Verde' }, + { id: 'rinfrescante', icon: '🧊', label: 'Rinfrescante' }, + { id: 'vitaminico', icon: '🍊', label: 'Vitaminico / Agrumi' }, + ] +}; + function getMealType() { const hour = new Date().getHours(); for (const m of MEAL_TYPES) { @@ -8326,11 +8552,11 @@ let _recipeUseContext = null; // { idx, productId, btn, qtyNumber } let _recipeUseConfMode = null; let _recipeUseNormalUnit = 'pz'; -async function useRecipeIngredient(idx, productId, location, qtyNumber, btn) { +async function useRecipeIngredient(idx, productId, location, qtyNumber, btn, recipeQty) { if (btn.disabled) return; if (!qtyNumber || qtyNumber <= 0) qtyNumber = 1; - _recipeUseContext = { idx, productId, btn, qtyNumber }; + _recipeUseContext = { idx, productId, btn, qtyNumber, recipeQty }; _recipeUseConfMode = null; // Fetch inventory to build the modal @@ -8410,20 +8636,40 @@ async function useRecipeIngredient(idx, productId, location, qtyNumber, btn) {
`; } - // Available info + // Scale live UI: show only when scale is connected and unit is g or ml const availInfo = items.map(i => { const loc = LOCATIONS[i.location] || { icon: '📦', label: i.location }; return `${loc.icon} ${formatQuantity(i.quantity, i.unit, i.default_quantity, i.package_unit)}`; }).join(' · '); - + + const showScaleLive = _scaleConnected && (unit === 'g' || unit === 'ml' || + (_recipeUseConfMode && ((_recipeUseConfMode.packageUnit || '').toLowerCase() === 'g' || (_recipeUseConfMode.packageUnit || '').toLowerCase() === 'ml'))); + const scaleLiveSection = showScaleLive ? ` +
+
+ ⚖️ + — — + +
+
+
+
+ +
Attendi 10s di stabilità per la compilazione automatica…
+
` : ''; + document.getElementById('modal-content').innerHTML = `
-

${escapeHtml(items[0].name)}

+

${escapeHtml(items[0].name)}

+ ${recipeQty ? `

📋 Ricetta: ${escapeHtml(recipeQty)}

` : ''}

📦 ${availInfo}

+ ${scaleLiveSection}
${locButtons}
@@ -8676,7 +8922,7 @@ function renderRecipe(r) { if (alreadyUsed) { html += ``; } else { - html += ``; + html += ``; } html += ``; } else { @@ -9245,6 +9491,27 @@ function updateRecipeMealTitle() { const meal = getSelectedMealType(); document.getElementById('recipe-meal-title').textContent = MEAL_LABELS[meal] || '🍳 Ricetta'; _renderMealPlanHint(meal); + _renderMealSubTypes(meal); +} + +function _renderMealSubTypes(mealId) { + const container = document.getElementById('recipe-subtype-group'); + if (!container) return; + const subs = MEAL_SUB_TYPES[mealId]; + if (!subs) { + container.style.display = 'none'; + container.innerHTML = ''; + return; + } + container.style.display = ''; + container.innerHTML = subs.map((s, i) => + `` + ).join(''); +} + +function getSelectedSubType() { + const checked = document.querySelector('input[name="recipe-subtype"]:checked'); + return checked ? checked.value : ''; } /** Show/hide the meal-plan badge hint + top banner in the recipe dialog. */ @@ -9354,6 +9621,7 @@ async function generateRecipe() { const result = await api('generate_recipe', {}, 'POST', { meal, persons, + sub_type: MEAL_SUB_TYPES[meal] ? getSelectedSubType() : '', options, appliances: settings.appliances || [], dietary_restrictions: settings.dietary_restrictions || '', @@ -10505,7 +10773,7 @@ async function _backgroundBringSync() { if (!bringMatch) { // Not on Bring — add if critical AND not recently purchased - if (si.urgency === 'critical' && !_isBringPurchased(si.name)) { + if (si.urgency === 'critical' && !_isBringPurchased(si.name, 'critical')) { toAdd.push({ name: si.name, specification: expectedSpec }); } } else { diff --git a/evershelf-kiosk/app/build.gradle.kts b/evershelf-kiosk/app/build.gradle.kts index cc555ad..7cfa508 100644 --- a/evershelf-kiosk/app/build.gradle.kts +++ b/evershelf-kiosk/app/build.gradle.kts @@ -11,11 +11,25 @@ android { applicationId = "it.dadaloop.evershelf.kiosk" minSdk = 24 targetSdk = 34 - versionCode = 3 - versionName = "1.2.0" + versionCode = 4 + versionName = "1.3.0" + } + + signingConfigs { + // Use the standard Android debug keystore so every machine produces + // APKs with the same debug signature — required for over-the-air updates. + getByName("debug") { + storeFile = file("${System.getProperty("user.home")}/.android/debug.keystore") + storePassword = "android" + keyAlias = "androiddebugkey" + keyPassword = "android" + } } buildTypes { + debug { + signingConfig = signingConfigs.getByName("debug") + } release { isMinifyEnabled = false proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt")) diff --git a/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/KioskActivity.kt b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/KioskActivity.kt index d153605..720317c 100644 --- a/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/KioskActivity.kt +++ b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/KioskActivity.kt @@ -124,10 +124,7 @@ class KioskActivity : AppCompatActivity() { scaleStatusText = findViewById(R.id.scaleStatusText) scaleStatusDetail = findViewById(R.id.scaleStatusDetail) - // Triple-tap on wizard title to exit kiosk - findViewById(R.id.wizardTitle).setOnClickListener { - handleTripleTap() - } + // Triple-tap on wizard title is disabled — exit only via the X button in the overlay // Step 1 findViewById(R.id.btnGetStarted).setOnClickListener { @@ -163,9 +160,9 @@ class KioskActivity : AppCompatActivity() { finishWizard() } - // Settings — triple-tap to exit + // Settings gear — short press opens settings, no kiosk exit via tap btnSettings.setOnClickListener { - handleTripleTap() + startActivity(Intent(this, SettingsActivity::class.java)) } btnSettings.setOnLongClickListener { startActivity(Intent(this, SettingsActivity::class.java)) @@ -456,7 +453,8 @@ class KioskActivity : AppCompatActivity() { override fun onPageFinished(view: WebView?, url: String?) { super.onPageFinished(view, url) - // Kiosk overlay removed — exit is handled via the Android settings gear button + // Inject X (exit) and ↻ (refresh) buttons into the page header + injectKioskOverlay() // Check for updates periodically checkForUpdates() } @@ -537,23 +535,21 @@ class KioskActivity : AppCompatActivity() { // ── Inject kiosk buttons in header (left of title) ────────────────── private fun injectKioskOverlay() { + // Use a position:fixed overlay so injection never depends on SPA DOM readiness. val js = """ (function() { - if (document.getElementById('_kiosk_exit_btn')) return; - var content = document.querySelector('.header-content'); - var title = document.querySelector('.header-title'); - if (!content || !title) return; + if (document.getElementById('_kiosk_overlay')) return; var wrap = document.createElement('div'); - wrap.id = '_kiosk_controls'; - wrap.style.cssText = 'display:flex;align-items:center;gap:6px;margin-right:8px;flex-shrink:0;'; + wrap.id = '_kiosk_overlay'; + wrap.style.cssText = 'position:fixed;top:8px;right:8px;z-index:2147483647;display:flex;gap:6px;align-items:center;pointer-events:auto;'; // Exit button var exitBtn = document.createElement('button'); exitBtn.id = '_kiosk_exit_btn'; exitBtn.textContent = '\u2715'; exitBtn.title = 'Esci dal kiosk'; - exitBtn.style.cssText = 'background:rgba(0,0,0,0.25);border:1.5px solid rgba(255,255,255,0.4);color:#fff;width:30px;height:30px;border-radius:50%;font-size:14px;cursor:pointer;display:flex;align-items:center;justify-content:center;-webkit-tap-highlight-color:transparent;'; + exitBtn.style.cssText = 'background:rgba(0,0,0,0.45);border:1.5px solid rgba(255,255,255,0.5);color:#fff;width:34px;height:34px;border-radius:50%;font-size:15px;cursor:pointer;display:flex;align-items:center;justify-content:center;-webkit-tap-highlight-color:transparent;touch-action:manipulation;'; exitBtn.addEventListener('click', function(e) { e.stopPropagation(); if (confirm('Uscire dalla modalit\u00e0 kiosk?')) { @@ -566,7 +562,7 @@ class KioskActivity : AppCompatActivity() { refBtn.id = '_kiosk_refresh_btn'; refBtn.textContent = '\u21bb'; refBtn.title = 'Aggiorna pagina'; - refBtn.style.cssText = 'background:rgba(0,0,0,0.25);border:1.5px solid rgba(255,255,255,0.4);color:#fff;width:30px;height:30px;border-radius:50%;font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;-webkit-tap-highlight-color:transparent;'; + refBtn.style.cssText = 'background:rgba(0,0,0,0.45);border:1.5px solid rgba(255,255,255,0.5);color:#fff;width:34px;height:34px;border-radius:50%;font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center;-webkit-tap-highlight-color:transparent;touch-action:manipulation;'; refBtn.addEventListener('click', function(e) { e.stopPropagation(); if (typeof _kioskBridge !== 'undefined') _kioskBridge.hardReload(); @@ -575,7 +571,7 @@ class KioskActivity : AppCompatActivity() { wrap.appendChild(exitBtn); wrap.appendChild(refBtn); - content.insertBefore(wrap, title); + document.documentElement.appendChild(wrap); })(); """.trimIndent() webView.evaluateJavascript(js, null) @@ -644,9 +640,9 @@ class KioskActivity : AppCompatActivity() { var banner = document.createElement('div'); banner.id = '_kiosk_update_banner'; banner.style.cssText = 'position:fixed;bottom:0;left:0;right:0;background:#1e293b;color:#fbbf24;padding:10px 16px;font-size:13px;z-index:999998;display:flex;align-items:center;justify-content:space-between;border-top:2px solid #fbbf24;'; - banner.innerHTML = '⬆️ ${message.replace("\n", "
")}
'; + banner.innerHTML = '⬆️ ${message.replace("\n", "
")} — Per installare: disinstalla prima la versione attuale, poi installa la nuova.
'; document.body.appendChild(banner); - setTimeout(function(){ var b = document.getElementById('_kiosk_update_banner'); if(b) b.remove(); }, 3000); + setTimeout(function(){ var b = document.getElementById('_kiosk_update_banner'); if(b) b.remove(); }, 12000); })(); """.trimIndent() webView.evaluateJavascript(js, null) diff --git a/index.html b/index.html index d720d24..aae6762 100644 --- a/index.html +++ b/index.html @@ -20,7 +20,7 @@
-

🏠 EverShelfv1.4.0

+

🏠 EverShelfv1.5.0

+
@@ -637,6 +640,11 @@ 🛒 Aggiungi selezionati a Bring!
+
+ +
@@ -670,10 +678,10 @@ - +