Compare commits
132 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 3d27433eb3 | |||
| eddb622c85 | |||
| 95c20adbbd | |||
| 6fa2e4d830 | |||
| 6ff1dfe0cc | |||
| 43e0ac9da3 | |||
| 1ce32cb5f0 | |||
| d75cde7eb6 | |||
| 43fe1c7bb5 | |||
| b2c87ae343 | |||
| fbdae35516 | |||
| d9ebc51e71 | |||
| 56ca58bc18 | |||
| b2e0f6d683 | |||
| ddb9bd9f75 | |||
| 965a672abe | |||
| 7249daa8eb | |||
| ec53f7529c | |||
| 1074dff87d | |||
| 3989d11094 | |||
| b010ced1a6 | |||
| cc0fa09219 | |||
| c0a076749e | |||
| 6a41b53174 | |||
| 1d04236bc0 | |||
| 561c6e9809 | |||
| 6857c20893 | |||
| 964de98203 | |||
| e28a6e4e39 | |||
| fd9e2471e0 | |||
| 3c8a9693b2 | |||
| b38bdc45f5 | |||
| 83a0df272a | |||
| 6320b575e0 | |||
| 8ccd218c5a | |||
| 5c1afaaaf5 | |||
| 6245b15420 | |||
| 02f673a164 | |||
| 61bb1b5552 | |||
| cbf4bd54da | |||
| 1cdbdb3b25 | |||
| 837d62c335 | |||
| fa36ba83bf | |||
| 1efeaf9236 | |||
| 573bcd1102 | |||
| 426cc9df7e | |||
| 6f2d6d9944 | |||
| d3eb82eee2 | |||
| 98426bf861 | |||
| 264b1f648e | |||
| b89df961a6 | |||
| 5e34bc90b3 | |||
| 3b100df26c | |||
| 2ecb3cbac6 | |||
| c2004fd0f8 | |||
| fba0947945 | |||
| 3a1f6cfd1e | |||
| 37fb522e8b | |||
| 66f5a03503 | |||
| a37d97dfcd | |||
| 47197d0d66 | |||
| 149621651d | |||
| b5a6daa557 | |||
| ccc2f8907d | |||
| 9e80915a61 | |||
| 7b60f1dbe3 | |||
| 7019160704 | |||
| ac8b5acc0c | |||
| 34df755ba3 | |||
| 87eac171bf | |||
| ef15f3536c | |||
| f77b3259ad | |||
| 5ad24ed73b | |||
| 84934c1908 | |||
| dd0625b253 | |||
| fa0442e2f6 | |||
| a85414d790 | |||
| c07439fea4 | |||
| 8f6934485a | |||
| d7aadff598 | |||
| d8aff8ac04 | |||
| 7364e75881 | |||
| ff25307662 | |||
| 4515ff7246 |
@@ -89,11 +89,73 @@ PRICE_CURRENCY=EUR
|
|||||||
# PRICE_UPDATE_MONTHS: how many months to cache a price before re-fetching (default 3)
|
# PRICE_UPDATE_MONTHS: how many months to cache a price before re-fetching (default 3)
|
||||||
PRICE_UPDATE_MONTHS=3
|
PRICE_UPDATE_MONTHS=3
|
||||||
|
|
||||||
|
# ── Cleanup / retention ──────────────────────────────────────────────────────
|
||||||
|
# RECIPE_RETENTION_DAYS: delete auto-generated recipe plans older than N days
|
||||||
|
RECIPE_RETENTION_DAYS=7
|
||||||
|
# TRANSACTION_RETENTION_DAYS: keep stock transaction history for N days.
|
||||||
|
# Smart Shopping uses this history to compute purchase frequencies.
|
||||||
|
# WARNING: values below 30 will cause the shopping list to appear nearly empty.
|
||||||
|
# Minimum enforced at runtime: 30 days.
|
||||||
|
TRANSACTION_RETENTION_DAYS=90
|
||||||
|
|
||||||
|
# ── Local Backup ─────────────────────────────────────────────────────────────
|
||||||
|
# BACKUP_ENABLED: run a daily incremental backup via cron (true/false)
|
||||||
|
BACKUP_ENABLED=true
|
||||||
|
# BACKUP_RETENTION_DAYS: keep local backups for N days (minimum 1)
|
||||||
|
BACKUP_RETENTION_DAYS=3
|
||||||
|
|
||||||
|
# ── Google Drive Backup ───────────────────────────────────────────────────────
|
||||||
|
# GDRIVE_ENABLED: upload the daily backup to Google Drive (requires a service account)
|
||||||
|
GDRIVE_ENABLED=false
|
||||||
|
#
|
||||||
|
# Setup steps:
|
||||||
|
# 1. Create a Google Cloud project and enable the Drive API
|
||||||
|
# 2. Create a Service Account and download the JSON key
|
||||||
|
# 3. Create a Drive folder and share it with the service account email
|
||||||
|
# 4. Paste the JSON content below (or set GDRIVE_SERVICE_ACCOUNT_FILE to the path)
|
||||||
|
# 5. Set GDRIVE_FOLDER_ID to the Drive folder ID (from its URL)
|
||||||
|
#
|
||||||
|
# GDRIVE_SERVICE_ACCOUNT_JSON: full JSON content of the service account key
|
||||||
|
GDRIVE_SERVICE_ACCOUNT_JSON=
|
||||||
|
# GDRIVE_SERVICE_ACCOUNT_FILE: alternative — path to the service account JSON file
|
||||||
|
GDRIVE_SERVICE_ACCOUNT_FILE=
|
||||||
|
# GDRIVE_FOLDER_ID: ID of the Drive folder where backups will be stored
|
||||||
|
GDRIVE_FOLDER_ID=
|
||||||
|
# GDRIVE_RETENTION_DAYS: delete Drive backups older than N days (0 = keep all)
|
||||||
|
GDRIVE_RETENTION_DAYS=30
|
||||||
|
|
||||||
# ── Security ─────────────────────────────────────────────────────────────────
|
# ── Security ─────────────────────────────────────────────────────────────────
|
||||||
# SETTINGS_TOKEN: if set, the Settings screen requires this token to save changes.
|
# SETTINGS_TOKEN: if set, the Settings screen requires this token to save changes.
|
||||||
# Leave empty to allow anyone with access to the server to change settings.
|
# Leave empty to allow anyone with access to the server to change settings.
|
||||||
SETTINGS_TOKEN=
|
SETTINGS_TOKEN=
|
||||||
|
|
||||||
|
# INSTANCE_NAME: display name for this EverShelf instance (used by the HA integration
|
||||||
|
# for Zeroconf discovery label and device name in Home Assistant).
|
||||||
|
# Defaults to the server hostname if left empty.
|
||||||
|
INSTANCE_NAME=
|
||||||
|
|
||||||
|
# ── Home Assistant Integration ────────────────────────────────────────────────
|
||||||
|
# All HA settings can also be configured from the Settings → 🏠 tab.
|
||||||
|
#
|
||||||
|
# HA_ENABLED: master switch for all HA features (webhooks, TTS, sensors)
|
||||||
|
HA_ENABLED=false
|
||||||
|
# HA_URL: base URL of your HA instance — no trailing slash
|
||||||
|
# Examples: http://homeassistant.local:8123 or http://192.168.1.50:8123
|
||||||
|
HA_URL=
|
||||||
|
# HA_TOKEN: Long-Lived Access Token (HA Profile → Security → Long-Lived Access Tokens)
|
||||||
|
HA_TOKEN=
|
||||||
|
# HA_TTS_ENTITY: media_player entity for recipe step TTS (e.g. media_player.living_room)
|
||||||
|
HA_TTS_ENTITY=
|
||||||
|
# HA_WEBHOOK_ID: ID of an HA automation's Webhook trigger
|
||||||
|
HA_WEBHOOK_ID=
|
||||||
|
# HA_WEBHOOK_EVENTS: comma-separated events to fire webhooks for
|
||||||
|
# Available: expiry, shopping_add, stock_update, barcode_scan
|
||||||
|
HA_WEBHOOK_EVENTS=expiry,shopping_add,stock_update
|
||||||
|
# HA_NOTIFY_SERVICE: HA notify service for push alerts (e.g. notify.mobile_app_my_phone)
|
||||||
|
HA_NOTIFY_SERVICE=
|
||||||
|
# HA_EXPIRY_DAYS: days before expiry to trigger expiry alert (default 3)
|
||||||
|
HA_EXPIRY_DAYS=3
|
||||||
|
|
||||||
# ── Developer / demo ─────────────────────────────────────────────────────────
|
# ── Developer / demo ─────────────────────────────────────────────────────────
|
||||||
# DEMO_MODE: when true, all write operations are blocked (for public demos)
|
# DEMO_MODE: when true, all write operations are blocked (for public demos)
|
||||||
DEMO_MODE=false
|
DEMO_MODE=false
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -102,7 +105,9 @@ jobs:
|
|||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
# Always use the built-in GITHUB_TOKEN for checkout (read-only fetch).
|
||||||
|
# WORKFLOW_PAT is only needed for the push step below.
|
||||||
|
token: ${{ github.token }}
|
||||||
|
|
||||||
- name: Configure git bot identity
|
- name: Configure git bot identity
|
||||||
run: |
|
run: |
|
||||||
@@ -111,6 +116,15 @@ jobs:
|
|||||||
|
|
||||||
- name: Merge develop → main
|
- name: Merge develop → main
|
||||||
run: |
|
run: |
|
||||||
|
# ── ROOT CAUSE FIX ──────────────────────────────────────────────────
|
||||||
|
# actions/checkout writes an http.extraheader (AUTHORIZATION: basic …)
|
||||||
|
# that silently overrides any credentials embedded in git remote URLs.
|
||||||
|
# We must clear it BEFORE setting the remote URL with WORKFLOW_PAT,
|
||||||
|
# otherwise GITHUB_TOKEN is always used for the push and workflow-file
|
||||||
|
# changes are rejected.
|
||||||
|
# ────────────────────────────────────────────────────────────────────
|
||||||
|
git config --local --unset-all http."https://github.com/".extraheader 2>/dev/null || true
|
||||||
|
|
||||||
LAST=$(git log --oneline -1 origin/develop)
|
LAST=$(git log --oneline -1 origin/develop)
|
||||||
git checkout main
|
git checkout main
|
||||||
git pull --ff-only origin main
|
git pull --ff-only origin main
|
||||||
@@ -118,6 +132,26 @@ jobs:
|
|||||||
-m "chore: auto-merge develop → main
|
-m "chore: auto-merge develop → main
|
||||||
|
|
||||||
Triggered by: $LAST"
|
Triggered by: $LAST"
|
||||||
|
|
||||||
|
# ── PUSH STRATEGY ───────────────────────────────────────────────────
|
||||||
|
# Priority 1: WORKFLOW_PAT (classic PAT, repo+workflow scopes)
|
||||||
|
# → can push workflow file changes; set as a repo secret.
|
||||||
|
# Priority 2: GITHUB_TOKEN fallback
|
||||||
|
# → cannot push workflow files; strip them from the merge commit.
|
||||||
|
# ────────────────────────────────────────────────────────────────────
|
||||||
|
PUSH_TOKEN="${{ secrets.WORKFLOW_PAT }}"
|
||||||
|
if [ -z "$PUSH_TOKEN" ]; then
|
||||||
|
WF=$(git diff --name-only origin/main -- .github/workflows/ 2>/dev/null || echo "")
|
||||||
|
if [ -n "$WF" ]; then
|
||||||
|
echo "::warning::WORKFLOW_PAT not set — stripping workflow changes from merge commit:"
|
||||||
|
echo "$WF"
|
||||||
|
git checkout origin/main -- .github/workflows/
|
||||||
|
git diff --cached --quiet || git commit --amend --no-edit
|
||||||
|
fi
|
||||||
|
PUSH_TOKEN="${{ github.token }}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
git remote set-url origin "https://x-access-token:${PUSH_TOKEN}@github.com/${{ github.repository }}.git"
|
||||||
git push origin main
|
git push origin main
|
||||||
|
|
||||||
# ── Auto-create GitHub Release on main ───────────────────────────────────
|
# ── Auto-create GitHub Release on main ───────────────────────────────────
|
||||||
|
|||||||
@@ -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.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
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Home Assistant integration** — Full bidirectional HA support: inventory sensor (`sensor.evershelf_*`) exposes item counts, expiring items, shopping total, opened items and next-expiry info. Webhooks fire on inventory changes (add/use/shopping). Daily cron alert notifies via HA for items expiring within the configured threshold. TTS announces cooking steps through HA Media Player. New Settings tab 🏠 with connection test, TTS preset (Piper, Google, Nabu Casa), webhook config, and YAML snippet for `configuration.yaml`. Resolves [#111](https://github.com/dadaloop82/EverShelf/issues/111).
|
||||||
|
- **Offline mode** — Full offline-first support. Full-screen overlay on network loss; "Continue offline" button after 3 s, auto-enter after 8 s. Inventory and settings are synced to `localStorage` at startup and cached on every successful API call. Writes (add/use/update/delete) are queued and synced on reconnect with optimistic UI updates. Pending operations survive page refresh and are re-synced automatically at next startup. AI/network-dependent sections (anti-waste chart, nutrition analysis, recipe generator, price fetching, Gemini chat) are hidden in offline mode. `remoteLog` and `reportError` are buffered offline and flushed on restore. Broken external images replaced with a grey placeholder.
|
||||||
|
- **Offline-computed dashboard** — While offline, `inventory_summary` and `stats` (expiring/expired/opened) are derived client-side from the local cache so all dashboard stat cards and expiry alerts show accurate data.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Offline banner flood** — Opened items in the offline `stats` response lacked `is_edible`; `!undefined` evaluated to `true`, causing every opened item to be shown as "not edible" in the dashboard banner. Field is now set to `true` (client-side shelf-life check already handles genuinely expired items).
|
||||||
|
- **Version update badge showing older versions** — `_checkWebappUpdate` used `latestTag !== _loadedVersion` (inequality only), so running a newer dev build triggered an "update available" badge for an older GitHub release. Now uses `_semverGt(latest, current)` so only genuinely newer releases trigger the badge.
|
||||||
|
- **Bring! items re-appearing after manual purchase removal** — `removeBringItem` and `confirmShoppingItemFound` now call `_markBringPurchased` immediately, and `autoAddCriticalItems` respects the blocklist for depleted items.
|
||||||
|
- **Barcode lookup false "not found"** — New `_offFetchProduct()` tries three barcode candidates (given, UPC-A↔EAN-13 conversion) across two Open Food Facts locales with auto-retry.
|
||||||
|
- **Partial throw from expired-items banner** — "Butta" now opens the throw modal (qty + location) instead of silently deleting the entire inventory row.
|
||||||
|
- **Related stock display when scanning branded products** — When scanning a product, the action page now shows a green card listing any inventory items from the same generic family already at home.
|
||||||
|
|
||||||
|
## [1.7.24] - 2026-05-21
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Dark mode resets to Auto on every reload** — `dark_mode` was never saved to `.env` (missing from `saveSettings` and `getServerSettings`). It is now fully server-side like all other settings; `localStorage` retains only a pre-render hint for the flash-prevention IIFE.
|
||||||
|
- **Cooking timer — no sound or speech on Android kiosk** — Three independent root causes fixed: (1) `AudioContext` was created fresh outside a user gesture, starting in `suspended` state and failing silently; a shared pre-unlocked context (`_sharedAudioCtx`) is now created during user gestures (`startCookingMode`, `addCookingTimer`). (2) The `_cookingTTS` gate (for step narration) was incorrectly blocking timer alarm speech — timer alerts now always speak regardless of that flag. (3) `_kioskBridge.speak()` (native Android TTS) was never considered as a fallback when `window.speechSynthesis` is absent in the WebView.
|
||||||
|
- **Scale use ignored for conf products** — `_scaleAutoFillUse()` returned early when `_activeUnit !== 'sub'`, but conf products default to `conf` mode. The function now auto-switches to sub mode before processing the weight reading. Scale button (`btnUse`) is also now visible for conf products that have a g/ml package unit.
|
||||||
|
- **Kiosk — native settings button reappearing unexpectedly** — `closeModal()` was calling `setNativeSettingsVisible(true)`, restoring the native Android settings button after every modal close. `_injectKioskOverlay()` now permanently hides the native button; scattered per-modal show/hide calls removed; a ⚙️ web button opens the in-app settings page.
|
||||||
|
- **SQLite database locked during inventory update** — `updateInventory()` made 3–4 separate write statements without a transaction; a concurrent cron job could acquire the write lock between them, causing a `database is locked` PDO error. All writes are now wrapped in `beginTransaction()`/`commit()`, with the Bring! HTTP sync deferred to after `commit()`. Closes [#109](https://github.com/dadaloop82/EverShelf/issues/109), [#110](https://github.com/dadaloop82/EverShelf/issues/110).
|
||||||
|
- **Depleted-item urgency incorrect** — Items with zero quantity were assigned urgency based on recency of use rather than consumption frequency. Urgency is now computed from `usesPerMonth` only, so frequently-used depleted items are correctly flagged as urgent.
|
||||||
|
- **0.5 conf use and decimal display** — Default mode on the use-quantity page is now conf for conf products; fraction buttons (½, ¼, ¾) work correctly; conf decimals are shown in the transaction history log.
|
||||||
|
- **Bring! health check token warning** — Token validity warning was shown even for valid tokens; health check is now restored with correct token-format detection.
|
||||||
|
- **Recipe quantities for conf+weight products** — Quantities are now calculated correctly when a conf product has a gram-based package unit.
|
||||||
|
- **Shopping settings not syncing across clients** — `shopping_*` keys were missing from `serverKeys` in `_applySyncedSettings`; shopping settings were client-local. All shopping keys now sync from server on load.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Native shopping list** — Built-in shopping list (no Bring! required) as an alternative mode (`SHOPPING_MODE=internal`). Resolves [#105](https://github.com/dadaloop82/EverShelf/issues/105).
|
||||||
|
- **Google Drive backup via localhost OAuth** — GDrive backup no longer requires a public domain; the OAuth redirect flow uses `http://localhost` via a temporary local server, compatible with self-hosted setups. Resolves [#107](https://github.com/dadaloop82/EverShelf/issues/107).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **All settings fully server-centralised** — Removed remaining `localStorage` usage for user preferences; all settings are now read from and written to `.env` via the API. Preferences are shared across all devices (desktop, phone, kiosk) automatically.
|
||||||
|
|
||||||
## [1.7.23] - 2026-05-18
|
## [1.7.23] - 2026-05-18
|
||||||
|
|
||||||
### 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)
|
||||||
@@ -36,13 +36,39 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
> **⚠️ Name disambiguation:** There is an unrelated iOS app also called **EverShelf**, developed and published by [Joshumi Technologies LLC](https://evershelf.joshumi.com/) on the [Apple App Store](https://apps.apple.com/app/evershelf/id6759439940). That application is a **completely separate, independent product** with no affiliation, association, or collaboration with this open-source project. This repository has no connection to Joshumi Technologies LLC, its products, or its services.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
> ⚙️ **New in v1.7.23 — Global settings tab, DB auto-cleanup, vacuum-sealed expiry**
|
### 🏠 NEW — Home Assistant Integration
|
||||||
> A new **Generali** tab groups all global settings (language, currency, theme, screensaver, zero-waste, export) in one place.
|
|
||||||
> Recipes older than `RECIPE_RETENTION_DAYS` and transactions older than `TRANSACTION_RETENTION_DAYS` are deleted automatically every cron cycle, followed by a SQLite `VACUUM` to keep the database small.
|
EverShelf has a **native Home Assistant integration** available on HACS.
|
||||||
> Vacuum-sealed products get an extended grace period (`VACUUM_EXPIRY_EXTENSION_DAYS`, default 30 days) before being flagged as expired.
|
Connect your pantry to your smart home in minutes — no YAML, no manual sensor setup.
|
||||||
> Auto theme now follows **time of day** (dark 20:00–07:00) instead of the OS setting, making it server-friendly.
|
|
||||||
|
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=dadaloop82&repository=ha-evershelf&category=integration)
|
||||||
|
|
||||||
|
[](https://my.home-assistant.io/redirect/config_flow_start/?domain=evershelf)
|
||||||
|
|
||||||
|
**What you get:**
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **16 sensors** | Expiry counts, stock levels by location (pantry / fridge / freezer), shopping list total, AI API usage, last backup timestamp, days to next expiry |
|
||||||
|
| **6 binary sensors** | Expired items, expiring items, expiring today, shopping list active, backup overdue, Bring! connected |
|
||||||
|
| **5 action buttons** | Refresh data, Refresh prices, **Suggest Recipe** (AI — result as HA notification), Sync smart shopping, Clear expired rows |
|
||||||
|
| **Shopping list todo** | Bidirectional sync — add, remove, check off items directly from HA |
|
||||||
|
| **Expiry calendar** | Every product's expiry date as a native HA calendar event — works with the calendar card and any calendar automation |
|
||||||
|
| **Quick-add text entity** | Type a product name in HA to instantly add it to the shopping list (great for voice assistants / Assist) |
|
||||||
|
| **6 services** | `add_to_shopping`, `mark_used`, `refresh`, `suggest_recipe`, `refresh_prices`, `clear_expired` |
|
||||||
|
| **Auto-discovery** | Detected automatically via Zeroconf/mDNS when `avahi-daemon` runs on the EverShelf host |
|
||||||
|
| **5 languages** | English, Italian, German, French, Spanish |
|
||||||
|
|
||||||
|
> **Requires a self-hosted EverShelf instance.** The integration talks directly to your server — no cloud involved.
|
||||||
|
> Full documentation: [ha-evershelf on GitHub](https://github.com/dadaloop82/ha-evershelf)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### 📦 Inventory Management
|
### 📦 Inventory Management
|
||||||
- **Export inventory** — Download the full inventory as a UTF-8 CSV (Excel-compatible) or open a print-ready page to save as PDF; export button always visible in the inventory page header
|
- **Export inventory** — Download the full inventory as a UTF-8 CSV (Excel-compatible) or open a print-ready page to save as PDF; export button always visible in the inventory page header
|
||||||
@@ -100,7 +126,7 @@
|
|||||||
|
|
||||||
### 🌙 Appearance
|
### 🌙 Appearance
|
||||||
- **Dark mode** — Three modes: Light, Dark, and Auto (time-based: dark from 20:00 to 07:00, light otherwise); applies immediately without page reload; auto mode re-evaluates every 5 minutes, so night/day transitions happen automatically even on always-on kiosk displays; theme is applied before the first render to prevent a white flash
|
- **Dark mode** — Three modes: Light, Dark, and Auto (time-based: dark from 20:00 to 07:00, light otherwise); applies immediately without page reload; auto mode re-evaluates every 5 minutes, so night/day transitions happen automatically even on always-on kiosk displays; theme is applied before the first render to prevent a white flash
|
||||||
- **Global settings tab** — A dedicated **⚙️ Generali** tab groups all system-wide settings (language, currency, theme, screensaver, zero-waste tips, export) at the top of the Settings panel
|
- **Global settings tab** — A dedicated **⚙️ General** tab groups all system-wide settings (language, currency, theme, screensaver, zero-waste tips, export) at the top of the Settings panel
|
||||||
|
|
||||||
### �️ Database Maintenance
|
### �️ Database Maintenance
|
||||||
- **Automatic cleanup** — Recipes older than `RECIPE_RETENTION_DAYS` (default 7) and transactions older than `TRANSACTION_RETENTION_DAYS` (default 7) are deleted automatically on every cron cycle; SQLite `VACUUM` runs after each cleanup to keep the file compact
|
- **Automatic cleanup** — Recipes older than `RECIPE_RETENTION_DAYS` (default 7) and transactions older than `TRANSACTION_RETENTION_DAYS` (default 7) are deleted automatically on every cron cycle; SQLite `VACUUM` runs after each cleanup to keep the file compact
|
||||||
@@ -111,7 +137,16 @@
|
|||||||
- **Mobile-first design** — Optimized for phones, works on tablets and desktop
|
- **Mobile-first design** — Optimized for phones, works on tablets and desktop
|
||||||
- **Installable** — Add to home screen for a native app experience
|
- **Installable** — Add to home screen for a native app experience
|
||||||
- **Multi-device** — All user data (shopping tags, pinned items, location preferences, scan history) is stored server-side in SQLite and shared across every device on the same instance; no data is siloed in a single browser's localStorage
|
- **Multi-device** — All user data (shopping tags, pinned items, location preferences, scan history) is stored server-side in SQLite and shared across every device on the same instance; no data is siloed in a single browser's localStorage
|
||||||
|
### 📶 Offline Mode
|
||||||
|
- **Automatic detection** — Full-screen overlay appears immediately on network loss; shows a "Continue offline" button after 3 s, and auto-enters offline mode after 8 s
|
||||||
|
- **Local inventory cache** — Inventory is synced to `localStorage` at every startup and on each successful API call; the offline view always reflects the last known state
|
||||||
|
- **Write queue** — Add, use, update and delete operations performed while offline are queued locally and synced to the server automatically on reconnect (including after a page refresh)
|
||||||
|
- **Optimistic UI** — Queued writes are applied immediately to the local cache so the interface stays responsive
|
||||||
|
- **Offline-computed stats** — Expiring and expired items are derived client-side from the cache; dashboard stat cards show real counts instead of zeros
|
||||||
|
- **AI/network sections hidden** — Anti-waste chart, nutrition analysis, recipe generator, price fetching, and Gemini chat are hidden in offline mode; the inventory, history, and manually-managed shopping list remain fully functional
|
||||||
|
- **Broken image fallback** — External product images (Open Food Facts, etc.) that fail to load are replaced with a neutral grey placeholder, keeping the layout intact
|
||||||
|
- **Startup recovery** — If the page is refreshed while operations are queued, they are detected and synced automatically on the next successful startup
|
||||||
|
- **Buffered error reporting** — `remoteLog` and `reportError` calls made while offline are stored locally and flushed to the server (and to GitHub issues) when the connection is restored
|
||||||
### ⚖️ Smart Scale Integration (Add-on)
|
### ⚖️ Smart Scale Integration (Add-on)
|
||||||
- **Bluetooth gateway** — Connects a BLE smart scale to EverShelf via local WebSocket
|
- **Bluetooth gateway** — Connects a BLE smart scale to EverShelf via local WebSocket
|
||||||
- **SSE relay** — Server-side relay avoids mixed-content (HTTPS→WS) issues
|
- **SSE relay** — Server-side relay avoids mixed-content (HTTPS→WS) issues
|
||||||
@@ -296,6 +331,24 @@ The included `backup.sh` creates local daily backups of your database:
|
|||||||
0 3 * * * /path/to/evershelf/backup.sh
|
0 3 * * * /path/to/evershelf/backup.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Google Drive Backup (Optional)
|
||||||
|
|
||||||
|
EverShelf supports automatic daily backups to Google Drive via OAuth 2.0. This works on any server, including private IP / local network setups (no public domain required).
|
||||||
|
|
||||||
|
**Setup:**
|
||||||
|
|
||||||
|
1. Go to [console.cloud.google.com](https://console.cloud.google.com) and select or create a project.
|
||||||
|
2. Enable the **Google Drive API** (`APIs & Services → Enable APIs → Google Drive API`).
|
||||||
|
3. Go to `APIs & Services → Credentials → Create Credentials → OAuth client ID`.
|
||||||
|
4. Application type: **Web application**.
|
||||||
|
5. Add **`http://localhost`** as an Authorized Redirect URI (this is the key — it works even without a real domain).
|
||||||
|
6. Copy **Client ID** and **Client Secret** into EverShelf Settings → Backup.
|
||||||
|
7. Enter your **Google Drive Folder ID** (the last part of the folder URL).
|
||||||
|
8. Click **Authorize with Google** and sign in.
|
||||||
|
9. The browser will redirect to `http://localhost` and may show a connection error — **this is expected**. Copy the full URL from the address bar (e.g. `http://localhost/?code=4%2F0A...`) and paste it into the field that appears in EverShelf, then click **Submit**.
|
||||||
|
|
||||||
|
> **Note:** While the OAuth app is in *Testing* status in Google Cloud Console, you must add your Google account as a test user under `APIs & Services → OAuth consent screen → Test users`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🏗️ Architecture
|
## 🏗️ Architecture
|
||||||
@@ -421,6 +474,54 @@ Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed g
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
EverShelf is a community project and contributions of any size are welcome!
|
||||||
|
|
||||||
|
### Easiest way to start — translate EverShelf into your language
|
||||||
|
|
||||||
|
Translations are just JSON files. No coding, no setup — fork → edit → PR.
|
||||||
|
|
||||||
|
```
|
||||||
|
translations/
|
||||||
|
├── it.json ✅ Italian (base)
|
||||||
|
├── en.json ✅ English
|
||||||
|
├── de.json ✅ German
|
||||||
|
├── fr.json ✅ French
|
||||||
|
├── es.json ✅ Spanish
|
||||||
|
├── pt.json ❌ Portuguese — wanted!
|
||||||
|
├── nl.json ❌ Dutch — wanted!
|
||||||
|
└── ... ❌ Your language here!
|
||||||
|
```
|
||||||
|
|
||||||
|
👉 See [issue #93](https://github.com/dadaloop82/EverShelf/issues/93) to claim a language.
|
||||||
|
|
||||||
|
### Other ways to contribute
|
||||||
|
|
||||||
|
| What | Skill needed |
|
||||||
|
|---|---|
|
||||||
|
| 🐛 Report a bug | None |
|
||||||
|
| 📖 Improve the wiki | Markdown |
|
||||||
|
| 🌍 Add a translation | JSON editing |
|
||||||
|
| 🎨 Fix a CSS/UI issue | CSS / HTML |
|
||||||
|
| ⚙️ Implement a feature | PHP / JS |
|
||||||
|
| ⭐ Star the repo | Clicking |
|
||||||
|
|
||||||
|
👉 Browse [`help wanted`](https://github.com/dadaloop82/EverShelf/labels/help%20wanted) issues for good starting points.
|
||||||
|
|
||||||
|
Read [CONTRIBUTING.md](CONTRIBUTING.md) for the full guide (branch naming, code style, how to run locally).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💬 Community
|
||||||
|
|
||||||
|
Join the conversation in [GitHub Discussions](https://github.com/dadaloop82/EverShelf/discussions):
|
||||||
|
- **Vote on upcoming features** — tell us what to build next
|
||||||
|
- **Show your setup** — share your kitchen kiosk
|
||||||
|
- **Ask questions** — get help from the community
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 📄 License
|
## 📄 License
|
||||||
|
|
||||||
This project is licensed under the **MIT License** — see the [LICENSE](LICENSE) file for details.
|
This project is licensed under the **MIT License** — see the [LICENSE](LICENSE) file for details.
|
||||||
|
|||||||
+152
-1
@@ -87,11 +87,45 @@ try {
|
|||||||
ob_end_clean();
|
ob_end_clean();
|
||||||
echo '[' . date('Y-m-d H:i:s') . '] DB cleanup done'
|
echo '[' . date('Y-m-d H:i:s') . '] DB cleanup done'
|
||||||
. ' (recipes >' . env('RECIPE_RETENTION_DAYS','7') . 'd'
|
. ' (recipes >' . env('RECIPE_RETENTION_DAYS','7') . 'd'
|
||||||
. ', tx >' . env('TRANSACTION_RETENTION_DAYS','7') . 'd' . ")\n";
|
. ', tx >' . env('TRANSACTION_RETENTION_DAYS','90') . 'd' . ")\n";
|
||||||
} catch (Throwable $ce) {
|
} catch (Throwable $ce) {
|
||||||
echo '[' . date('Y-m-d H:i:s') . '] DB cleanup warning: ' . $ce->getMessage() . "\n";
|
echo '[' . date('Y-m-d H:i:s') . '] DB cleanup warning: ' . $ce->getMessage() . "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Daily incremental backup ──────────────────────────────────────────
|
||||||
|
// Create a local backup at most once every 23 h; also push to Google Drive
|
||||||
|
// if GDRIVE_ENABLED=true. The guard prevents multiple backups per day even
|
||||||
|
// though the cron runs every 5 minutes.
|
||||||
|
if (env('BACKUP_ENABLED', 'true') === 'true') {
|
||||||
|
try {
|
||||||
|
$lastBackupTs = 0;
|
||||||
|
if (file_exists(BACKUP_LAST_TS_PATH)) {
|
||||||
|
$lastData = json_decode(file_get_contents(BACKUP_LAST_TS_PATH), true) ?: [];
|
||||||
|
$lastBackupTs = (int)($lastData['ts'] ?? 0);
|
||||||
|
}
|
||||||
|
if (time() - $lastBackupTs >= 82800) { // 23 h
|
||||||
|
$backupResult = createLocalBackup($db);
|
||||||
|
if ($backupResult['success']) {
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] Backup local: ' . $backupResult['filename']
|
||||||
|
. ' (' . $backupResult['size_kb'] . 'KB, purged ' . $backupResult['purged'] . " old)\n";
|
||||||
|
if (env('GDRIVE_ENABLED', 'false') === 'true') {
|
||||||
|
$gResult = backupToGDrive($db);
|
||||||
|
if ($gResult['success']) {
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] Backup GDrive: OK'
|
||||||
|
. ' (purged remote: ' . ($gResult['purged_remote'] ?? 0) . ")\n";
|
||||||
|
} else {
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] Backup GDrive warning: ' . ($gResult['error'] ?? 'unknown') . "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] Backup warning: ' . ($backupResult['error'] ?? 'unknown') . "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Throwable $be) {
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] Backup error: ' . $be->getMessage() . "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
$msg = $e->getMessage();
|
$msg = $e->getMessage();
|
||||||
echo '[' . date('Y-m-d H:i:s') . '] ERROR: ' . $msg . "\n";
|
echo '[' . date('Y-m-d H:i:s') . '] ERROR: ' . $msg . "\n";
|
||||||
@@ -99,3 +133,120 @@ try {
|
|||||||
_phpErrorReport($msg, $e->getFile(), $e->getLine(), $e->getTraceAsString(), get_class($e));
|
_phpErrorReport($msg, $e->getFile(), $e->getLine(), $e->getTraceAsString(), get_class($e));
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Home Assistant: expiry alerts ─────────────────────────────────────────────
|
||||||
|
// Fire one HA webhook per expiring item (once per day guard via a simple flag file).
|
||||||
|
if (env('HA_ENABLED', 'false') === 'true' && env('HA_WEBHOOK_ID', '') !== '') {
|
||||||
|
try {
|
||||||
|
$haFlagFile = __DIR__ . '/../data/ha_expiry_notified_' . date('Y-m-d') . '.json';
|
||||||
|
if (!file_exists($haFlagFile)) {
|
||||||
|
$expiryDays = max(1, (int)env('HA_EXPIRY_DAYS', '3'));
|
||||||
|
$expiringItems = $db->query(
|
||||||
|
"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
|
||||||
|
WHERE i.quantity > 0 AND i.expiry_date IS NOT NULL
|
||||||
|
AND i.expiry_date BETWEEN date('now') AND date('now', '+{$expiryDays} days')
|
||||||
|
ORDER BY i.expiry_date ASC LIMIT 20"
|
||||||
|
)->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$expiredItems = $db->query(
|
||||||
|
"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
|
||||||
|
WHERE i.quantity > 0 AND i.expiry_date IS NOT NULL
|
||||||
|
AND i.expiry_date < date('now')
|
||||||
|
ORDER BY i.expiry_date ASC LIMIT 10"
|
||||||
|
)->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)) {
|
||||||
|
$names = implode(', ', array_column($expiringItems, 'name'));
|
||||||
|
_fireHaWebhook('expiry_alert', [
|
||||||
|
'count' => count($expiringItems),
|
||||||
|
'items' => $expiringItems,
|
||||||
|
'type' => 'expiring_soon',
|
||||||
|
'days' => $expiryDays,
|
||||||
|
'summary' => $names,
|
||||||
|
]);
|
||||||
|
// Also send HA notification if service configured
|
||||||
|
if (env('HA_NOTIFY_SERVICE', '') !== '') {
|
||||||
|
$msg = count($expiringItems) . ' product(s) expiring within ' . $expiryDays . ' days: ' . $names;
|
||||||
|
_sendHaNotify($msg, ['expiring_items' => $expiringItems]);
|
||||||
|
}
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] HA expiry_alert fired: ' . count($expiringItems) . " items\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($expiredItems)) {
|
||||||
|
$expNames = implode(', ', array_column($expiredItems, 'name'));
|
||||||
|
_fireHaWebhook('expiry_alert', [
|
||||||
|
'count' => count($expiredItems),
|
||||||
|
'items' => $expiredItems,
|
||||||
|
'type' => 'expired',
|
||||||
|
'summary' => $expNames,
|
||||||
|
]);
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] HA expired fired: ' . count($expiredItems) . " items\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as done for today
|
||||||
|
file_put_contents($haFlagFile, json_encode(['ts' => time(), 'expiring' => count($expiringItems ?? []), 'expired' => count($expiredItems ?? [])]));
|
||||||
|
// Clean up old flag files (keep last 7 days)
|
||||||
|
foreach (glob(__DIR__ . '/../data/ha_expiry_notified_*.json') as $oldFlag) {
|
||||||
|
$flagDate = str_replace([__DIR__ . '/../data/ha_expiry_notified_', '.json'], '', $oldFlag);
|
||||||
|
if ($flagDate < date('Y-m-d', strtotime('-7 days'))) @unlink($oldFlag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Throwable $haE) {
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] HA expiry hook warning: ' . $haE->getMessage() . "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Avahi/mDNS discovery registration ─────────────────────────────────────────
|
||||||
|
// If avahi-daemon is running on this host, register the _evershelf._tcp service
|
||||||
|
// so that Home Assistant can auto-discover this instance via Zeroconf.
|
||||||
|
if (function_exists('shell_exec')) {
|
||||||
|
try {
|
||||||
|
$avahiService = '/etc/avahi/services/evershelf.xml';
|
||||||
|
// Only create/update if avahi-daemon is installed and the file doesn't exist yet
|
||||||
|
if (!file_exists($avahiService) && (shell_exec('which avahi-daemon 2>/dev/null') || shell_exec('which avahi-publish 2>/dev/null'))) {
|
||||||
|
$template = __DIR__ . '/../docker/avahi-evershelf.xml';
|
||||||
|
if (file_exists($template)) {
|
||||||
|
$xml = file_get_contents($template);
|
||||||
|
@file_put_contents($avahiService, $xml);
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] Avahi mDNS service registered at ' . $avahiService . "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Throwable $avahiE) {
|
||||||
|
// Non-fatal: avahi not available
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,6 +48,13 @@ function getDB(): PDO {
|
|||||||
? new LoggingPDO('sqlite:' . DB_PATH)
|
? new LoggingPDO('sqlite:' . DB_PATH)
|
||||||
: new PDO('sqlite:' . DB_PATH);
|
: new PDO('sqlite:' . DB_PATH);
|
||||||
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
|
// Set a busy timeout to prevent "database is locked" errors under high concurrency.
|
||||||
|
// This gives SQLite up to 5 seconds to acquire a lock before throwing an exception.
|
||||||
|
$db->setAttribute(PDO::ATTR_TIMEOUT, 5); // PDO::ATTR_TIMEOUT is in seconds for MySQL, but not directly for SQLite.
|
||||||
|
// For SQLite, we use PRAGMA busy_timeout.
|
||||||
|
$db->exec('PRAGMA journal_mode = WAL;');
|
||||||
|
$db->exec('PRAGMA busy_timeout = 5000;'); // 5000 milliseconds = 5 seconds
|
||||||
|
|
||||||
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||||
$db->exec("PRAGMA journal_mode=WAL");
|
$db->exec("PRAGMA journal_mode=WAL");
|
||||||
$db->exec("PRAGMA foreign_keys=ON");
|
$db->exec("PRAGMA foreign_keys=ON");
|
||||||
@@ -119,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');
|
||||||
@@ -244,6 +261,36 @@ function migrateDB(PDO $db): void {
|
|||||||
// Ensure composite indexes exist (added in v1.7.5 for performance)
|
// Ensure composite indexes exist (added in v1.7.5 for performance)
|
||||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_type_date ON transactions(type, created_at)");
|
$db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_type_date ON transactions(type, created_at)");
|
||||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_pid_type_undone ON transactions(product_id, type, undone)");
|
$db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_pid_type_undone ON transactions(product_id, type, undone)");
|
||||||
|
|
||||||
|
// Internal shopping list table (v1.8.0) — used when SHOPPING_MODE=internal
|
||||||
|
$shopTables = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='shopping_list'")->fetchAll();
|
||||||
|
if (empty($shopTables)) {
|
||||||
|
$db->exec("
|
||||||
|
CREATE TABLE shopping_list (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
raw_name TEXT NOT NULL DEFAULT '',
|
||||||
|
specification TEXT NOT NULL DEFAULT '',
|
||||||
|
added_at INTEGER DEFAULT (strftime('%s','now')),
|
||||||
|
sort_order INTEGER DEFAULT 0
|
||||||
|
)
|
||||||
|
");
|
||||||
|
$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; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -417,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;
|
||||||
|
|||||||
+2405
-271
File diff suppressed because it is too large
Load Diff
+674
-5
@@ -596,13 +596,37 @@ body {
|
|||||||
}
|
}
|
||||||
.offline-banner-retry:hover { background: rgba(255,255,255,0.38); }
|
.offline-banner-retry:hover { background: rgba(255,255,255,0.38); }
|
||||||
|
|
||||||
|
/* Pulsing dot shown in the banner while the offline cache is being read */
|
||||||
|
.offline-banner-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f87171;
|
||||||
|
margin-right: 6px;
|
||||||
|
vertical-align: middle;
|
||||||
|
animation: offline-dot-pulse 1.1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes offline-dot-pulse {
|
||||||
|
0%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||||
|
50% { opacity: 1; transform: scale(1.15); }
|
||||||
|
}
|
||||||
|
|
||||||
/* When server is offline, block interactions with the main content */
|
/* When server is offline, block interactions with the main content */
|
||||||
body.server-offline .app-content {
|
body.server-offline:not(.offline-mode) .app-content {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
transition: opacity 0.3s;
|
transition: opacity 0.3s;
|
||||||
}
|
}
|
||||||
|
/* In offline-mode the app is usable; just a subtle left-border indicator */
|
||||||
|
body.offline-mode .app-content {
|
||||||
|
border-left: 3px solid rgba(239, 68, 68, 0.45);
|
||||||
|
}
|
||||||
|
/* Hide the "Retry" button in the banner when in offline mode — use the Continue button instead */
|
||||||
|
body.offline-mode .offline-banner-retry {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
body.server-offline .bottom-nav {
|
body.server-offline .bottom-nav {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -1945,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;
|
||||||
@@ -2567,6 +2631,17 @@ body.server-offline .bottom-nav {
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shopping-pantry-hint {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: #15803d;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-top: 2px;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .shopping-pantry-hint {
|
||||||
|
color: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
.shopping-item-right {
|
.shopping-item-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -3027,10 +3102,82 @@ body.server-offline .bottom-nav {
|
|||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-preview-small {
|
/* Action and Use page hero card */
|
||||||
padding: 12px;
|
#page-action .product-preview-small,
|
||||||
|
#page-use .product-preview-small {
|
||||||
|
padding: 14px 16px;
|
||||||
|
gap: 14px;
|
||||||
|
border-left: 4px solid var(--primary);
|
||||||
|
align-items: flex-start;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* action page: slightly larger name */
|
||||||
|
#page-action .use-hero-name {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* barcode pill on action page */
|
||||||
|
.action-pill-barcode { background: #f1f5f9; color: #64748b; }
|
||||||
|
|
||||||
|
.use-hero-icon {
|
||||||
|
font-size: 2.4rem;
|
||||||
|
width: 52px;
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
line-height: 1;
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.use-hero-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.use-hero-name {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.use-hero-brand {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.use-hero-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.use-meta-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 99px;
|
||||||
|
line-height: 1.3;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expiry pill colours */
|
||||||
|
.use-pill-ok { background: #dcfce7; color: #166534; }
|
||||||
|
.use-pill-warn { background: #fef9c3; color: #854d0e; }
|
||||||
|
.use-pill-soon { background: #fed7aa; color: #7c2d12; }
|
||||||
|
.use-pill-expired { background: #fee2e2; color: #991b1b; }
|
||||||
|
/* Quantity pill */
|
||||||
|
.use-pill-qty { background: #e0f2fe; color: #0c4a6e; }
|
||||||
|
|
||||||
.product-preview img, .product-preview-small img {
|
.product-preview img, .product-preview-small img {
|
||||||
width: 60px;
|
width: 60px;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
@@ -3040,8 +3187,11 @@ body.server-offline .bottom-nav {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.product-preview-small img {
|
.product-preview-small img {
|
||||||
width: 45px;
|
width: 52px;
|
||||||
height: 45px;
|
height: 52px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-preview-emoji {
|
.product-preview-emoji {
|
||||||
@@ -4130,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;
|
||||||
}
|
}
|
||||||
@@ -4166,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 {
|
||||||
@@ -5583,6 +5763,26 @@ body.cooking-mode-active .app-header {
|
|||||||
background: rgba(59, 130, 246, 0.15);
|
background: rgba(59, 130, 246, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Related stock hint (same generic family, different brand/product) */
|
||||||
|
.action-related-stock-card {
|
||||||
|
background: #f0fdf4;
|
||||||
|
border: 1.5px solid #86efac;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #166534;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.action-related-stock-card strong { color: #15803d; }
|
||||||
|
.related-stock-item { display: inline-block; margin-right: 8px; }
|
||||||
|
[data-theme="dark"] .action-related-stock-card {
|
||||||
|
background: rgba(21, 128, 61, 0.12);
|
||||||
|
border-color: #166534;
|
||||||
|
color: #86efac;
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .action-related-stock-card strong { color: #4ade80; }
|
||||||
|
|
||||||
/* ===== ACTION BUTTONS GRID ===== */
|
/* ===== ACTION BUTTONS GRID ===== */
|
||||||
.action-buttons-3col {
|
.action-buttons-3col {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -6376,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;
|
||||||
@@ -6765,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;
|
||||||
@@ -7133,6 +7520,7 @@ body.cooking-mode-active .app-header {
|
|||||||
--bg: #0f172a;
|
--bg: #0f172a;
|
||||||
--bg-card: #1e293b;
|
--bg-card: #1e293b;
|
||||||
--bg-dark: #020617;
|
--bg-dark: #020617;
|
||||||
|
--bg-secondary: #263448;
|
||||||
--text: #e2e8f0;
|
--text: #e2e8f0;
|
||||||
--text-light: #94a3b8;
|
--text-light: #94a3b8;
|
||||||
--text-muted: #64748b;
|
--text-muted: #64748b;
|
||||||
@@ -7384,3 +7772,284 @@ body.cooking-mode-active .app-header {
|
|||||||
color: var(--primary-light);
|
color: var(--primary-light);
|
||||||
}
|
}
|
||||||
/* @media prefers-color-scheme: auto handled in JS */
|
/* @media prefers-color-scheme: auto handled in JS */
|
||||||
|
|
||||||
|
/* ===== DARK MODE — EXTENDED COMPONENT OVERRIDES ===== */
|
||||||
|
|
||||||
|
/* ── Inventory badges ── */
|
||||||
|
[data-theme="dark"] .badge-location { background: #0c2a4e; color: #7dd3fc; }
|
||||||
|
[data-theme="dark"] .badge-category { background: #1e293b; color: #94a3b8; }
|
||||||
|
[data-theme="dark"] .badge-qty { background: #0f2a1a; color: #86efac; }
|
||||||
|
[data-theme="dark"] .badge-expiry { background: #2a1a00; color: #fcd34d; }
|
||||||
|
[data-theme="dark"] .badge-expired { background: #2a0808; color: #fca5a5; }
|
||||||
|
|
||||||
|
/* ── Urgency / priority badges ── */
|
||||||
|
[data-theme="dark"] .badge-critical { background: #2a0808; color: #fca5a5; }
|
||||||
|
[data-theme="dark"] .badge-high { background: #2a1200; color: #fdba74; }
|
||||||
|
[data-theme="dark"] .badge-medium { background: #2a1e00; color: #fde68a; }
|
||||||
|
[data-theme="dark"] .badge-low { background: #0f2a1a; color: #86efac; }
|
||||||
|
[data-theme="dark"] .badge-freq-high { background: #2e0d1a; color: #f9a8d4; }
|
||||||
|
[data-theme="dark"] .badge-tag-add { background: #1e293b; color: #94a3b8; }
|
||||||
|
|
||||||
|
/* ── Smart shopping badges ── */
|
||||||
|
[data-theme="dark"] .smart-freq-badge.freq-suggest { background: #0c2a4e; color: #7dd3fc; }
|
||||||
|
[data-theme="dark"] .smart-freq-badge.freq-suggest-approx { background: #0c1f3a; color: #93c5fd; font-style: italic; }
|
||||||
|
[data-theme="dark"] .smart-pred-badge { background: #2a1e00; color: #fde68a; }
|
||||||
|
[data-theme="dark"] .smart-pred-badge.pred-urgent { background: #2a0808; color: #fca5a5; }
|
||||||
|
[data-theme="dark"] .smart-pred-badge.pred-soon { background: #2a1200; color: #fdba74; }
|
||||||
|
[data-theme="dark"] .smart-bring-badge { background: #0c2a4e; color: #7dd3fc; }
|
||||||
|
|
||||||
|
/* ── AW trend mini-cards ── */
|
||||||
|
[data-theme="dark"] .aw-tcard-good { background: #0f2a1a; border-color: #166534; }
|
||||||
|
[data-theme="dark"] .aw-tcard-ok { background: #1c1300; border-color: #78350f; }
|
||||||
|
[data-theme="dark"] .aw-tcard-bad { background: #2a0808; border-color: #7f1d1d; }
|
||||||
|
|
||||||
|
/* ── Alert sections ── */
|
||||||
|
[data-theme="dark"] .alert-danger { background: #2a0808; border-color: var(--danger); }
|
||||||
|
[data-theme="dark"] .alert-item { background: rgba(255,255,255,0.04); }
|
||||||
|
[data-theme="dark"] .alert-item-qty { background: rgba(255,255,255,0.06); }
|
||||||
|
[data-theme="dark"] .alert-review { background: #1c1300; border-color: #78350f; }
|
||||||
|
[data-theme="dark"] .alert-review h3 { color: #fcd34d; }
|
||||||
|
[data-theme="dark"] .alert-opened { background: #0c1f3a; border-color: #1e3a8a; }
|
||||||
|
[data-theme="dark"] .alert-opened h3 { color: #7dd3fc; }
|
||||||
|
[data-theme="dark"] .alert-item-badge.opened { background: #1e40af; }
|
||||||
|
|
||||||
|
/* ── Opened expiry badges ── */
|
||||||
|
[data-theme="dark"] .opened-expiry-ok { background: #0f2a1a; color: #86efac; }
|
||||||
|
[data-theme="dark"] .opened-expiry-soon { background: #2a1e00; color: #fde68a; }
|
||||||
|
[data-theme="dark"] .opened-expiry-urgent { background: #2a0808; color: #fca5a5; }
|
||||||
|
|
||||||
|
/* ── Alert banner: gradient overrides ── */
|
||||||
|
[data-theme="dark"] .alert-banner.banner-expired { background: #2a0808; border-color: #7f1d1d; }
|
||||||
|
[data-theme="dark"] .banner-expired .alert-banner-title { color: #fca5a5; }
|
||||||
|
[data-theme="dark"] .banner-expired .alert-banner-counter { color: #f87171; }
|
||||||
|
[data-theme="dark"] .alert-banner.banner-expiring { background: #1c1300; border-color: #78350f; }
|
||||||
|
[data-theme="dark"] .banner-expiring .alert-banner-title { color: #fdba74; }
|
||||||
|
[data-theme="dark"] .banner-expiring .alert-banner-counter { color: #fb923c; }
|
||||||
|
[data-theme="dark"] .alert-banner.banner-expired-ok { background: #0f2a1a; border-color: #166534; }
|
||||||
|
[data-theme="dark"] .banner-expired-ok .alert-banner-title { color: #86efac; }
|
||||||
|
[data-theme="dark"] .banner-expired-ok .alert-banner-counter { color: #4ade80; }
|
||||||
|
[data-theme="dark"] .alert-banner.banner-expired-warning { background: #1c1300; border-color: #78350f; }
|
||||||
|
[data-theme="dark"] .banner-expired-warning .alert-banner-title { color: #fde68a; }
|
||||||
|
[data-theme="dark"] .banner-expired-warning .alert-banner-counter { color: #fcd34d; }
|
||||||
|
[data-theme="dark"] .alert-banner.banner-expired-danger { background: #2a0808; border-color: #7f1d1d; border-width: 2px; }
|
||||||
|
[data-theme="dark"] .banner-expired-danger .alert-banner-title { color: #fca5a5; }
|
||||||
|
[data-theme="dark"] .alert-banner.banner-prediction { background: #1a1040; border-color: #6d28d9; }
|
||||||
|
[data-theme="dark"] .banner-prediction .alert-banner-title { color: #c4b5fd; }
|
||||||
|
[data-theme="dark"] .banner-prediction .alert-banner-counter { color: #a78bfa; }
|
||||||
|
[data-theme="dark"] .alert-banner.banner-anomaly { background: #1a1200; border-color: #c2410c; }
|
||||||
|
[data-theme="dark"] .banner-anomaly .alert-banner-title { color: #fdba74; }
|
||||||
|
[data-theme="dark"] .alert-banner.banner-no-expiry { background: #0f2a1a; border-color: #166534; }
|
||||||
|
[data-theme="dark"] .banner-no-expiry .alert-banner-title { color: #86efac; }
|
||||||
|
|
||||||
|
/* ── Alert banner: default text & close ── */
|
||||||
|
[data-theme="dark"] .alert-banner-title { color: #e2e8f0; }
|
||||||
|
[data-theme="dark"] .alert-banner-detail { color: #94a3b8; }
|
||||||
|
[data-theme="dark"] .alert-banner-close { color: #94a3b8; background: rgba(255,255,255,0.06); }
|
||||||
|
[data-theme="dark"] .banner-safety-warning { color: #fdba74; }
|
||||||
|
[data-theme="dark"] .banner-safety-ok { color: #86efac; }
|
||||||
|
[data-theme="dark"] .banner-safety-danger { color: #fca5a5; }
|
||||||
|
|
||||||
|
/* ── Banner action buttons ── */
|
||||||
|
[data-theme="dark"] .btn-banner-ok { background: #0f2a1a; color: #86efac; }
|
||||||
|
[data-theme="dark"] .btn-banner-edit { background: #1a1040; color: #c4b5fd; }
|
||||||
|
[data-theme="dark"] .btn-banner-ai { background: #2e1a4a; color: #c4b5fd; }
|
||||||
|
[data-theme="dark"] .btn-banner-weigh { background: #2e1a4a; color: #c4b5fd; }
|
||||||
|
[data-theme="dark"] .btn-banner-confirm { background: #0f2a1a; color: #86efac; }
|
||||||
|
[data-theme="dark"] .btn-banner-use { background: #0c2a4e; color: #7dd3fc; }
|
||||||
|
[data-theme="dark"] .btn-banner-throw { background: #2a0808; color: #fca5a5; }
|
||||||
|
[data-theme="dark"] .btn-banner-throw-primary { background: #dc2626; color: #fff; }
|
||||||
|
[data-theme="dark"] .btn-banner-use-danger { background: #1e293b; color: #64748b; }
|
||||||
|
[data-theme="dark"] .btn-banner-vacuum { background: #2e1a4a; color: #c4b5fd; }
|
||||||
|
[data-theme="dark"] .btn-banner-edit2 { background: #0c2a4e; color: #7dd3fc; }
|
||||||
|
|
||||||
|
/* ── Review items ── */
|
||||||
|
[data-theme="dark"] .review-item { background: rgba(255,255,255,0.04); }
|
||||||
|
[data-theme="dark"] .review-item-meta { color: #94a3b8; }
|
||||||
|
[data-theme="dark"] .review-warn { color: #fca5a5; }
|
||||||
|
[data-theme="dark"] .review-qty-value { background: #2a0808; color: #fca5a5; }
|
||||||
|
[data-theme="dark"] .btn-review-ok { background: #0f2a1a; color: #86efac; }
|
||||||
|
[data-theme="dark"] .btn-review-ok:active { background: #0d2416; }
|
||||||
|
[data-theme="dark"] .btn-review-edit { background: #1a1040; color: #c4b5fd; }
|
||||||
|
[data-theme="dark"] .btn-review-edit:active { background: #140d36; }
|
||||||
|
|
||||||
|
/* ── Chat UI ── */
|
||||||
|
[data-theme="dark"] .chat-header-bar { background: var(--bg-card); border-color: var(--border); }
|
||||||
|
[data-theme="dark"] .chat-title { color: #818cf8; }
|
||||||
|
[data-theme="dark"] .chat-suggestion { background: #1a1040; border-color: #3730a3; color: #a5b4fc; }
|
||||||
|
[data-theme="dark"] .chat-suggestion:active { background: #2e1a4a; }
|
||||||
|
[data-theme="dark"] .chat-gemini { background: var(--bg-card); color: var(--text); box-shadow: 0 1px 3px rgba(0,0,0,0.3); }
|
||||||
|
[data-theme="dark"] .chat-gemini strong { color: #818cf8; }
|
||||||
|
[data-theme="dark"] .chat-input-bar { background: var(--bg-card); border-color: var(--border); }
|
||||||
|
|
||||||
|
/* ── Settings status ── */
|
||||||
|
[data-theme="dark"] .settings-status.success { background: #0f2a1a; color: #86efac; }
|
||||||
|
[data-theme="dark"] .settings-status.error { background: #2a0808; color: #fca5a5; }
|
||||||
|
|
||||||
|
/* ── Inventory status bar ── */
|
||||||
|
[data-theme="dark"] .inventory-status-bar {
|
||||||
|
background: linear-gradient(135deg, #0c2a4e 0%, #1a1040 100%);
|
||||||
|
border-color: #1e3a8a;
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .inventory-status-bar .inv-status-title { color: #7dd3fc; }
|
||||||
|
[data-theme="dark"] .inventory-status-bar .inv-status-total { color: #e2e8f0; background: rgba(0,0,0,0.3); }
|
||||||
|
[data-theme="dark"] .inventory-status-bar .inv-status-item { color: #93c5fd; background: rgba(0,0,0,0.2); }
|
||||||
|
|
||||||
|
/* ── Use inventory info ── */
|
||||||
|
[data-theme="dark"] .use-inventory-info { background: #0c2a4e; color: #7dd3fc; }
|
||||||
|
[data-theme="dark"] #use-expiry-hint { background: #2a1e00; border-color: #78350f; color: #fde68a; }
|
||||||
|
[data-theme="dark"] #page-use .product-preview-small { border-left-color: var(--primary); }
|
||||||
|
[data-theme="dark"] #page-action .product-preview-small { border-left-color: var(--primary); }
|
||||||
|
[data-theme="dark"] .action-pill-barcode { background: #1e293b; color: #94a3b8; }
|
||||||
|
[data-theme="dark"] .use-pill-ok { background: #14532d; color: #86efac; }
|
||||||
|
[data-theme="dark"] .use-pill-warn { background: #422006; color: #fde68a; }
|
||||||
|
[data-theme="dark"] .use-pill-soon { background: #431407; color: #fdba74; }
|
||||||
|
[data-theme="dark"] .use-pill-expired { background: #450a0a; color: #fca5a5; }
|
||||||
|
[data-theme="dark"] .use-pill-qty { background: #0c2a4e; color: #7dd3fc; }
|
||||||
|
|
||||||
|
/* ── Recipe components ── */
|
||||||
|
[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-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:has(input:checked) { background: #2a1e00; border-color: #d97706; }
|
||||||
|
|
||||||
|
/* ── Bug report pills ── */
|
||||||
|
[data-theme="dark"] .bug-type-pill { background: var(--bg-card); border-color: var(--border); color: var(--text-light); }
|
||||||
|
|
||||||
|
/* ── Shopping tag menu ── */
|
||||||
|
[data-theme="dark"] .shopping-tag-menu-container { background: #1a2336; }
|
||||||
|
|
||||||
|
/* ── Edit unknown card ── */
|
||||||
|
[data-theme="dark"] .edit-unknown-card.highlight { background: #1c1300; border-color: var(--warning); }
|
||||||
|
|
||||||
|
/* ── AI match image ── */
|
||||||
|
[data-theme="dark"] .ai-match-img { background: var(--bg-card); }
|
||||||
|
|
||||||
|
/* ── Inline edit button ── */
|
||||||
|
[data-theme="dark"] .btn-edit-inline { background: rgba(30,41,59,0.92); border-color: var(--border); color: var(--text); }
|
||||||
|
|
||||||
|
/* ── Setup wizard ── */
|
||||||
|
[data-theme="dark"] .setup-body p { color: var(--text-muted); }
|
||||||
|
[data-theme="dark"] .setup-footer { border-color: var(--border); }
|
||||||
|
[data-theme="dark"] .setup-skip-link { color: var(--text-muted); }
|
||||||
|
[data-theme="dark"] .setup-skip-link:hover { color: var(--text-light); }
|
||||||
|
|
||||||
|
/* ── Appliance remove active ── */
|
||||||
|
[data-theme="dark"] .appliance-item .appliance-remove:active { background: #2a0808; }
|
||||||
|
|
||||||
|
/* ===== NETWORK ERROR OVERLAY ===== */
|
||||||
|
#network-error-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(6, 8, 20, 0.97);
|
||||||
|
z-index: 300000; /* highest: above screensaver(10000), cooking(99999), preloader(200000) */
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.4s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
#network-error-overlay.visible {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.net-error-body {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2.5rem 2rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.net-error-icon {
|
||||||
|
font-size: 5.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 1.75rem;
|
||||||
|
animation: net-pulse 2.2s ease-in-out infinite;
|
||||||
|
display: block;
|
||||||
|
filter: drop-shadow(0 0 32px rgba(248, 113, 113, 0.35));
|
||||||
|
}
|
||||||
|
#network-error-overlay.restored .net-error-icon {
|
||||||
|
animation: none;
|
||||||
|
filter: drop-shadow(0 0 32px rgba(74, 222, 128, 0.45));
|
||||||
|
}
|
||||||
|
#network-error-overlay.checking .net-error-icon {
|
||||||
|
animation: net-spin 1.2s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes net-pulse {
|
||||||
|
0%, 100% { opacity: 0.45; transform: scale(0.92); }
|
||||||
|
50% { opacity: 1; transform: scale(1.06); }
|
||||||
|
}
|
||||||
|
@keyframes net-spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
.net-error-title {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f87171;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
transition: color 0.4s;
|
||||||
|
}
|
||||||
|
#network-error-overlay.restored .net-error-title {
|
||||||
|
color: #4ade80;
|
||||||
|
}
|
||||||
|
.net-error-subtitle {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
max-width: 420px;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.net-error-status {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: #475569;
|
||||||
|
min-height: 1.3em;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
/* "Continue in offline mode" button — appears after 3 s */
|
||||||
|
.net-error-continue-btn {
|
||||||
|
margin-top: 2.2rem;
|
||||||
|
background: rgba(255,255,255,0.07);
|
||||||
|
border: 1px solid rgba(255,255,255,0.22);
|
||||||
|
color: #94a3b8;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.7rem 1.6rem;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, color 0.2s, transform 0.3s, opacity 0.3s;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
.net-error-continue-btn.visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
.net-error-continue-btn:hover {
|
||||||
|
background: rgba(255,255,255,0.16);
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Offline mode: hide AI and network-dependent UI ────────────────────────
|
||||||
|
Sections that require a live server response or AI are hidden so the user
|
||||||
|
isn't confronted with empty/broken widgets while offline. */
|
||||||
|
body.offline-mode #waste-chart-section,
|
||||||
|
body.offline-mode #nutrition-section,
|
||||||
|
body.offline-mode #quick-recipe-bar,
|
||||||
|
body.offline-mode .header-gemini-btn,
|
||||||
|
body.offline-mode #btn-suggest,
|
||||||
|
body.offline-mode #btn-fetch-prices,
|
||||||
|
body.offline-mode .recipe-generate-btn {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
/* Smart-shopping AI section: show as disabled rather than disappearing entirely */
|
||||||
|
body.offline-mode #smart-shopping {
|
||||||
|
opacity: 0.45;
|
||||||
|
pointer-events: none;
|
||||||
|
filter: grayscale(0.6);
|
||||||
|
}
|
||||||
|
|||||||
+2360
-292
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
{"ts":1779204302,"filename":"evershelf_2026-05-19_1525.db","size_kb":444}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" standalone='no'?>
|
||||||
|
<!DOCTYPE service-group SYSTEM "avahi-service.dtd">
|
||||||
|
<service-group>
|
||||||
|
<name replace-wildcards="yes">EverShelf Pantry (%h)</name>
|
||||||
|
<service>
|
||||||
|
<type>_evershelf._tcp</type>
|
||||||
|
<port>80</port>
|
||||||
|
<txt-record>path=/api/</txt-record>
|
||||||
|
<txt-record>version=1.0</txt-record>
|
||||||
|
<txt-record>app=evershelf</txt-record>
|
||||||
|
</service>
|
||||||
|
</service-group>
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
# Home Assistant Integration
|
||||||
|
|
||||||
|
EverShelf integrates natively with [Home Assistant](https://www.home-assistant.io/) to bring your pantry data into your smart-home automations.
|
||||||
|
|
||||||
|
**Capabilities:**
|
||||||
|
- 📡 **REST sensors** — expose pantry counts as HA sensor entities (expiring, expired, shopping list, total items)
|
||||||
|
- 🔔 **Webhooks** — trigger HA automations on pantry events (expiry alerts, shopping additions, stock updates)
|
||||||
|
- 📣 **Push notifications** — send alerts to your phone via any HA `notify.*` service
|
||||||
|
- 🔊 **TTS on smart speakers** — read recipe steps aloud on any HA `media_player` entity
|
||||||
|
- ⚙️ **In-app config panel** — configure everything from Settings → 🏠 tab (no need to edit `.env` manually)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Setup
|
||||||
|
|
||||||
|
1. **Generate a Long-Lived Access Token** in Home Assistant:
|
||||||
|
- Open HA → your **Profile** (bottom-left avatar) → **Security** → **Long-Lived Access Tokens** → **Create Token**
|
||||||
|
- Copy the generated token — you won't see it again.
|
||||||
|
|
||||||
|
2. **Open EverShelf Settings** → tab **🏠 Home Assistant**.
|
||||||
|
|
||||||
|
3. Fill in **Home Assistant URL** (e.g. `http://homeassistant.local:8123`) and paste the token.
|
||||||
|
|
||||||
|
4. Click **Test connection** — you should see ✅.
|
||||||
|
|
||||||
|
5. Enable the features you want (TTS, Webhooks, REST Sensors) and click **Save HA settings**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## REST Sensors
|
||||||
|
|
||||||
|
Add EverShelf pantry data as native HA sensor entities that update automatically.
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
| URL | Returns | Sensor |
|
||||||
|
|-----|---------|--------|
|
||||||
|
| `/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=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=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
|
||||||
|
|
||||||
|
In Settings → 🏠 Home Assistant → **REST Sensors** card, click **Copy YAML** to get a ready-to-paste `configuration.yaml` block that already contains your EverShelf URL.
|
||||||
|
|
||||||
|
### Manual YAML example
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# configuration.yaml
|
||||||
|
sensor:
|
||||||
|
- platform: rest
|
||||||
|
name: "EverShelf Overview"
|
||||||
|
unique_id: evershelf_overview
|
||||||
|
resource: "http://YOUR_EVERSHELF_URL/api/?action=ha_sensor"
|
||||||
|
scan_interval: 300 # seconds
|
||||||
|
value_template: "{{ value_json.state }}"
|
||||||
|
json_attributes:
|
||||||
|
- expiring_soon
|
||||||
|
- expiring_3d
|
||||||
|
- expired_items
|
||||||
|
- total_items
|
||||||
|
- shopping_items
|
||||||
|
- 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
|
||||||
|
unit_of_measurement: "items"
|
||||||
|
|
||||||
|
- platform: rest
|
||||||
|
name: "EverShelf Shopping Count"
|
||||||
|
unique_id: evershelf_shopping
|
||||||
|
resource: "http://YOUR_EVERSHELF_URL/api/?action=ha_sensor&sensor=shopping"
|
||||||
|
scan_interval: 180
|
||||||
|
value_template: "{{ value_json.state }}"
|
||||||
|
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`.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
EverShelf fires an HTTP POST to your HA webhook URL when pantry events occur.
|
||||||
|
|
||||||
|
### Create the HA Webhook Automation
|
||||||
|
|
||||||
|
1. HA → **Settings** → **Automations & Scenes** → **Create Automation**
|
||||||
|
2. Click **Add Trigger** → choose **Webhook**
|
||||||
|
3. HA generates a **Webhook ID** — copy it
|
||||||
|
4. Paste the ID into **Settings → 🏠 Home Assistant → Webhook ID**
|
||||||
|
5. Select which events should trigger the webhook
|
||||||
|
|
||||||
|
### Supported Events
|
||||||
|
|
||||||
|
| Event key | When it fires |
|
||||||
|
|-----------|--------------|
|
||||||
|
| `expiry` | Daily cron — items expiring within `HA_EXPIRY_DAYS` days |
|
||||||
|
| `shopping_add` | Item added to the shopping list |
|
||||||
|
| `stock_update` | Inventory quantity changed |
|
||||||
|
| `barcode_scan` | (reserved for future use) |
|
||||||
|
|
||||||
|
### Webhook Payload (POST body)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "expiry_alert",
|
||||||
|
"timestamp": "2025-06-12T08:00:00+00:00",
|
||||||
|
"data": {
|
||||||
|
"type": "expiring_soon",
|
||||||
|
"count": 3,
|
||||||
|
"days": 3,
|
||||||
|
"summary": "Milk, Yogurt, Butter",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example: Expiry Alert → Telegram
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
alias: EverShelf Expiry Alert
|
||||||
|
trigger:
|
||||||
|
- platform: webhook
|
||||||
|
webhook_id: "evershelf_webhook_abc123" # ← your Webhook ID
|
||||||
|
action:
|
||||||
|
- service: notify.telegram_bot
|
||||||
|
data:
|
||||||
|
message: >
|
||||||
|
🥫 EverShelf: {{ trigger.json.data.count }} product(s) expiring soon
|
||||||
|
{% for item in trigger.json.data.items %}
|
||||||
|
— {{ item.name }}{% if item.brand %} ({{ item.brand }}){% endif %} ·
|
||||||
|
{{ item.quantity }} {{ item.unit }} · 📍 {{ item.location }} ·
|
||||||
|
expires {{ item.expiry_date }} ({{ item.days_remaining }} days)
|
||||||
|
{% 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
|
||||||
|
|
||||||
|
If you prefer to receive push alerts without using webhooks, configure a **HA notify service** directly:
|
||||||
|
|
||||||
|
1. Find your notify service name in HA: **Developer Tools → Services** → search `notify`
|
||||||
|
2. Paste it into **Settings → 🏠 → Notify service** (e.g. `notify.mobile_app_my_phone`)
|
||||||
|
3. Save
|
||||||
|
|
||||||
|
EverShelf will call this service from the cron job whenever expiry alerts fire.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TTS on Smart Speakers
|
||||||
|
|
||||||
|
Read recipe steps aloud on an Amazon Echo, Google Home, Sonos, or any HA `media_player`.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
1. Enter the **Entity ID** of your media player (e.g. `media_player.kitchen_display`)
|
||||||
|
- Find it in HA: **Developer Tools → States**
|
||||||
|
2. Click **Apply HA preset to TTS tab** — this auto-fills the TTS tab with the correct HA endpoint and auth headers
|
||||||
|
3. Save settings
|
||||||
|
|
||||||
|
### How it Works
|
||||||
|
|
||||||
|
When recipe step TTS is triggered, EverShelf calls:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/services/tts/speak
|
||||||
|
Authorization: Bearer <HA_TOKEN>
|
||||||
|
{
|
||||||
|
"entity_id": "media_player.kitchen_display",
|
||||||
|
"message": "Add 200 g of flour and mix well."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The request is proxied through the EverShelf PHP backend (avoids CORS / mixed-content issues).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables Reference
|
||||||
|
|
||||||
|
All settings are configurable from `.env` or from the in-app Settings panel.
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `HA_ENABLED` | `false` | Master switch for all HA features |
|
||||||
|
| `HA_URL` | _(empty)_ | Base URL of HA instance, no trailing slash |
|
||||||
|
| `HA_TOKEN` | _(empty)_ | Long-Lived Access Token |
|
||||||
|
| `HA_TTS_ENTITY` | _(empty)_ | `media_player` entity for TTS |
|
||||||
|
| `HA_WEBHOOK_ID` | _(empty)_ | Webhook trigger ID from HA automation |
|
||||||
|
| `HA_WEBHOOK_EVENTS` | `expiry,shopping_add,stock_update` | Comma-separated list of events |
|
||||||
|
| `HA_NOTIFY_SERVICE` | _(empty)_ | HA notify service (e.g. `notify.mobile_app_phone`) |
|
||||||
|
| `HA_EXPIRY_DAYS` | `3` | Days before expiry to trigger the daily alert |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**Test shows ❌ "Connection failed"**
|
||||||
|
- Verify the URL is reachable from the EverShelf server (not just your browser)
|
||||||
|
- If using HTTPS with a self-signed certificate, the server-side cURL request may fail — use HTTP on the local network instead
|
||||||
|
- Check that port 8123 (or your custom port) is open on the HA host
|
||||||
|
|
||||||
|
**Test shows ❌ "bad_token"**
|
||||||
|
- The Long-Lived Access Token may have expired or been revoked — generate a new one in HA Profile
|
||||||
|
|
||||||
|
**Webhook not firing**
|
||||||
|
- Confirm HA_ENABLED=true and the Webhook ID is exactly as shown in HA
|
||||||
|
- Check the EverShelf cron is running (`/api/cron_smart_shopping.php` every 5 minutes)
|
||||||
|
- For shopping/stock events: verify the event name is in `HA_WEBHOOK_EVENTS`
|
||||||
|
|
||||||
|
**TTS not speaking**
|
||||||
|
- Ensure the media player entity is online in HA (check its state in Developer Tools)
|
||||||
|
- Try the "Apply HA preset to TTS tab" button and send a test from the TTS tab
|
||||||
|
- Check HA logs for `tts.speak` errors (some platforms require `tts_options`)
|
||||||
|
|
||||||
|
**Sensors show unavailable in HA**
|
||||||
|
- The EverShelf URL must be reachable from the HA host
|
||||||
|
- If running EverShelf behind a reverse proxy, ensure `/api/` is accessible
|
||||||
|
- Use `scan_interval` ≥ 60 to avoid hammering the server
|
||||||
@@ -5,14 +5,14 @@ plugins {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "it.dadaloop.evershelf.kiosk"
|
namespace = "it.dadaloop.evershelf.kiosk"
|
||||||
compileSdk = 34
|
compileSdk = 35
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "it.dadaloop.evershelf.kiosk"
|
applicationId = "it.dadaloop.evershelf.kiosk"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 34
|
targetSdk = 35
|
||||||
versionCode = 17
|
versionCode = 18
|
||||||
versionName = "1.7.16"
|
versionName = "1.7.17"
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|||||||
@@ -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() }
|
||||||
@@ -774,7 +798,13 @@ class KioskActivity : AppCompatActivity() {
|
|||||||
val q = DownloadManager.Query().setFilterById(downloadId)
|
val q = DownloadManager.Query().setFilterById(downloadId)
|
||||||
val c = (getSystemService(DOWNLOAD_SERVICE) as DownloadManager).query(q)
|
val c = (getSystemService(DOWNLOAD_SERVICE) as DownloadManager).query(q)
|
||||||
var ok = false
|
var ok = false
|
||||||
if (c.moveToFirst()) ok = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS)) == DownloadManager.STATUS_SUCCESSFUL
|
var dmStatus = -1
|
||||||
|
var dmReason = -1
|
||||||
|
if (c.moveToFirst()) {
|
||||||
|
dmStatus = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
|
||||||
|
dmReason = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_REASON))
|
||||||
|
ok = dmStatus == DownloadManager.STATUS_SUCCESSFUL
|
||||||
|
}
|
||||||
c.close()
|
c.close()
|
||||||
if (ok) {
|
if (ok) {
|
||||||
pollHandler.removeCallbacksAndMessages(null); activeDownloadId = -1
|
pollHandler.removeCallbacksAndMessages(null); activeDownloadId = -1
|
||||||
@@ -784,7 +814,12 @@ class KioskActivity : AppCompatActivity() {
|
|||||||
pollHandler.removeCallbacksAndMessages(null); activeDownloadId = -1
|
pollHandler.removeCallbacksAndMessages(null); activeDownloadId = -1
|
||||||
setInstallUI("\u274C", getString(R.string.install_error_download), getString(R.string.install_error_download_detail), 0xFFf87171.toInt(), btnEnabled = true, progress = -2)
|
setInstallUI("\u274C", getString(R.string.install_error_download), getString(R.string.install_error_download_detail), 0xFFf87171.toInt(), btnEnabled = true, progress = -2)
|
||||||
runOnUiThread { activeInstallBtn?.text = getString(R.string.install_btn_retry) }
|
runOnUiThread { activeInstallBtn?.text = getString(R.string.install_btn_retry) }
|
||||||
ErrorReporter.reportMessage("install_download_failed", "DownloadManager returned failure for URL: $apkUrl")
|
ErrorReporter.reportMessage(
|
||||||
|
"install_download_failed",
|
||||||
|
"DownloadManager returned failure for URL: $apkUrl",
|
||||||
|
mapOf("dm_status" to dmStatus, "dm_reason" to dmReason,
|
||||||
|
"device" to buildDeviceLabel())
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -868,6 +903,11 @@ class KioskActivity : AppCompatActivity() {
|
|||||||
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
||||||
// Note: setAppPackageName() is intentionally omitted — it causes STATUS_FAILURE (1)
|
// Note: setAppPackageName() is intentionally omitted — it causes STATUS_FAILURE (1)
|
||||||
// on some OEM/Android versions even when the package name is correct.
|
// on some OEM/Android versions even when the package name is correct.
|
||||||
|
// setInstallReason is required on Android 14+ (API 34+) for PackageInstaller
|
||||||
|
// to accept self-updates; without it Android 16 returns STATUS_FAILURE=1.
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
params.setInstallReason(android.content.pm.PackageManager.INSTALL_REASON_USER)
|
||||||
|
}
|
||||||
val sessionId = pi.createSession(params)
|
val sessionId = pi.createSession(params)
|
||||||
val session = pi.openSession(sessionId)
|
val session = pi.openSession(sessionId)
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
+298
-9
@@ -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.23</span>
|
<span class="app-preloader-version" id="preloader-version">v1.7.25</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.23</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.25</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 -->
|
||||||
@@ -249,6 +251,11 @@
|
|||||||
</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>
|
||||||
<!-- 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>
|
||||||
@@ -331,8 +338,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- Banner: shopping list scan context -->
|
<!-- Banner: shopping list scan context -->
|
||||||
<div id="shopping-scan-target-banner" class="shopping-scan-target-banner" style="display:none"></div>
|
<div id="shopping-scan-target-banner" class="shopping-scan-target-banner" style="display:none"></div>
|
||||||
<div class="product-preview product-preview-large" id="action-product-preview"></div>
|
<div class="product-preview product-preview-small" id="action-product-preview"></div>
|
||||||
<div class="inventory-status-bar" id="action-inventory-status" style="display:none"></div>
|
<div class="inventory-status-bar" id="action-inventory-status" style="display:none"></div>
|
||||||
|
<div id="action-related-stock" style="display:none"></div>
|
||||||
<div class="action-buttons" id="action-buttons-container">
|
<div class="action-buttons" id="action-buttons-container">
|
||||||
<button class="btn btn-huge btn-success" onclick="showAddForm()">
|
<button class="btn btn-huge btn-success" onclick="showAddForm()">
|
||||||
<span class="btn-icon">📥</span>
|
<span class="btn-icon">📥</span>
|
||||||
@@ -671,7 +679,7 @@
|
|||||||
<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">
|
||||||
<button class="btn btn-large btn-success full-width" onclick="openRecipeDialog()" data-i18n="recipes.generate">
|
<button class="btn btn-large btn-success full-width recipe-generate-btn" onclick="openRecipeDialog()" data-i18n="recipes.generate">
|
||||||
✨ Genera nuova ricetta
|
✨ Genera nuova ricetta
|
||||||
</button>
|
</button>
|
||||||
<div id="recipe-archive" class="recipe-archive"></div>
|
<div id="recipe-archive" class="recipe-archive"></div>
|
||||||
@@ -833,14 +841,16 @@
|
|||||||
<div class="settings-tabs">
|
<div class="settings-tabs">
|
||||||
<button class="settings-tab active" onclick="switchSettingsTab(this, 'tab-general')" data-tab="tab-general" data-i18n-title="settings.tab_general" title="Generali">⚙️</button>
|
<button class="settings-tab active" onclick="switchSettingsTab(this, 'tab-general')" data-tab="tab-general" data-i18n-title="settings.tab_general" title="Generali">⚙️</button>
|
||||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-api')" data-tab="tab-api" title="API Keys">🔑</button>
|
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-api')" data-tab="tab-api" title="API Keys">🔑</button>
|
||||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-bring')" data-tab="tab-bring" title="Bring!">🛒</button>
|
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-bring')" data-tab="tab-bring" data-i18n-title="settings.shopping.tab" title="Lista spesa">🛒</button>
|
||||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-recipe')" data-tab="tab-recipe" title="Ricette">🍳</button>
|
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-recipe')" data-tab="tab-recipe" title="Ricette">🍳</button>
|
||||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-mealplan')" data-tab="tab-mealplan" title="Piano Settimanale">📅</button>
|
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-mealplan')" data-tab="tab-mealplan" title="Piano Settimanale">📅</button>
|
||||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-appliances')" data-tab="tab-appliances" title="Elettrodomestici">🔌</button>
|
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-appliances')" data-tab="tab-appliances" title="Elettrodomestici">🔌</button>
|
||||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-camera')" data-tab="tab-camera" title="Fotocamera">📷</button>
|
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-camera')" data-tab="tab-camera" title="Fotocamera">📷</button>
|
||||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-security')" data-tab="tab-security" title="Sicurezza">🔒</button>
|
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-security')" data-tab="tab-security" title="Sicurezza">🔒</button>
|
||||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-tts')" data-tab="tab-tts" title="Voce (TTS)" data-i18n-title="settings.tab_tts">🔊</button>
|
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-tts')" data-tab="tab-tts" title="Voce (TTS)" data-i18n-title="settings.tab_tts">🔊</button>
|
||||||
|
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-ha'); _loadHaTab();" data-tab="tab-ha" title="Home Assistant" data-i18n-title="settings.ha.tab">🏠</button>
|
||||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-scale')" data-tab="tab-scale" title="Bilancia Smart" data-i18n-title="settings.scale.tab">⚖️</button>
|
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-scale')" data-tab="tab-scale" title="Bilancia Smart" data-i18n-title="settings.scale.tab">⚖️</button>
|
||||||
|
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-backup'); _loadBackupTab();" data-tab="tab-backup" data-i18n-title="settings.backup.tab" title="Backup">💾</button>
|
||||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-info'); _loadInfoTab();" data-tab="tab-info" data-i18n-title="settings.info.tab" title="Info">ℹ️</button>
|
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-info'); _loadInfoTab();" data-tab="tab-info" data-i18n-title="settings.info.tab" title="Info">ℹ️</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-panels">
|
<div class="settings-panels">
|
||||||
@@ -954,9 +964,36 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- Bring! Tab -->
|
<!-- Bring! Tab -->
|
||||||
<div class="settings-panel" id="tab-bring">
|
<div class="settings-panel" id="tab-bring">
|
||||||
|
<!-- Shopping enable + provider -->
|
||||||
<div class="settings-card">
|
<div class="settings-card">
|
||||||
<h4 data-i18n="settings.bring.title">🛒 Bring! Shopping List</h4>
|
<h4 data-i18n="settings.shopping.title">🛒 Lista della spesa</h4>
|
||||||
<p class="settings-hint" data-i18n="settings.bring.hint">Credenziali per l'integrazione con la lista della spesa Bring!</p>
|
<p class="settings-hint" data-i18n="settings.shopping.hint">Configura la lista della spesa integrata o collega Bring!.</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="toggle-row">
|
||||||
|
<span data-i18n="settings.shopping.enable_label">Abilita lista della spesa</span>
|
||||||
|
<span class="toggle-switch">
|
||||||
|
<input type="checkbox" id="setting-shopping-enabled" onchange="onShoppingEnabledChange()">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" id="shopping-mode-group">
|
||||||
|
<label data-i18n="settings.shopping.mode_label">Provider</label>
|
||||||
|
<div class="radio-group" style="margin-top:6px">
|
||||||
|
<label class="radio-option">
|
||||||
|
<input type="radio" name="shopping-mode" value="internal" onchange="onShoppingModeChange(this.value)">
|
||||||
|
<span data-i18n="settings.shopping.mode_internal">Interno (senza Bring!)</span>
|
||||||
|
</label>
|
||||||
|
<label class="radio-option" style="margin-left:16px">
|
||||||
|
<input type="radio" name="shopping-mode" value="bring" onchange="onShoppingModeChange(this.value)">
|
||||||
|
<span data-i18n="settings.shopping.mode_bring">Bring! (app esterna)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Bring! sub-section (shown only when mode = bring) -->
|
||||||
|
<div class="settings-card" id="bring-subsection" style="display:none;margin-top:12px">
|
||||||
|
<h4 data-i18n="settings.shopping.bring_section_title">Configurazione Bring!</h4>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label data-i18n="settings.bring.email_label">📧 Email Bring!</label>
|
<label data-i18n="settings.bring.email_label">📧 Email Bring!</label>
|
||||||
<input type="email" id="setting-bring-email" class="form-input" placeholder="email@esempio.com">
|
<input type="email" id="setting-bring-email" class="form-input" placeholder="email@esempio.com">
|
||||||
@@ -967,6 +1004,37 @@
|
|||||||
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-bring-password')" data-i18n="btn.toggle_password">👁️ Mostra/Nascondi</button>
|
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-bring-password')" data-i18n="btn.toggle_password">👁️ Mostra/Nascondi</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Smart suggestions + forecast -->
|
||||||
|
<div class="settings-card" style="margin-top:12px">
|
||||||
|
<h4 data-i18n="settings.shopping.ai_section_title">Assistenza AI</h4>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="toggle-row">
|
||||||
|
<span data-i18n="settings.shopping.smart_suggestions_label">Suggerimenti AI</span>
|
||||||
|
<span class="toggle-switch">
|
||||||
|
<input type="checkbox" id="setting-shopping-smart-suggestions">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="toggle-row">
|
||||||
|
<span data-i18n="settings.shopping.forecast_label">Previsione prodotti in esaurimento</span>
|
||||||
|
<span class="toggle-switch">
|
||||||
|
<input type="checkbox" id="setting-shopping-forecast">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-top:8px">
|
||||||
|
<label data-i18n="settings.shopping.auto_add_label">Aggiungi automaticamente quando</label>
|
||||||
|
<div class="qty-control" style="margin-top:6px">
|
||||||
|
<button type="button" class="qty-btn" onclick="adjustQty('setting-shopping-auto-add', -1, 0, 20)">−</button>
|
||||||
|
<input type="number" id="setting-shopping-auto-add" value="0" min="0" max="20" class="qty-input">
|
||||||
|
<button type="button" class="qty-btn" onclick="adjustQty('setting-shopping-auto-add', 1, 0, 20)">+</button>
|
||||||
|
</div>
|
||||||
|
<p class="settings-hint" data-i18n="settings.shopping.auto_add_suffix">rimasto in magazzino (0 = solo quando esaurito)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<!-- Price Estimation Settings -->
|
<!-- Price Estimation Settings -->
|
||||||
<div class="settings-card" style="margin-top:12px">
|
<div class="settings-card" style="margin-top:12px">
|
||||||
<h4 data-i18n="settings.price.title">💰 Stima Prezzi (AI)</h4>
|
<h4 data-i18n="settings.price.title">💰 Stima Prezzi (AI)</h4>
|
||||||
@@ -1253,10 +1321,127 @@
|
|||||||
</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 -->
|
||||||
|
<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.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>
|
||||||
|
<!-- Home Assistant Tab -->
|
||||||
|
<div class="settings-panel" id="tab-ha">
|
||||||
|
<!-- Connection card -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<h4 data-i18n="settings.ha.title">🏠 Home Assistant</h4>
|
||||||
|
<p class="settings-hint" data-i18n="settings.ha.hint">Integra EverShelf con Home Assistant: TTS su speaker smart, webhook per automazioni, sensori per la dashboard.</p>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-bottom:10px">
|
||||||
|
<label class="toggle-row">
|
||||||
|
<span data-i18n="settings.ha.enabled">✅ Abilita integrazione Home Assistant</span>
|
||||||
|
<span class="toggle-switch">
|
||||||
|
<input type="checkbox" id="setting-ha-enabled" onchange="onHaEnabledChange()">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="ha-config-section">
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="settings.ha.url_label">🌐 Home Assistant URL</label>
|
||||||
|
<input type="url" id="setting-ha-url" class="form-input" placeholder="http://192.168.1.50:8123">
|
||||||
|
<p class="settings-hint" data-i18n="settings.ha.url_hint">URL base della tua istanza HA (senza slash finale). Es: <code>http://homeassistant.local:8123</code></p>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="settings.ha.token_label">🔑 Long-Lived Access Token</label>
|
||||||
|
<div style="display:flex;gap:8px;align-items:center">
|
||||||
|
<input type="password" id="setting-ha-token" class="form-input" style="flex:1" placeholder="eyJhbGci...">
|
||||||
|
<button class="btn btn-secondary" style="flex-shrink:0" onclick="togglePasswordVisibility('setting-ha-token')" data-i18n="btn.toggle_password">👁️</button>
|
||||||
|
</div>
|
||||||
|
<p class="settings-hint" data-i18n="settings.ha.token_hint">Genera un token in HA → Profilo → Token di accesso a lungo termine.</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-secondary full-width" onclick="testHaConnection()" data-i18n="settings.ha.test_btn">🔗 Testa connessione HA</button>
|
||||||
|
<div id="ha-test-status" style="display:none;margin-top:8px" class="settings-status"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TTS via HA card -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<h4 data-i18n="settings.ha.tts_title">🔊 TTS su Speaker Smart</h4>
|
||||||
|
<p class="settings-hint" data-i18n="settings.ha.tts_hint">Leggi i passi della ricetta su un altoparlante gestito da HA (Sonos, Echo, Google Home, ecc.).</p>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="settings.ha.tts_entity_label">🔈 Entity ID del media player</label>
|
||||||
|
<input type="text" id="setting-ha-tts-entity" class="form-input" placeholder="media_player.living_room">
|
||||||
|
<p class="settings-hint" data-i18n="settings.ha.tts_entity_hint">Copia l'entity ID del media player da HA → Strumenti sviluppatore → Stati.</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="settings.ha.tts_platform_label">🎙️ Piattaforma TTS</label>
|
||||||
|
<select id="setting-ha-tts-platform" class="form-input">
|
||||||
|
<option value="tts.speak" data-i18n="settings.ha.tts_platform_speak">tts.speak (raccomandato)</option>
|
||||||
|
<option value="notify" data-i18n="settings.ha.tts_platform_notify">notify.* (servizio notifiche)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-secondary full-width" onclick="applyHaTtsPreset()" data-i18n="settings.ha.tts_apply_btn">✅ Applica preset HA al TTS</button>
|
||||||
|
<p class="settings-hint mt-2" data-i18n="settings.ha.tts_apply_hint">Configura automaticamente il tab TTS con i parametri HA corretti.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Webhook card -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<h4 data-i18n="settings.ha.webhook_title">⚡ Automazioni Webhook</h4>
|
||||||
|
<p class="settings-hint" data-i18n="settings.ha.webhook_hint">EverShelf chiama il webhook HA quando si verificano eventi (prodotto in scadenza, aggiunto alla lista, ecc.).</p>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="settings.ha.webhook_id_label">🔗 Webhook ID</label>
|
||||||
|
<input type="text" id="setting-ha-webhook-id" class="form-input" placeholder="evershelf_events">
|
||||||
|
<p class="settings-hint" data-i18n="settings.ha.webhook_id_hint">Crea un'automazione in HA con trigger "Webhook" e copia qui l'ID. <a href="#" onclick="showHaWebhookHelp();return false" style="color:var(--accent)">Come farlo?</a></p>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="settings.ha.webhook_events_label">📋 Eventi da notificare</label>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:6px;margin-top:4px">
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;font-weight:normal">
|
||||||
|
<input type="checkbox" id="ha-event-expiry" value="expiry"> <span data-i18n="settings.ha.event_expiry">Prodotti in scadenza (cron giornaliero)</span>
|
||||||
|
</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;font-weight:normal">
|
||||||
|
<input type="checkbox" id="ha-event-shopping" value="shopping_add"> <span data-i18n="settings.ha.event_shopping">Aggiunta alla lista della spesa</span>
|
||||||
|
</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;font-weight:normal">
|
||||||
|
<input type="checkbox" id="ha-event-stock" value="stock_update"> <span data-i18n="settings.ha.event_stock">Aggiornamento scorte (quantità modificata)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="settings.ha.expiry_days_label">📅 Giorni anticipo per scadenze</label>
|
||||||
|
<input type="number" id="setting-ha-expiry-days" class="form-input" min="1" max="30" value="3">
|
||||||
|
<p class="settings-hint" data-i18n="settings.ha.expiry_days_hint">Quanti giorni prima della scadenza inviare l'alert.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notify service card -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<h4 data-i18n="settings.ha.notify_title">📱 Notifiche Push</h4>
|
||||||
|
<p class="settings-hint" data-i18n="settings.ha.notify_hint">EverShelf invia notifiche push tramite il servizio <code>notify.*</code> di HA (Telegram, Pushover, app mobile, ecc.).</p>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="settings.ha.notify_service_label">📣 Servizio notify</label>
|
||||||
|
<input type="text" id="setting-ha-notify-service" class="form-input" placeholder="notify.mobile_app_mio_telefono">
|
||||||
|
<p class="settings-hint" data-i18n="settings.ha.notify_service_hint">Formato: <code>notify.NOME_SERVIZIO</code>. Lascia vuoto per disabilitare. Richiede token HA configurato.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sensor card (read-only info) -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<h4 data-i18n="settings.ha.sensor_title">📊 Sensori REST per HA</h4>
|
||||||
|
<p class="settings-hint" data-i18n="settings.ha.sensor_hint">HA può leggere i dati dell'inventario via REST polling. Aggiungi questo snippet a <code>configuration.yaml</code>:</p>
|
||||||
|
<div id="ha-sensor-yaml" style="background:var(--bg-secondary,#f1f5f9);border-radius:8px;padding:12px;font-family:monospace;font-size:0.75rem;white-space:pre;overflow-x:auto;max-height:220px;overflow-y:auto;border:1px solid var(--border,#e2e8f0)"></div>
|
||||||
|
<button class="btn btn-secondary full-width mt-2" onclick="copyHaSensorYaml()" data-i18n="settings.ha.sensor_copy_btn">📋 Copia YAML</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Save button -->
|
||||||
|
<button class="btn btn-large btn-accent full-width" onclick="saveHaSettings()" data-i18n="settings.ha.save_btn">💾 Salva impostazioni HA</button>
|
||||||
|
<div id="ha-save-status" style="display:none;margin-top:8px" class="settings-status"></div>
|
||||||
|
</div>
|
||||||
<!-- Scale Tab -->
|
<!-- Scale Tab -->
|
||||||
<div class="settings-panel" id="tab-scale">
|
<div class="settings-panel" id="tab-scale">
|
||||||
<div class="settings-card">
|
<div class="settings-card">
|
||||||
@@ -1342,6 +1527,93 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- Language Tab -->
|
<!-- Language Tab -->
|
||||||
|
|
||||||
|
<!-- Backup Tab -->
|
||||||
|
<div class="settings-panel" id="tab-backup">
|
||||||
|
<!-- Local Backup -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<h4 data-i18n="settings.backup.local_title">💾 Backup Locale</h4>
|
||||||
|
<p class="settings-hint" data-i18n="settings.backup.local_hint">Snapshot giornaliero automatico del database. Massimo 3 giorni di storico (configurabile).</p>
|
||||||
|
<div id="backup-last-info" style="margin-bottom:12px;padding:10px 12px;background:var(--bg-secondary,#f8fafc);border-radius:8px;font-size:0.83rem;color:var(--text-secondary)">
|
||||||
|
<span data-i18n="settings.info.loading">Caricamento…</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:14px">
|
||||||
|
<label data-i18n="settings.backup.retention_days" style="flex-shrink:0">Retention (giorni):</label>
|
||||||
|
<input type="number" id="setting-backup-retention-days" class="form-input" style="width:80px" min="1" max="90" value="3">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-large btn-accent full-width" onclick="_backupNow()" id="btn-backup-now" data-i18n="settings.backup.backup_now">💾 Backup Ora</button>
|
||||||
|
<div id="backup-status" style="display:none;margin-top:8px" class="settings-status"></div>
|
||||||
|
<!-- List of backups -->
|
||||||
|
<div id="backup-list-container" style="margin-top:14px">
|
||||||
|
<p class="settings-hint" data-i18n="settings.info.loading">Caricamento…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Google Drive -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<h4 data-i18n="settings.backup.gdrive_title">☁️ Google Drive</h4>
|
||||||
|
<p class="settings-hint" data-i18n="settings.backup.gdrive_hint">Carica automaticamente il backup su Google Drive usando un Service Account.</p>
|
||||||
|
<div class="form-group" style="margin-bottom:10px">
|
||||||
|
<label class="toggle-row">
|
||||||
|
<span data-i18n="settings.backup.gdrive_enabled">Abilita backup Google Drive</span>
|
||||||
|
<span class="toggle-switch">
|
||||||
|
<input type="checkbox" id="setting-gdrive-enabled" onchange="saveSettings()">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="gdrive-config-section">
|
||||||
|
<!-- Folder ID (shared between both methods) -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="settings.backup.gdrive_folder_id">ID Cartella Drive</label>
|
||||||
|
<input type="text" id="setting-gdrive-folder-id" class="form-input" placeholder="1ABCdef_xyz…">
|
||||||
|
<p class="settings-hint" data-i18n="settings.backup.gdrive_folder_id_hint">Copia l'ID dalla URL della cartella Drive: …/folders/<strong>ID</strong></p>
|
||||||
|
</div>
|
||||||
|
<!-- OAuth 2.0 section -->
|
||||||
|
<div id="gdrive-oauth-section">
|
||||||
|
<details style="margin-bottom:14px;background:var(--bg-secondary,#f8fafc);border-radius:8px;padding:10px 14px">
|
||||||
|
<summary style="cursor:pointer;font-weight:600;font-size:0.83rem" data-i18n="settings.backup.gdrive_oauth_how_to">📋 Come configurare OAuth 2.0 (passo dopo passo)</summary>
|
||||||
|
<ol style="margin:10px 0 0 16px;font-size:0.8rem;color:var(--text-secondary);line-height:1.8" data-i18n-html="settings.backup.gdrive_oauth_steps"></ol>
|
||||||
|
</details>
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="settings.backup.gdrive_client_id">Client ID</label>
|
||||||
|
<input type="text" id="setting-gdrive-client-id" class="form-input" placeholder="1234567890-abc….apps.googleusercontent.com">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="settings.backup.gdrive_client_secret">Client Secret</label>
|
||||||
|
<input type="password" id="setting-gdrive-client-secret" class="form-input" placeholder="GOCSPX-…">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="background:var(--bg-secondary,#f8fafc);border-radius:8px;padding:10px 14px;font-size:0.82rem">
|
||||||
|
<span data-i18n="settings.backup.gdrive_redirect_uri_label">Redirect URI (aggiungi in Google Cloud Console):</span>
|
||||||
|
<code id="gdrive-redirect-uri-display" style="display:block;margin-top:4px;word-break:break-all;color:var(--text-primary);font-size:0.78rem">http://localhost</code>
|
||||||
|
<p class="settings-hint" style="margin-top:6px;margin-bottom:0" data-i18n="settings.backup.gdrive_redirect_uri_hint">Registra questo URI in Google Cloud Console come "URI di reindirizzamento autorizzato". Per le installazioni senza dominio pubblico usa <strong>http://localhost</strong>.</p>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:8px">
|
||||||
|
<button class="btn btn-secondary" onclick="_gdriveAuthorize()" id="btn-gdrive-authorize" data-i18n="settings.backup.gdrive_oauth_authorize">🔑 Autorizza con Google</button>
|
||||||
|
<span id="gdrive-oauth-token-status" style="font-size:0.83rem"></span>
|
||||||
|
</div>
|
||||||
|
<!-- Manual code entry (appears after clicking Authorize) -->
|
||||||
|
<div id="gdrive-code-section" style="display:none;margin-top:12px;padding:12px 14px;background:var(--bg-secondary,#f8fafc);border-radius:8px;border:1px solid var(--border)">
|
||||||
|
<p style="font-size:0.82rem;margin-bottom:8px;font-weight:600" data-i18n="settings.backup.gdrive_code_title">Incolla l'URL o il codice di autorizzazione</p>
|
||||||
|
<p class="settings-hint" style="margin-bottom:8px" data-i18n="settings.backup.gdrive_code_hint">Dopo aver autorizzato su Google, il browser proverà ad aprire <code>http://localhost</code> e mostrerà un errore. Copia l'intero URL dalla barra degli indirizzi e incollalo qui sotto.</p>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||||
|
<input type="text" id="gdrive-code-input" class="form-input" style="flex:1;min-width:0" placeholder="http://localhost/?code=4%2F0A… oppure solo il codice">
|
||||||
|
<button class="btn btn-primary" onclick="_gdriveSubmitCode()" id="btn-gdrive-submit-code" data-i18n="settings.backup.gdrive_code_submit">Conferma</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Retention + action buttons (shared) -->
|
||||||
|
<div class="form-group" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:10px">
|
||||||
|
<label data-i18n="settings.backup.gdrive_retention_days" style="flex-shrink:0">Retention Drive (giorni, 0=tutto):</label>
|
||||||
|
<input type="number" id="setting-gdrive-retention-days" class="form-input" style="width:80px" min="0" max="365" value="30">
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:12px">
|
||||||
|
<button class="btn btn-secondary" onclick="_gdriveTest()" id="btn-gdrive-test" data-i18n="settings.backup.gdrive_test">🔗 Testa Connessione</button>
|
||||||
|
<button class="btn btn-accent" onclick="_gdrivePushNow()" id="btn-gdrive-push" data-i18n="settings.backup.gdrive_push_now">☁️ Carica Ora su Drive</button>
|
||||||
|
</div>
|
||||||
|
<div id="gdrive-test-status" style="display:none;margin-top:8px" class="settings-status"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Info Tab -->
|
<!-- Info Tab -->
|
||||||
<div class="settings-panel" id="tab-info">
|
<div class="settings-panel" id="tab-info">
|
||||||
<!-- Gemini AI Usage card -->
|
<!-- Gemini AI Usage card -->
|
||||||
@@ -1552,9 +1824,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>
|
||||||
@@ -1611,6 +1889,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== NETWORK ERROR OVERLAY ===== -->
|
||||||
|
<div id="network-error-overlay" style="display:none" aria-live="assertive" role="alert">
|
||||||
|
<div class="net-error-body">
|
||||||
|
<div class="net-error-icon" id="net-error-icon">📡</div>
|
||||||
|
<div class="net-error-title" id="net-error-title" data-i18n="error.offline_title">Nessuna connessione</div>
|
||||||
|
<div class="net-error-subtitle" id="net-error-subtitle" data-i18n="error.offline_subtitle">L'app non riesce a raggiungere il server. Verifica la connessione Wi-Fi.</div>
|
||||||
|
<div class="net-error-status" id="net-error-status"></div>
|
||||||
|
<button class="net-error-continue-btn" id="net-error-continue-btn" onclick="_enterOfflineMode()" data-i18n="error.offline_continue" style="display:none">Continua in modalità offline</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ===== COOKING MODE OVERLAY ===== -->
|
<!-- ===== COOKING MODE OVERLAY ===== -->
|
||||||
<div id="cooking-overlay" class="cooking-overlay" style="display:none" aria-live="polite">
|
<div id="cooking-overlay" class="cooking-overlay" style="display:none" aria-live="polite">
|
||||||
<div id="cooking-flash-overlay" class="cooking-flash-overlay"></div>
|
<div id="cooking-flash-overlay" class="cooking-flash-overlay"></div>
|
||||||
|
|||||||
+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.23",
|
"version": "1.7.25",
|
||||||
"start_url": "/evershelf/",
|
"start_url": "/evershelf/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#f0f4e8",
|
"background_color": "#f0f4e8",
|
||||||
|
|||||||
+1436
-1262
File diff suppressed because it is too large
Load Diff
+1436
-1262
File diff suppressed because it is too large
Load Diff
+1380
-1214
File diff suppressed because it is too large
Load Diff
+1380
-1214
File diff suppressed because it is too large
Load Diff
+1435
-1262
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user