Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 51f55071fa | |||
| 3a4e843334 | |||
| 7104483dac | |||
| 94e98bc79f | |||
| fd039d743e | |||
| b1bcf9e714 | |||
| 98c38f017e | |||
| 7947f47e6d | |||
| 758eb93e20 | |||
| ff1175451a | |||
| 42630c3e3e | |||
| 637eaa20d6 | |||
| 5e307f79b8 | |||
| a6478b20e1 | |||
| 223457bbdf | |||
| 12c6a8977a | |||
| c7a69d8379 | |||
| c7f3c95d75 | |||
| a6f90a07e5 | |||
| 2d07001c5b | |||
| faa55eda93 | |||
| 0b902d7c19 | |||
| d80199e4f1 | |||
| 1637cc1020 | |||
| 904a398009 | |||
| bc39361246 | |||
| 7f173770fc | |||
| b83db76a8d | |||
| cfd089a0a3 | |||
| ade121f43f | |||
| 2f665f777b | |||
| f46b12e3ad | |||
| a932d3de11 | |||
| 6120fad40b | |||
| 8ac6fec5a2 | |||
| fe7587e9e4 | |||
| 4f68925a7c | |||
| f4ea9e74e6 | |||
| 8f217fd166 | |||
| b985247b95 | |||
| efbed479df | |||
| 695c23fc21 | |||
| 7a34406b07 | |||
| 50660f634f | |||
| fb06b42107 | |||
| c16067d9e5 | |||
| 605d8590f6 | |||
| 149cff3ca5 | |||
| ec7d172ed9 | |||
| 0479e34c7f | |||
| 730efe4d87 | |||
| be3dceeebb | |||
| 875250626d | |||
| 245d007e29 | |||
| 63a9f70f86 | |||
| 1a6e0c87ce | |||
| 73f43cb296 | |||
| baed815a48 | |||
| 8aa934f5ca | |||
| 83b5eb3063 | |||
| 59c6f9d76c | |||
| bac9485e4e | |||
| 11178af001 | |||
| 4e4a736dba | |||
| 52afdd6bfa |
@@ -1,5 +1,8 @@
|
|||||||
name: Build & Release Kiosk APK
|
name: Build & Release Kiosk APK
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint-php:
|
lint-php:
|
||||||
name: PHP Syntax Check
|
name: PHP Syntax Check
|
||||||
@@ -203,7 +206,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Create release
|
- name: Create release
|
||||||
if: steps.tag_check.outputs.exists == 'false'
|
if: steps.tag_check.outputs.exists == 'false'
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v3
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ steps.version.outputs.version }}
|
tag_name: ${{ steps.version.outputs.version }}
|
||||||
name: "EverShelf ${{ steps.version.outputs.version }}"
|
name: "EverShelf ${{ steps.version.outputs.version }}"
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
name: Security Scan (Trivy)
|
name: Security Scan (Trivy)
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main, develop]
|
branches: [main, develop]
|
||||||
|
|||||||
@@ -11,6 +11,84 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
- **Recipe scraps tips** — During cooking steps, detect "waste" generated (peels, cores, bones, eggshells, coffee grounds, citrus zest, etc.) and surface AI-powered tips on how to reuse them (compost, natural cleaner, broth, candied peel, etc.). Could be shown as an optional collapsible hint card below the step that generates the scrap.
|
- **Recipe scraps tips** — During cooking steps, detect "waste" generated (peels, cores, bones, eggshells, coffee grounds, citrus zest, etc.) and surface AI-powered tips on how to reuse them (compost, natural cleaner, broth, candied peel, etc.). Could be shown as an optional collapsible hint card below the step that generates the scrap.
|
||||||
|
|
||||||
|
## [1.7.35] - 2026-06-02
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Barcode scanner accepts invalid codes** — Manual barcode input with an incorrect EAN checksum now blocks the lookup and shows an error (previously showed a warning but proceeded anyway). The native `BarcodeDetector` path now also validates EAN-8/EAN-13/UPC checksum before confirming a scan, consistent with the Quagga fallback which already did this check.
|
||||||
|
- **Recipe persons +/− buttons stopped working in the generation dialog** — A duplicate `adjustRecipePersons` function added for the post-generation rescaler was overriding the one that updated the persons input in the recipe setup dialog. The rescaler is now named `scaleRecipePersons` to avoid the conflict.
|
||||||
|
|
||||||
|
## [1.7.34] - 2026-05-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **AI visual barcode fallback** — When the barcode scanner fails to read a barcode within 5 seconds, EverShelf can now automatically capture a camera frame and send it to Gemini Vision to visually identify the product (name, brand, category). On success the product is saved and the inventory form opens just as if a barcode had been scanned. A new toggle in **Settings → Camera** (`AI visual identification (5s fallback)`) lets users enable or disable this feature at any time. Requires Gemini API key configured. Disabled by default.
|
||||||
|
|
||||||
|
## [1.7.33] - 2026-05-29
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **HA sensor `shopping_total` always null** — `haInventorySensor` was reading `shopping_total_cache.json` with a 1-hour TTL (cache populated only by the JS frontend, so it was often empty). Extended TTL to 24 hours and added an inline fallback: when the cache is absent or stale, the sensor now computes the total directly from `shopping_price_cache.json` without any AI calls. Queries `shopping_list` joined to `products` for the canonical `shopping_name`, then looks up both v3 and legacy v0 cache key formats to maximise hit rate. Works in both internal and Bring shopping modes.
|
||||||
|
- **HA `ha_refresh_prices` using non-existent columns** — `haInventorySensor` and `haRefreshPrices` were querying `quantity`, `unit`, `checked` from `shopping_list` — columns that do not exist in that table (schema: `id, name, raw_name, specification, added_at, sort_order`). Changed to `SELECT name` with `shopping_name` join and default `qty=1 / unit=pz`.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.7.32] - 2026-05-29
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Smarter expiry u2192 shopping list logic** — The "expiring soon" threshold is now 7 days (was 3), giving enough time to plan the next shopping trip. Items expiring soon are only flagged for restocking when the user is a **regular buyer** (`isRegular`) and either stock is low (<50%) or the consumption rate predicts the item will expire before being used. Non-regular products keep the old 3-day safety-net. Expired items are now only added to the shopping list when `isRegular || buyCount >= 2` — products that expired unused without ever being a staple no longer pollute the list; the expiry banner handles them.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.7.31] - 2026-05-29
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **New pack merges into opened pack on add** — `addToInventory` was looking for ANY existing row for the same product+location and adding the new quantity to it. This caused a newly purchased sealed pack to be silently merged with an already-opened pack, collapsing two physically distinct containers into one row and corrupting the `opened_at` timestamp. The fix now searches only for a **sealed** (unopened) row (`opened_at IS NULL`) to merge into. If only opened rows exist, a new sealed row is created instead — keeping the two packs separate and allowing the anomaly model and shelf-life tracker to work correctly.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.7.30] - 2026-05-29
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **False consumption anomaly with multi-row stock** — The anomaly detection banner was evaluating each inventory row in isolation. Products split across multiple rows (e.g. one opened pack with 1 pz + one sealed pack with 6 pz) incorrectly triggered a "consumed faster than expected" warning because only the opened row (1 pz) was compared against the model. The check now aggregates the total quantity across all rows for the same product before deciding to flag an anomaly. If the combined total ≥ expected remaining, the anomaly is suppressed.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.7.29] - 2026-05-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Buy-cycle consumption prediction** — Products that are never tracked per-use (salt, spices, cleaning supplies, etc.) now use the average time between restocks as a proxy for consumption rate. When a product has ≥ 3 purchase events and no individual `out` events, EverShelf calculates the average buy cycle (`(lastBuy - firstBuy) / (buyCount - 1)`) and estimates how many days of stock remain in the current cycle. The product appears in the smart shopping list with a reason like "Finisce tra ~12gg (ciclo medio 75gg)" before it runs out, rather than only after. These products are now also treated as `isRegular` so all stock-level urgency checks apply correctly.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.7.28] - 2026-05-30
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Duplicate auto-reported issues** — The GitHub issue reporter was relying solely on the GitHub Search API for deduplication. Because search indexing has a several-minutes lag, rapid error recurrences each created a new issue before the previous one was indexed, producing ~50 duplicate issues. The reporter now uses a local file cache (`data/reported_issue_fps.json`, with `/tmp/` fallback when `data/` is not writable) as the primary deduplication store. A 30-minute per-fingerprint comment throttle is also applied to prevent flooding an existing issue. GitHub Search is used only on first run or after a cache miss. Closes [#134](https://github.com/dadaloop82/EverShelf/issues/134) (and all duplicates #135–#183).
|
||||||
|
|
||||||
|
## [1.7.27] - 2026-05-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **HA sensor enrichment** — All HA sensor attributes that list products now include full product details: `location`, `brand`, `category`, `days_remaining`, `opened_at`, `vacuum_sealed`, `default_quantity`, `package_unit`, `product_id`, `inventory_id`. Applies to `expiring_list`, the new `expired_list`, and the new `low_stock_list`.
|
||||||
|
- **HA `expired_list` attribute** — `sensor.evershelf_overview` now exposes `expired_list` (full details for all expired items, not just a count).
|
||||||
|
- **HA `low_stock_list` attribute** — New attribute listing all items with quantity ≤ 1 with full product info.
|
||||||
|
- **HA `sensor=product` endpoint** — New `GET /api/?action=ha_sensor&sensor=product` returns the full inventory with all product details. Optional filters: `&id=N`, `&name=...`, `&location=...`.
|
||||||
|
- **Inventory edit safety guard** — Confirm dialog when saving a quantity that is unusually large for its unit (e.g. 183 conf), preventing accidental data loss from unit-confusion typos.
|
||||||
|
- **Bread shelf-life in fridge** — Opened shelf-life rules added for piadina/crescia (2 days), packaged sliced bread/bauletto (4 days), and generic bread (3 days).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Recipe AI ingredient substitution** — Added explicit rule to both recipe prompts preventing Gemini from substituting ingredient forms (e.g. fresh tomatoes ↔ passata, fresh milk ↔ UHT ↔ cream, flour 00 ↔ wholemeal).
|
||||||
|
- **HA cron webhook payload** — Expiry alert webhook items now include full product details (brand, category, location, days_remaining, opened_at, vacuum_sealed) instead of only name/qty/unit/expiry_date.
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
- `docs/wiki/Home-Assistant.md` — Documented new `sensor=product` endpoint, full product schema table, enriched webhook payload example, and Lovelace/automation template examples using `location` and `days_remaining`.
|
||||||
|
|
||||||
|
## [1.7.26] - 2026-05-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Monthly stats panel** — Third rotating card in the insight banner (anti-waste → nutrition → monthly stats, 1 minute each). Shows products consumed this month with a trend vs. the previous calendar month (↑/↓/→ with % delta), animated horizontal category bars, and badges for items added, wasted, and top-used product. Falls back gracefully when the current month has no transactions. Closes [#100](https://github.com/dadaloop82/EverShelf/issues/100).
|
||||||
|
- **Extended smart-shopping horizon for staples** — Items consumed ≥ 4 times/month now get a 28-day look-ahead window; ≥ 2 times/month get 21 days. Frequently used staples no longer disappear from the smart list between restocks. Closes [#98](https://github.com/dadaloop82/EverShelf/issues/98).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **TTS test interactive confirmation** — Test timeout raised from 4 s to 10 s; instead of an error, the UI shows a YES/NO prompt ("Did you hear it?") so users can confirm or report failure explicitly.
|
||||||
|
- **`end()` PHP 8 reference error** — `_offFetchProduct()` passed the result of `??` directly to `end()`, which requires a variable. Fixed with a temporary variable.
|
||||||
|
- **Database migration crash on fresh installs** — `migrateDB()` tried to rename the `transactions` table before it existed. A `sqlite_master` guard now calls `initializeDB()` and returns early when the schema is absent. Closes [#131](https://github.com/dadaloop82/EverShelf/issues/131), [#133](https://github.com/dadaloop82/EverShelf/issues/133).
|
||||||
|
- **Health-check crash on empty database** — `db_row_count` query was executed even when the `inventory` table was missing, causing a fatal PDO error. The query is now skipped until the schema is fully initialised. Closes [#132](https://github.com/dadaloop82/EverShelf/issues/132).
|
||||||
|
- **Insight banner stuck on one panel** — Rotation interval was 1 hour (effectively invisible); now 60 seconds. `_applyInsightPhase` also now skips empty panels instead of always falling back to the anti-waste card, so the rotation works correctly even when a panel has no data.
|
||||||
|
- **Untranslated OpenFoodFacts category labels** — Categories stored as OFF slugs (`en:plant-based-foods-and-beverages`, `en:dairies`, …) were shown raw. A new `_normalizeCat()` PHP function maps ~60 OFF slugs to Italian app categories; counts are re-aggregated after normalisation so `en:dairies` + `en:milk` both contribute to `latticini`.
|
||||||
|
|
||||||
## [1.7.25] - 2026-05-25
|
## [1.7.25] - 2026-05-25
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
[](https://www.sqlite.org/)
|
[](https://www.sqlite.org/)
|
||||||
[](Dockerfile)
|
[](Dockerfile)
|
||||||
[](translations/)
|
[](translations/)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](https://github.com/dadaloop82/EverShelf/stargazers)
|
[](https://github.com/dadaloop82/EverShelf/stargazers)
|
||||||
[](https://github.com/dadaloop82/EverShelf/commits/main)
|
[](https://github.com/dadaloop82/EverShelf/commits/main)
|
||||||
[](https://github.com/dadaloop82/EverShelf/graphs/contributors)
|
[](https://github.com/dadaloop82/EverShelf/graphs/contributors)
|
||||||
|
|||||||
@@ -142,7 +142,9 @@ if (env('HA_ENABLED', 'false') === 'true' && env('HA_WEBHOOK_ID', '') !== '') {
|
|||||||
if (!file_exists($haFlagFile)) {
|
if (!file_exists($haFlagFile)) {
|
||||||
$expiryDays = max(1, (int)env('HA_EXPIRY_DAYS', '3'));
|
$expiryDays = max(1, (int)env('HA_EXPIRY_DAYS', '3'));
|
||||||
$expiringItems = $db->query(
|
$expiringItems = $db->query(
|
||||||
"SELECT p.name, i.quantity, i.unit, i.expiry_date, i.location
|
"SELECT p.id AS product_id, i.id AS inventory_id,
|
||||||
|
p.name, p.brand, p.category, p.unit, p.default_quantity, p.package_unit,
|
||||||
|
i.quantity, i.location, i.expiry_date, i.opened_at, i.vacuum_sealed
|
||||||
FROM inventory i JOIN products p ON i.product_id = p.id
|
FROM inventory i JOIN products p ON i.product_id = p.id
|
||||||
WHERE i.quantity > 0 AND i.expiry_date IS NOT NULL
|
WHERE i.quantity > 0 AND i.expiry_date IS NOT NULL
|
||||||
AND i.expiry_date BETWEEN date('now') AND date('now', '+{$expiryDays} days')
|
AND i.expiry_date BETWEEN date('now') AND date('now', '+{$expiryDays} days')
|
||||||
@@ -150,13 +152,44 @@ if (env('HA_ENABLED', 'false') === 'true' && env('HA_WEBHOOK_ID', '') !== '') {
|
|||||||
)->fetchAll(PDO::FETCH_ASSOC);
|
)->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
$expiredItems = $db->query(
|
$expiredItems = $db->query(
|
||||||
"SELECT p.name, i.quantity, i.unit, i.expiry_date, i.location
|
"SELECT p.id AS product_id, i.id AS inventory_id,
|
||||||
|
p.name, p.brand, p.category, p.unit, p.default_quantity, p.package_unit,
|
||||||
|
i.quantity, i.location, i.expiry_date, i.opened_at, i.vacuum_sealed
|
||||||
FROM inventory i JOIN products p ON i.product_id = p.id
|
FROM inventory i JOIN products p ON i.product_id = p.id
|
||||||
WHERE i.quantity > 0 AND i.expiry_date IS NOT NULL
|
WHERE i.quantity > 0 AND i.expiry_date IS NOT NULL
|
||||||
AND i.expiry_date < date('now')
|
AND i.expiry_date < date('now')
|
||||||
ORDER BY i.expiry_date ASC LIMIT 10"
|
ORDER BY i.expiry_date ASC LIMIT 10"
|
||||||
)->fetchAll(PDO::FETCH_ASSOC);
|
)->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// Normalise rows to full product format
|
||||||
|
if (!function_exists('_haFormatProduct')) {
|
||||||
|
function _haFormatProduct(array $row): array {
|
||||||
|
$daysRemaining = null;
|
||||||
|
if (!empty($row['expiry_date'])) {
|
||||||
|
$diff = (new DateTime(date('Y-m-d')))->diff(new DateTime($row['expiry_date']));
|
||||||
|
$daysRemaining = (int)$diff->format('%r%a');
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'product_id' => (int)($row['product_id'] ?? 0),
|
||||||
|
'inventory_id' => (int)($row['inventory_id'] ?? 0),
|
||||||
|
'name' => $row['name'],
|
||||||
|
'brand' => $row['brand'] ?? null,
|
||||||
|
'category' => $row['category'] ?? null,
|
||||||
|
'quantity' => (float)($row['quantity'] ?? 0),
|
||||||
|
'unit' => $row['unit'] ?? '',
|
||||||
|
'default_quantity' => (float)($row['default_quantity'] ?? 0),
|
||||||
|
'package_unit' => $row['package_unit'] ?? null,
|
||||||
|
'location' => $row['location'] ?? null,
|
||||||
|
'expiry_date' => $row['expiry_date'] ?? null,
|
||||||
|
'days_remaining' => $daysRemaining,
|
||||||
|
'opened_at' => $row['opened_at'] ?? null,
|
||||||
|
'vacuum_sealed' => !empty($row['vacuum_sealed']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$expiringItems = array_map('_haFormatProduct', $expiringItems);
|
||||||
|
$expiredItems = array_map('_haFormatProduct', $expiredItems);
|
||||||
|
|
||||||
if (!empty($expiringItems)) {
|
if (!empty($expiringItems)) {
|
||||||
$names = implode(', ', array_column($expiringItems, 'name'));
|
$names = implode(', ', array_column($expiringItems, 'name'));
|
||||||
_fireHaWebhook('expiry_alert', [
|
_fireHaWebhook('expiry_alert', [
|
||||||
|
|||||||
@@ -126,6 +126,16 @@ function initializeDB(PDO $db): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function migrateDB(PDO $db): void {
|
function migrateDB(PDO $db): void {
|
||||||
|
// Guard: if core tables don't exist yet (e.g. DB file present but empty / partial init),
|
||||||
|
// run initializeDB first so all tables are created, then return — no ALTER TABLE needed.
|
||||||
|
$productsExists = $db->query(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='products'"
|
||||||
|
)->fetchColumn();
|
||||||
|
if (!$productsExists) {
|
||||||
|
initializeDB($db);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Add package_unit column if missing
|
// Add package_unit column if missing
|
||||||
$cols = $db->query("PRAGMA table_info(products)")->fetchAll();
|
$cols = $db->query("PRAGMA table_info(products)")->fetchAll();
|
||||||
$colNames = array_column($cols, 'name');
|
$colNames = array_column($cols, 'name');
|
||||||
@@ -267,6 +277,20 @@ function migrateDB(PDO $db): void {
|
|||||||
");
|
");
|
||||||
$db->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_shopping_list_name ON shopping_list(lower(name))");
|
$db->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_shopping_list_name ON shopping_list(lower(name))");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add is_favorite column to recipes if missing (#124)
|
||||||
|
$recCols = array_column($db->query("PRAGMA table_info(recipes)")->fetchAll(), 'name');
|
||||||
|
if (!in_array('is_favorite', $recCols)) {
|
||||||
|
try { $db->exec("ALTER TABLE recipes ADD COLUMN is_favorite INTEGER NOT NULL DEFAULT 0"); }
|
||||||
|
catch (PDOException $e) { if (strpos($e->getMessage(), 'duplicate column') === false) throw $e; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add nutriments_json column to products if missing (#118)
|
||||||
|
$prodCols2 = array_column($db->query("PRAGMA table_info(products)")->fetchAll(), 'name');
|
||||||
|
if (!in_array('nutriments_json', $prodCols2)) {
|
||||||
|
try { $db->exec("ALTER TABLE products ADD COLUMN nutriments_json TEXT DEFAULT NULL"); }
|
||||||
|
catch (PDOException $e) { if (strpos($e->getMessage(), 'duplicate column') === false) throw $e; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -440,6 +464,14 @@ function estimateOpenedExpiryDaysPHP(string $name, string $category, string $loc
|
|||||||
if (preg_match('/\b(patata|patate|tubero)\b/', $n)) return 4;
|
if (preg_match('/\b(patata|patate|tubero)\b/', $n)) return 4;
|
||||||
if (preg_match('/\baglio\b/', $n)) return 14;
|
if (preg_match('/\baglio\b/', $n)) return 14;
|
||||||
|
|
||||||
|
// ── F.extra: Bread in fridge (opened) ──────────────────────────────────
|
||||||
|
// Thin flatbreads (piadina, crescia, tigella) get mold very quickly
|
||||||
|
if (preg_match('/\b(piadina|piadelle?|crescia|tigella)\b/', $n)) return 2;
|
||||||
|
// Packaged sliced bread — preservatives help a bit
|
||||||
|
if (preg_match('/\b(bauletto|pancarrè|pan\s+carr|tramezzin)\b/', $n)) return 4;
|
||||||
|
// Generic bread / sandwich bread in fridge
|
||||||
|
if (preg_match('/\bpane\b/', $cat)) return 3;
|
||||||
|
|
||||||
// ── G: Fridge condiments — medium shelf-life ─────────────────────────
|
// ── G: Fridge condiments — medium shelf-life ─────────────────────────
|
||||||
if (preg_match('/maionese|mayo|mayon/', $n)) return 90;
|
if (preg_match('/maionese|mayo|mayon/', $n)) return 90;
|
||||||
if (preg_match('/\bketchup\b/', $n)) return 90;
|
if (preg_match('/\bketchup\b/', $n)) return 90;
|
||||||
|
|||||||
+821
-76
File diff suppressed because it is too large
Load Diff
@@ -1969,6 +1969,46 @@ body.server-offline .bottom-nav {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* — Scan status bar — */
|
||||||
|
.scan-status-bar {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 38px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 12;
|
||||||
|
}
|
||||||
|
.scan-status-method {
|
||||||
|
font-size: 0.58rem;
|
||||||
|
color: rgba(255,255,255,0.45);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
.scan-status-msg {
|
||||||
|
font-size: 0.74rem;
|
||||||
|
color: rgba(255,255,255,0.9);
|
||||||
|
background: rgba(0,0,0,0.55);
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 92%;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
transition: color 0.2s, background 0.2s;
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
|
}
|
||||||
|
.scan-status-msg:empty { visibility: hidden; }
|
||||||
|
.scan-status-msg.state-partial { color: #fbbf24; }
|
||||||
|
.scan-status-msg.state-invalid { color: #f87171; background: rgba(239,68,68,0.28); }
|
||||||
|
.scan-status-msg.state-confirmed { color: #4ade80; background: rgba(74,222,128,0.22); }
|
||||||
|
.scan-status-msg.state-retry { color: #fb923c; }
|
||||||
|
|
||||||
/* — Viewport overlay controls (torch / zoom / flip) — */
|
/* — Viewport overlay controls (torch / zoom / flip) — */
|
||||||
.scan-viewport-controls {
|
.scan-viewport-controls {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -4240,6 +4280,7 @@ body.server-offline .bottom-nav {
|
|||||||
.recipe-result .recipe-meta {
|
.recipe-result .recipe-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
@@ -4276,6 +4317,35 @@ body.server-offline .bottom-nav {
|
|||||||
color: #3730a3;
|
color: #3730a3;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
/* Appliance/mode badge shown inline next to a step text */
|
||||||
|
.recipe-step-appliance {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 6px;
|
||||||
|
background: #f0fdf4;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: #15803d;
|
||||||
|
vertical-align: middle;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Regen choice panel */
|
||||||
|
.recipe-regen-choice {
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.recipe-regen-choice-title {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
/* Recipe ingredient use buttons */
|
/* Recipe ingredient use buttons */
|
||||||
.recipe-ingredients {
|
.recipe-ingredients {
|
||||||
@@ -6506,6 +6576,117 @@ body.cooking-mode-active .app-header {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== RECIPE FAVORITES (#124) ===== */
|
||||||
|
.recipe-fav-badge {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #f59e0b;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-archive-card-fav {
|
||||||
|
border-left: 3px solid #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-recipe-fav {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0 4px;
|
||||||
|
line-height: 1;
|
||||||
|
vertical-align: middle;
|
||||||
|
transition: color 0.2s, transform 0.15s;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-recipe-fav:hover { color: #f59e0b; transform: scale(1.2); }
|
||||||
|
.btn-recipe-fav.active { color: #f59e0b; }
|
||||||
|
|
||||||
|
/* ===== PORTION RESCALER (#123) ===== */
|
||||||
|
.recipe-persons-ctrl {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 0 6px;
|
||||||
|
vertical-align: middle;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-persons-adj {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-persons-adj:hover { background: var(--accent, #6366f1); color: #fff; }
|
||||||
|
|
||||||
|
#recipe-persons-display {
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== MACRONUTRIENT PANEL (#118) ===== */
|
||||||
|
.macro-bars {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 12px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
min-width: 70px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-bar-wrap {
|
||||||
|
flex: 1;
|
||||||
|
height: 8px;
|
||||||
|
background: var(--bg-secondary, #1e2a3a);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
width: 0%;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.6s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-val {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-align: right;
|
||||||
|
min-width: 80px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-val small {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== SCREENSAVER ===== */
|
/* ===== SCREENSAVER ===== */
|
||||||
.screensaver-overlay {
|
.screensaver-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -6895,6 +7076,82 @@ body.cooking-mode-active .app-header {
|
|||||||
}
|
}
|
||||||
.nutr-score-val { flex: 0 0 32px; text-align: right; font-weight: 600; }
|
.nutr-score-val { flex: 0 0 32px; text-align: right; font-weight: 600; }
|
||||||
|
|
||||||
|
/* ===== MONTHLY STATS PANEL ===== */
|
||||||
|
.ms-main-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
margin: 12px 0 8px;
|
||||||
|
}
|
||||||
|
.ms-main-num {
|
||||||
|
font-size: 2.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #6366f1;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.ms-main-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.ms-main-label {
|
||||||
|
font-size: .85rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
.ms-trend {
|
||||||
|
font-size: .8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.ms-cats-section {
|
||||||
|
margin: 6px 0 4px;
|
||||||
|
}
|
||||||
|
.ms-cats-title {
|
||||||
|
font-size: .68rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
margin-bottom: 7px;
|
||||||
|
}
|
||||||
|
.ms-cat-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.ms-cat-name {
|
||||||
|
font-size: .74rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
min-width: 78px;
|
||||||
|
max-width: 78px;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.ms-cat-bar-wrap {
|
||||||
|
flex: 1;
|
||||||
|
height: 8px;
|
||||||
|
background: #1e293b;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.ms-cat-bar {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
.ms-cat-cnt {
|
||||||
|
font-size: .7rem;
|
||||||
|
color: #64748b;
|
||||||
|
min-width: 22px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.ms-badges-row {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== SETUP WIZARD ===== */
|
/* ===== SETUP WIZARD ===== */
|
||||||
.setup-wizard-content {
|
.setup-wizard-content {
|
||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
@@ -7653,6 +7910,9 @@ body.cooking-mode-active .app-header {
|
|||||||
[data-theme="dark"] .recipe-expiry-note { background: #2a1e00; color: #fde68a; }
|
[data-theme="dark"] .recipe-expiry-note { background: #2a1e00; color: #fde68a; }
|
||||||
[data-theme="dark"] .recipe-tools-banner { background: #1a1040; border-color: #3730a3; color: #c4b5fd; }
|
[data-theme="dark"] .recipe-tools-banner { background: #1a1040; border-color: #3730a3; color: #c4b5fd; }
|
||||||
[data-theme="dark"] .recipe-tool-chip { background: #2e1a4a; color: #c4b5fd; }
|
[data-theme="dark"] .recipe-tool-chip { background: #2e1a4a; color: #c4b5fd; }
|
||||||
|
[data-theme="dark"] .recipe-step-appliance { background: #052e16; border-color: #166534; color: #4ade80; }
|
||||||
|
[data-theme="dark"] .recipe-regen-choice { background: #1e293b; border-color: #334155; }
|
||||||
|
[data-theme="dark"] .recipe-regen-choice-title { color: #94a3b8; }
|
||||||
[data-theme="dark"] .recipe-subtype-chip { background: #1c1300; border-color: #78350f; color: var(--text); }
|
[data-theme="dark"] .recipe-subtype-chip { background: #1c1300; border-color: #78350f; color: var(--text); }
|
||||||
[data-theme="dark"] .recipe-subtype-chip:has(input:checked) { background: #2a1e00; border-color: #d97706; }
|
[data-theme="dark"] .recipe-subtype-chip:has(input:checked) { background: #2a1e00; border-color: #d97706; }
|
||||||
|
|
||||||
|
|||||||
+1103
-134
File diff suppressed because it is too large
Load Diff
@@ -35,10 +35,14 @@ Add EverShelf pantry data as native HA sensor entities that update automatically
|
|||||||
|
|
||||||
| URL | Returns | Sensor |
|
| URL | Returns | Sensor |
|
||||||
|-----|---------|--------|
|
|-----|---------|--------|
|
||||||
| `/api/?action=ha_sensor` | Items expiring soon (≤3 days) | `sensor.evershelf_overview` |
|
| `/api/?action=ha_sensor` | Items expiring soon (≤`HA_EXPIRY_DAYS` days) | `sensor.evershelf_overview` |
|
||||||
| `/api/?action=ha_sensor&sensor=expired` | Expired items count | `sensor.evershelf_expired` |
|
| `/api/?action=ha_sensor&sensor=expired` | Expired items count | `sensor.evershelf_expired` |
|
||||||
| `/api/?action=ha_sensor&sensor=shopping` | Shopping list item count | `sensor.evershelf_shopping` |
|
| `/api/?action=ha_sensor&sensor=shopping` | Shopping list item count | `sensor.evershelf_shopping` |
|
||||||
| `/api/?action=ha_sensor&sensor=total` | Total pantry items | `sensor.evershelf_total` |
|
| `/api/?action=ha_sensor&sensor=total` | Total pantry items | `sensor.evershelf_total` |
|
||||||
|
| `/api/?action=ha_sensor&sensor=product` | Full inventory — all items with complete details | `sensor.evershelf_products` |
|
||||||
|
| `/api/?action=ha_sensor&sensor=product&id=42` | Full details for inventory row `id=42` | — |
|
||||||
|
| `/api/?action=ha_sensor&sensor=product&name=milk` | Full details for items whose name contains "milk" | — |
|
||||||
|
| `/api/?action=ha_sensor&sensor=product&location=frigo` | All items in a specific location | — |
|
||||||
|
|
||||||
### Generate & Copy YAML
|
### Generate & Copy YAML
|
||||||
|
|
||||||
@@ -61,7 +65,12 @@ sensor:
|
|||||||
- expired_items
|
- expired_items
|
||||||
- total_items
|
- total_items
|
||||||
- shopping_items
|
- shopping_items
|
||||||
- expiring_list
|
- expiring_list # full product details for expiring items
|
||||||
|
- expired_list # full product details for expired items
|
||||||
|
- low_stock_list # full product details for items with quantity ≤ 1
|
||||||
|
- next_expiry_name
|
||||||
|
- next_expiry_date
|
||||||
|
- days_to_next_expiry
|
||||||
- last_updated
|
- last_updated
|
||||||
unit_of_measurement: "items"
|
unit_of_measurement: "items"
|
||||||
|
|
||||||
@@ -72,10 +81,62 @@ sensor:
|
|||||||
scan_interval: 180
|
scan_interval: 180
|
||||||
value_template: "{{ value_json.state }}"
|
value_template: "{{ value_json.state }}"
|
||||||
unit_of_measurement: "items"
|
unit_of_measurement: "items"
|
||||||
|
|
||||||
|
# Full product inventory — each item includes all details (location, brand, category, …)
|
||||||
|
- platform: rest
|
||||||
|
name: "EverShelf Products"
|
||||||
|
unique_id: evershelf_products
|
||||||
|
resource: "http://YOUR_EVERSHELF_URL/api/?action=ha_sensor&sensor=product"
|
||||||
|
scan_interval: 600
|
||||||
|
value_template: "{{ value_json.state }}"
|
||||||
|
json_attributes:
|
||||||
|
- items
|
||||||
|
- last_updated
|
||||||
|
unit_of_measurement: "items"
|
||||||
```
|
```
|
||||||
|
|
||||||
Restart Home Assistant after editing `configuration.yaml`.
|
Restart Home Assistant after editing `configuration.yaml`.
|
||||||
|
|
||||||
|
Every product entry inside `expiring_list`, `expired_list`, `low_stock_list`, and `sensor=product` responses follows the same schema:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"product_id": 42,
|
||||||
|
"inventory_id": 7,
|
||||||
|
"name": "Latte intero",
|
||||||
|
"brand": "Parmalat",
|
||||||
|
"category": "Lattiero-caseari",
|
||||||
|
"quantity": 2.0,
|
||||||
|
"unit": "conf",
|
||||||
|
"default_quantity": 1000.0,
|
||||||
|
"package_unit": "ml",
|
||||||
|
"location": "frigo",
|
||||||
|
"expiry_date": "2025-06-15",
|
||||||
|
"days_remaining": 3,
|
||||||
|
"opened_at": "2025-06-10",
|
||||||
|
"vacuum_sealed": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Field details:
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `product_id` | int | Products table ID |
|
||||||
|
| `inventory_id` | int | Inventory row ID |
|
||||||
|
| `name` | string | Product name |
|
||||||
|
| `brand` | string\|null | Brand (if set) |
|
||||||
|
| `category` | string\|null | Category (if set) |
|
||||||
|
| `quantity` | float | Current quantity in inventory |
|
||||||
|
| `unit` | string | Unit (`conf`, `g`, `ml`, `pz`, …) |
|
||||||
|
| `default_quantity` | float | Default package size (e.g. 1000 for 1-litre carton) |
|
||||||
|
| `package_unit` | string\|null | Unit of the default package (`g`, `ml`) |
|
||||||
|
| `location` | string\|null | Storage location (`frigo`, `freezer`, `dispensa`, …) |
|
||||||
|
| `expiry_date` | string\|null | ISO date `YYYY-MM-DD` |
|
||||||
|
| `days_remaining` | int\|null | Days until expiry (negative = already expired) |
|
||||||
|
| `opened_at` | string\|null | ISO date when the package was opened |
|
||||||
|
| `vacuum_sealed` | bool | Whether the item is vacuum-sealed |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Webhook Automations
|
## Webhook Automations
|
||||||
@@ -109,9 +170,24 @@ EverShelf fires an HTTP POST to your HA webhook URL when pantry events occur.
|
|||||||
"type": "expiring_soon",
|
"type": "expiring_soon",
|
||||||
"count": 3,
|
"count": 3,
|
||||||
"days": 3,
|
"days": 3,
|
||||||
"summary": "3 products expiring within 3 days",
|
"summary": "Milk, Yogurt, Butter",
|
||||||
"items": [
|
"items": [
|
||||||
{ "name": "Milk", "expiry_date": "2025-06-14", "quantity": 1, "unit": "l" }
|
{
|
||||||
|
"product_id": 42,
|
||||||
|
"inventory_id": 7,
|
||||||
|
"name": "Milk",
|
||||||
|
"brand": "Parmalat",
|
||||||
|
"category": "Dairy",
|
||||||
|
"quantity": 2.0,
|
||||||
|
"unit": "conf",
|
||||||
|
"default_quantity": 1000.0,
|
||||||
|
"package_unit": "ml",
|
||||||
|
"location": "frigo",
|
||||||
|
"expiry_date": "2025-06-14",
|
||||||
|
"days_remaining": 2,
|
||||||
|
"opened_at": "2025-06-10",
|
||||||
|
"vacuum_sealed": false
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,12 +204,25 @@ action:
|
|||||||
- service: notify.telegram_bot
|
- service: notify.telegram_bot
|
||||||
data:
|
data:
|
||||||
message: >
|
message: >
|
||||||
🥫 EverShelf: {{ trigger.json.data.summary }}
|
🥫 EverShelf: {{ trigger.json.data.count }} product(s) expiring soon
|
||||||
{% for item in trigger.json.data.items %}
|
{% for item in trigger.json.data.items %}
|
||||||
— {{ item.name }} (expires {{ item.expiry_date }})
|
— {{ item.name }}{% if item.brand %} ({{ item.brand }}){% endif %} ·
|
||||||
|
{{ item.quantity }} {{ item.unit }} · 📍 {{ item.location }} ·
|
||||||
|
expires {{ item.expiry_date }} ({{ item.days_remaining }} days)
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Example: Automation on location
|
||||||
|
|
||||||
|
You can filter by location in the automation template to only alert for fridge items:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
condition:
|
||||||
|
- condition: template
|
||||||
|
value_template: >
|
||||||
|
{{ trigger.json.data.items | selectattr('location','eq','frigo') | list | length > 0 }}
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Push Notifications
|
## Push Notifications
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ import android.os.Bundle
|
|||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
|
import android.media.AudioManager
|
||||||
import android.speech.tts.TextToSpeech
|
import android.speech.tts.TextToSpeech
|
||||||
|
import android.speech.tts.UtteranceProgressListener
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.WindowInsets
|
import android.view.WindowInsets
|
||||||
import android.view.WindowInsetsController
|
import android.view.WindowInsetsController
|
||||||
@@ -144,6 +146,25 @@ class KioskActivity : AppCompatActivity() {
|
|||||||
if (res == TextToSpeech.LANG_MISSING_DATA || res == TextToSpeech.LANG_NOT_SUPPORTED) {
|
if (res == TextToSpeech.LANG_MISSING_DATA || res == TextToSpeech.LANG_NOT_SUPPORTED) {
|
||||||
tts?.language = Locale.getDefault()
|
tts?.language = Locale.getDefault()
|
||||||
}
|
}
|
||||||
|
tts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
|
||||||
|
override fun onStart(utteranceId: String?) {}
|
||||||
|
override fun onDone(utteranceId: String?) {
|
||||||
|
runOnUiThread {
|
||||||
|
webView.evaluateJavascript("if(window._kioskTtsDone)window._kioskTtsDone('$utteranceId')", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Deprecated("Deprecated in API 21")
|
||||||
|
override fun onError(utteranceId: String?) {
|
||||||
|
runOnUiThread {
|
||||||
|
webView.evaluateJavascript("if(window._kioskTtsError)window._kioskTtsError('$utteranceId','error')", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun onError(utteranceId: String?, errorCode: Int) {
|
||||||
|
runOnUiThread {
|
||||||
|
webView.evaluateJavascript("if(window._kioskTtsError)window._kioskTtsError('$utteranceId',$errorCode)", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
ttsReady = true
|
ttsReady = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -466,7 +487,10 @@ class KioskActivity : AppCompatActivity() {
|
|||||||
if (!ttsReady) return
|
if (!ttsReady) return
|
||||||
engine.setSpeechRate(rate.coerceIn(0.1f, 4f))
|
engine.setSpeechRate(rate.coerceIn(0.1f, 4f))
|
||||||
engine.setPitch(pitch.coerceIn(0.1f, 4f))
|
engine.setPitch(pitch.coerceIn(0.1f, 4f))
|
||||||
engine.speak(text, android.speech.tts.TextToSpeech.QUEUE_FLUSH, null, "kiosk_tts")
|
val params = Bundle().apply {
|
||||||
|
putInt(TextToSpeech.Engine.KEY_PARAM_STREAM, AudioManager.STREAM_MUSIC)
|
||||||
|
}
|
||||||
|
engine.speak(text, TextToSpeech.QUEUE_FLUSH, params, "kiosk_tts")
|
||||||
}
|
}
|
||||||
@JavascriptInterface
|
@JavascriptInterface
|
||||||
fun stopSpeech() { tts?.stop() }
|
fun stopSpeech() { tts?.stop() }
|
||||||
|
|||||||
@@ -123,6 +123,9 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
// Back
|
// Back
|
||||||
findViewById<android.widget.ImageButton>(R.id.btnBack).setOnClickListener { finish() }
|
findViewById<android.widget.ImageButton>(R.id.btnBack).setOnClickListener { finish() }
|
||||||
|
|
||||||
|
// Advanced settings → back to webapp (where HA, Gemini, Bring! etc. are configured)
|
||||||
|
findViewById<MaterialButton>(R.id.btnOpenAppSettings).setOnClickListener { finish() }
|
||||||
|
|
||||||
// Test connection
|
// Test connection
|
||||||
findViewById<MaterialButton>(R.id.btnTestConnection).setOnClickListener { testConnection() }
|
findViewById<MaterialButton>(R.id.btnTestConnection).setOnClickListener { testConnection() }
|
||||||
|
|
||||||
|
|||||||
@@ -400,6 +400,7 @@ class SetupActivity : AppCompatActivity() {
|
|||||||
scaleTestCard.visibility = View.GONE
|
scaleTestCard.visibility = View.GONE
|
||||||
testWeightBox.visibility = View.GONE
|
testWeightBox.visibility = View.GONE
|
||||||
bleSetupCard.visibility = View.VISIBLE
|
bleSetupCard.visibility = View.VISIBLE
|
||||||
|
step3NextButtons.visibility = View.VISIBLE // restore nav buttons (back/next)
|
||||||
tvSelectedScale.text = ""
|
tvSelectedScale.text = ""
|
||||||
tvSelectedScale.visibility = View.GONE
|
tvSelectedScale.visibility = View.GONE
|
||||||
tvScanStatus.text = getString(R.string.ble_not_confirmed)
|
tvScanStatus.text = getString(R.string.ble_not_confirmed)
|
||||||
@@ -960,6 +961,8 @@ class SetupActivity : AppCompatActivity() {
|
|||||||
testWeightBox.visibility = View.GONE
|
testWeightBox.visibility = View.GONE
|
||||||
testHasWeight = false
|
testHasWeight = false
|
||||||
findViewById<MaterialButton>(R.id.btnTestConfirm).isEnabled = false
|
findViewById<MaterialButton>(R.id.btnTestConfirm).isEnabled = false
|
||||||
|
// Always re-enable retry so the user is never stuck
|
||||||
|
findViewById<MaterialButton>(R.id.btnTestRetry).isEnabled = true
|
||||||
}
|
}
|
||||||
override fun onWeightReceived(reading: WeightReading) {
|
override fun onWeightReceived(reading: WeightReading) {
|
||||||
if (!isInTestMode) return
|
if (!isInTestMode) return
|
||||||
|
|||||||
@@ -224,6 +224,43 @@
|
|||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Advanced / App Settings link -->
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="IMPOSTAZIONI AVANZATE"
|
||||||
|
android:textColor="#7c3aed"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:letterSpacing="0.1"
|
||||||
|
android:layout_marginBottom="12dp" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:background="@drawable/card_background"
|
||||||
|
android:padding="16dp"
|
||||||
|
android:layout_marginBottom="24dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Home Assistant, Gemini AI, Bring!, TTS, notifiche e tutte le altre funzionalità si configurano direttamente nell'app EverShelf."
|
||||||
|
android:textColor="#94a3b8"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:layout_marginBottom="12dp" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnOpenAppSettings"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="44dp"
|
||||||
|
android:text="← Torna all'app per le impostazioni avanzate"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:textAllCaps="false"
|
||||||
|
android:backgroundTint="#7c3aed" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- Danger Zone -->
|
<!-- Danger Zone -->
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
|||||||
+51
-16
@@ -64,7 +64,7 @@
|
|||||||
<div id="preloader-warnings" class="preloader-warnings" style="display:none"></div>
|
<div id="preloader-warnings" class="preloader-warnings" style="display:none"></div>
|
||||||
<div id="preloader-error-msg" class="preloader-error-msg" style="display:none"></div>
|
<div id="preloader-error-msg" class="preloader-error-msg" style="display:none"></div>
|
||||||
<button id="preloader-retry-btn" class="preloader-retry-btn" style="display:none" onclick="_startupRetry()">🔄 <span data-i18n="startup.retry">Riprova</span></button>
|
<button id="preloader-retry-btn" class="preloader-retry-btn" style="display:none" onclick="_startupRetry()">🔄 <span data-i18n="startup.retry">Riprova</span></button>
|
||||||
<span class="app-preloader-version" id="preloader-version">v1.7.25</span>
|
<span class="app-preloader-version" id="preloader-version">v1.7.35</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
<!-- Title — left-aligned; grows to fill space -->
|
<!-- Title — left-aligned; grows to fill space -->
|
||||||
<div class="header-title-wrap">
|
<div class="header-title-wrap">
|
||||||
<h1 class="header-title" onclick="showPage('dashboard')">
|
<h1 class="header-title" onclick="showPage('dashboard')">
|
||||||
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.25</span>
|
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.35</span>
|
||||||
</h1>
|
</h1>
|
||||||
<!-- Update badge — shown alongside title, never replaces it -->
|
<!-- Update badge — shown alongside title, never replaces it -->
|
||||||
<span class="header-update-badge" id="header-update-badge" style="display:none"></span>
|
<span class="header-update-badge" id="header-update-badge" style="display:none"></span>
|
||||||
@@ -169,10 +169,12 @@
|
|||||||
<div id="expired-list"></div>
|
<div id="expired-list"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Anti-Waste Report Card + Nutrition Analysis (alternating, content rendered by JS) -->
|
<!-- Anti-Waste Report Card + Nutrition Analysis + Monthly Stats (alternating, content rendered by JS) -->
|
||||||
<div id="dashboard-insight-wrap" style="position:relative">
|
<div id="dashboard-insight-wrap" style="position:relative">
|
||||||
<div id="waste-chart-section" style="display:none"></div>
|
<div id="waste-chart-section" style="display:none"></div>
|
||||||
<div id="nutrition-section" style="display:none"></div>
|
<div id="nutrition-section" style="display:none"></div>
|
||||||
|
<div id="monthly-stats-section" style="display:none"></div>
|
||||||
|
<div id="macros-section" style="display:none"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Alert for soonest expiring items -->
|
<!-- Alert for soonest expiring items -->
|
||||||
@@ -192,7 +194,7 @@
|
|||||||
<!-- ===== INVENTORY LIST ===== -->
|
<!-- ===== INVENTORY LIST ===== -->
|
||||||
<section class="page" id="page-inventory">
|
<section class="page" id="page-inventory">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
|
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||||
<h2 id="inventory-title" data-i18n="inventory.title">Dispensa</h2>
|
<h2 id="inventory-title" data-i18n="inventory.title">Dispensa</h2>
|
||||||
<button class="page-header-action-btn" onclick="_showExportModal()" title="Export" data-i18n-title="export.btn_title">📤</button>
|
<button class="page-header-action-btn" onclick="_showExportModal()" title="Export" data-i18n-title="export.btn_title">📤</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -223,7 +225,7 @@
|
|||||||
<!-- ===== SCAN PAGE ===== -->
|
<!-- ===== SCAN PAGE ===== -->
|
||||||
<section class="page" id="page-scan">
|
<section class="page" id="page-scan">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<button class="back-btn" onclick="stopScanner(); showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
|
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||||
<h2 data-i18n="scan.title">Scansiona</h2>
|
<h2 data-i18n="scan.title">Scansiona</h2>
|
||||||
<button class="scan-spesa-chip" id="scan-spesa-btn" onclick="startSpesaMode()" data-i18n="scan.spesa_btn">🛒 Spesa</button>
|
<button class="scan-spesa-chip" id="scan-spesa-btn" onclick="startSpesaMode()" data-i18n="scan.spesa_btn">🛒 Spesa</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -249,6 +251,19 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- Live partial code preview -->
|
<!-- Live partial code preview -->
|
||||||
<div class="scan-live-code" id="scan-live-code" style="display:none"></div>
|
<div class="scan-live-code" id="scan-live-code" style="display:none"></div>
|
||||||
|
<!-- Scan status bar -->
|
||||||
|
<div class="scan-status-bar" id="scan-status-bar">
|
||||||
|
<span id="scan-status-method" class="scan-status-method"></span>
|
||||||
|
<span id="scan-status-msg" class="scan-status-msg" data-i18n="scan.status_ready"></span>
|
||||||
|
</div>
|
||||||
|
<!-- AI processing overlay (shown when Gemini Vision is analyzing) -->
|
||||||
|
<div class="scan-ai-overlay" id="scan-ai-overlay" style="display:none">
|
||||||
|
<div class="scan-ai-overlay-inner">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<span class="scan-ai-overlay-label">Gemini Vision</span>
|
||||||
|
<span class="scan-ai-overlay-msg" id="scan-ai-overlay-msg"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<!-- Success flash overlay -->
|
<!-- Success flash overlay -->
|
||||||
<div class="scan-confirm-overlay" id="scan-confirm-overlay" style="display:none">
|
<div class="scan-confirm-overlay" id="scan-confirm-overlay" style="display:none">
|
||||||
<div class="scan-confirm-check">✓</div>
|
<div class="scan-confirm-check">✓</div>
|
||||||
@@ -267,6 +282,9 @@
|
|||||||
<!-- Scan errors -->
|
<!-- Scan errors -->
|
||||||
<div class="scan-result" id="scan-result" style="display:none"></div>
|
<div class="scan-result" id="scan-result" style="display:none"></div>
|
||||||
|
|
||||||
|
<!-- AI retry button (shown after visual identification fails) -->
|
||||||
|
<button class="btn btn-accent scan-ai-retry-btn" id="scan-ai-retry-btn" style="display:none" onclick="_retryAiScan()" data-i18n="scan.ai_retry_btn">🤖 Riprova con AI</button>
|
||||||
|
|
||||||
<!-- Recent scans -->
|
<!-- Recent scans -->
|
||||||
<div class="scan-recents" id="scan-recents" style="display:none">
|
<div class="scan-recents" id="scan-recents" style="display:none">
|
||||||
<span class="scan-recents-label" data-i18n="scan.recents_label">Recenti</span>
|
<span class="scan-recents-label" data-i18n="scan.recents_label">Recenti</span>
|
||||||
@@ -326,7 +344,7 @@
|
|||||||
<!-- ===== PRODUCT ACTION (IN/OUT after scan) ===== -->
|
<!-- ===== PRODUCT ACTION (IN/OUT after scan) ===== -->
|
||||||
<section class="page" id="page-action">
|
<section class="page" id="page-action">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<button class="back-btn" id="action-back-btn" onclick="showPage('scan')" data-i18n="btn.back">← Indietro</button>
|
<button class="back-btn" id="action-back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||||
<h2 data-i18n="action.title">Cosa vuoi fare?</h2>
|
<h2 data-i18n="action.title">Cosa vuoi fare?</h2>
|
||||||
</div>
|
</div>
|
||||||
<!-- Banner: shopping list scan context -->
|
<!-- Banner: shopping list scan context -->
|
||||||
@@ -349,7 +367,7 @@
|
|||||||
<!-- ===== ADD TO INVENTORY FORM ===== -->
|
<!-- ===== ADD TO INVENTORY FORM ===== -->
|
||||||
<section class="page" id="page-add">
|
<section class="page" id="page-add">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<button class="back-btn" onclick="showPage('action')" data-i18n="btn.back">← Indietro</button>
|
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||||
<h2 data-i18n="add.title">Aggiungi alla Dispensa</h2>
|
<h2 data-i18n="add.title">Aggiungi alla Dispensa</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="product-preview-small" id="add-product-preview"></div>
|
<div class="product-preview-small" id="add-product-preview"></div>
|
||||||
@@ -412,7 +430,7 @@
|
|||||||
<!-- ===== USE FROM INVENTORY FORM ===== -->
|
<!-- ===== USE FROM INVENTORY FORM ===== -->
|
||||||
<section class="page" id="page-use">
|
<section class="page" id="page-use">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<button class="back-btn" onclick="showPage('action')" data-i18n="btn.back">← Indietro</button>
|
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||||
<h2 data-i18n="use.title">Usa / Consuma</h2>
|
<h2 data-i18n="use.title">Usa / Consuma</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="product-preview-small" id="use-product-preview"></div>
|
<div class="product-preview-small" id="use-product-preview"></div>
|
||||||
@@ -468,7 +486,7 @@
|
|||||||
<!-- ===== MANUAL / EDIT PRODUCT FORM ===== -->
|
<!-- ===== MANUAL / EDIT PRODUCT FORM ===== -->
|
||||||
<section class="page" id="page-product-form">
|
<section class="page" id="page-product-form">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<button class="back-btn" onclick="showPage('scan')" data-i18n="btn.back">← Indietro</button>
|
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||||
<h2 id="product-form-title">Nuovo Prodotto</h2>
|
<h2 id="product-form-title">Nuovo Prodotto</h2>
|
||||||
</div>
|
</div>
|
||||||
<form class="form" onsubmit="submitProduct(event)">
|
<form class="form" onsubmit="submitProduct(event)">
|
||||||
@@ -656,7 +674,7 @@
|
|||||||
<!-- ===== ALL PRODUCTS PAGE ===== -->
|
<!-- ===== ALL PRODUCTS PAGE ===== -->
|
||||||
<section class="page" id="page-products">
|
<section class="page" id="page-products">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
|
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||||
<h2 data-i18n="products.title">📦 Tutti i Prodotti</h2>
|
<h2 data-i18n="products.title">📦 Tutti i Prodotti</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="search-bar">
|
<div class="search-bar">
|
||||||
@@ -668,7 +686,7 @@
|
|||||||
<!-- ===== RECIPE PAGE ===== -->
|
<!-- ===== RECIPE PAGE ===== -->
|
||||||
<section class="page" id="page-recipe">
|
<section class="page" id="page-recipe">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
|
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||||
<h2 data-i18n="recipes.title">🍳 Ricette</h2>
|
<h2 data-i18n="recipes.title">🍳 Ricette</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="recipe-page-container">
|
<div class="recipe-page-container">
|
||||||
@@ -682,7 +700,7 @@
|
|||||||
<!-- ===== SHOPPING LIST (BRING!) PAGE ===== -->
|
<!-- ===== SHOPPING LIST (BRING!) PAGE ===== -->
|
||||||
<section class="page" id="page-shopping">
|
<section class="page" id="page-shopping">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
|
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||||
<h2 data-i18n="shopping.title">🛒 Lista della Spesa</h2>
|
<h2 data-i18n="shopping.title">🛒 Lista della Spesa</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="shopping-container">
|
<div class="shopping-container">
|
||||||
@@ -790,7 +808,7 @@
|
|||||||
<!-- ===== AI IDENTIFICATION PAGE ===== -->
|
<!-- ===== AI IDENTIFICATION PAGE ===== -->
|
||||||
<section class="page" id="page-ai">
|
<section class="page" id="page-ai">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<button class="back-btn" onclick="stopScanner(); showPage('scan')" data-i18n="btn.back">← Indietro</button>
|
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||||
<h2 data-i18n="ai.title">🤖 Identificazione AI</h2>
|
<h2 data-i18n="ai.title">🤖 Identificazione AI</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="ai-container">
|
<div class="ai-container">
|
||||||
@@ -828,7 +846,7 @@
|
|||||||
<!-- ===== SETTINGS PAGE ===== -->
|
<!-- ===== SETTINGS PAGE ===== -->
|
||||||
<section class="page" id="page-settings">
|
<section class="page" id="page-settings">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
|
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||||
<h2 data-i18n="settings.title">⚙️ Configurazione</h2>
|
<h2 data-i18n="settings.title">⚙️ Configurazione</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-tabs">
|
<div class="settings-tabs">
|
||||||
@@ -1176,6 +1194,16 @@
|
|||||||
<p class="settings-hint mt-2" data-i18n="settings.camera.devices_hint">Se hai più fotocamere, puoi selezionarne una specifica dall'elenco sopra dopo aver concesso i permessi.</p>
|
<p class="settings-hint mt-2" data-i18n="settings.camera.devices_hint">Se hai più fotocamere, puoi selezionarne una specifica dall'elenco sopra dopo aver concesso i permessi.</p>
|
||||||
<button class="btn btn-small btn-secondary mt-2" onclick="loadCameraDevices()" data-i18n="settings.camera.detect_btn">🔄 Rileva fotocamere</button>
|
<button class="btn btn-small btn-secondary mt-2" onclick="loadCameraDevices()" data-i18n="settings.camera.detect_btn">🔄 Rileva fotocamere</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group" style="margin-top:14px">
|
||||||
|
<label class="toggle-row">
|
||||||
|
<span data-i18n="settings.camera.ai_fallback_label">Identificazione visiva AI (fallback 5s)</span>
|
||||||
|
<span class="toggle-switch">
|
||||||
|
<input type="checkbox" id="setting-barcode-ai-fallback">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<p class="settings-hint mt-2" data-i18n="settings.camera.ai_fallback_hint">Se il codice a barre non viene letto entro 5 secondi, un fotogramma viene inviato automaticamente all'AI per identificare visivamente il prodotto. Richiede Gemini configurato.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Security Tab -->
|
<!-- Security Tab -->
|
||||||
@@ -1314,11 +1342,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div><!-- /tts-server-section -->
|
</div><!-- /tts-server-section -->
|
||||||
|
|
||||||
|
<button class="btn btn-large btn-secondary full-width mt-2" onclick="testSound()" data-i18n="settings.tts.test_sound_btn">🔔 Esegui Test Suono</button>
|
||||||
<button class="btn btn-large btn-accent full-width mt-2" onclick="testTTS()" data-i18n="settings.tts.test_btn">🔊 Invia Test Vocale</button>
|
<button class="btn btn-large btn-accent full-width mt-2" onclick="testTTS()" data-i18n="settings.tts.test_btn">🔊 Invia Test Vocale</button>
|
||||||
<div id="tts-test-status" style="display:none;margin-top:8px"></div>
|
<div id="tts-test-status" style="display:none;margin-top:8px"></div>
|
||||||
<!-- HA TTS quick-fill hint -->
|
<!-- HA TTS quick-fill hint -->
|
||||||
<div style="margin-top:12px;padding:10px 12px;background:rgba(3,169,244,0.07);border:1px solid rgba(3,169,244,0.25);border-radius:8px;font-size:0.82rem">
|
<div style="margin-top:12px;padding:10px 12px;background:rgba(3,169,244,0.07);border:1px solid rgba(3,169,244,0.25);border-radius:8px;font-size:0.82rem">
|
||||||
<span data-i18n="settings.tts.ha_hint">🏠 Se usi Home Assistant, usa il tab <strong>Home Assistant</strong> per configurare TTS, webhook e sensori.</span>
|
<span data-i18n="settings.ha.ha_hint">🏠 Se usi Home Assistant, usa il tab <strong>Home Assistant</strong> per configurare TTS, webhook e sensori.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1816,9 +1845,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="recipe-result" style="display:none" class="recipe-result">
|
<div id="recipe-result" style="display:none" class="recipe-result">
|
||||||
<div id="recipe-content"></div>
|
<div id="recipe-content"></div>
|
||||||
<button class="btn btn-large btn-secondary full-width mt-2" onclick="regenerateRecipe()" data-i18n="recipes.regenerate">
|
<button id="recipe-regen-btn" class="btn btn-large btn-secondary full-width mt-2" onclick="showRegenChoice()" data-i18n="recipes.regenerate">
|
||||||
🔄 Generane un'altra
|
🔄 Generane un'altra
|
||||||
</button>
|
</button>
|
||||||
|
<div id="recipe-regen-choice" style="display:none" class="recipe-regen-choice">
|
||||||
|
<p class="recipe-regen-choice-title" data-i18n="recipes.regen_choice_title">Cosa vuoi fare con questa ricetta?</p>
|
||||||
|
<button class="btn btn-large btn-warning full-width" onclick="doRegenerateReplace()" data-i18n="recipes.regen_replace">🔄 Genera un'altra (scarta questa)</button>
|
||||||
|
<button class="btn btn-large btn-success full-width mt-2" onclick="doRegenerateSave()" data-i18n="recipes.regen_save_new">💾 Salva nell'archivio e genera nuova</button>
|
||||||
|
<button class="btn btn-large btn-ghost full-width mt-2" onclick="cancelRegenChoice()" data-i18n="action.cancel">Annulla</button>
|
||||||
|
</div>
|
||||||
<button class="btn btn-large btn-primary full-width mt-2" onclick="closeRecipeDialog()" data-i18n="recipes.close_btn">
|
<button class="btn btn-large btn-primary full-width mt-2" onclick="closeRecipeDialog()" data-i18n="recipes.close_btn">
|
||||||
✅ Chiudi
|
✅ Chiudi
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"name": "EverShelf",
|
"name": "EverShelf",
|
||||||
"short_name": "EverShelf",
|
"short_name": "EverShelf",
|
||||||
"description": "Gestione completa della dispensa di casa con scansione barcode",
|
"description": "Gestione completa della dispensa di casa con scansione barcode",
|
||||||
"version": "1.7.25",
|
"version": "1.7.35",
|
||||||
"start_url": "/evershelf/",
|
"start_url": "/evershelf/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#f0f4e8",
|
"background_color": "#f0f4e8",
|
||||||
|
|||||||
+1475
-1410
File diff suppressed because it is too large
Load Diff
+1475
-1410
File diff suppressed because it is too large
Load Diff
+1418
-1354
File diff suppressed because it is too large
Load Diff
+1418
-1354
File diff suppressed because it is too large
Load Diff
+1474
-1410
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user