Compare commits

..

34 Commits

Author SHA1 Message Date
dadaloop82 e858b3cc85 Merge branch 'develop' 2026-05-17 09:50:51 +00:00
dadaloop82 78f499205c feat: progress bar startup check with 29 diagnostics (v1.7.21)
- Replace banner checklist with real-time progress bar + per-check label
  Bar fills smoothly (0→100%) as each check runs; label shows current check.
  On success: bar stays green briefly then fades. On warnings: amber badges
  shown for 2.2s. On critical error: bar turns red + error block + Retry.
- Extend health_check to 29 comprehensive checks:
  PHP 8.0+ version, 4 critical extensions (pdo_sqlite/curl/json/mbstring),
  4 optional extensions (openssl/fileinfo/zip/intl), PHP memory/timeout/upload,
  data/ writable, rate_limits/ dir, backups/ dir, actual file-write test,
  free disk space, SQLite connect, required tables, PRAGMA quick_check integrity,
  WAL mode, DB file size, inventory row count, .env file, Gemini AI key,
  Bring! credentials + token, cURL SSL version, internet reachability (Gemini API)
- Fresh-install detection: if dispensa.db not found + data/ writable → OK (auto-create)
- Translations: startup.* expanded to 28 keys in IT, EN, DE, FR, ES
- CSS: new .preloader-progress-wrap, .preloader-bar-track, .preloader-bar,
  .preloader-check-label, .preloader-warn-badge; removed old .preloader-checks
- Version: v1.7.21, assets v=20260520b
2026-05-17 09:50:42 +00:00
dadaloop82 b3a0e83dde Merge branch 'develop' 2026-05-17 09:40:29 +00:00
dadaloop82 d3b119c7fe feat: startup health check during splash screen (v1.7.20)
- Add ?action=health_check PHP endpoint (early-exit, before rate-limiter)
  Checks: PHP version, required extensions, data/ writability, SQLite DB
  connection + table integrity, .env file, Gemini AI key, Bring! token
- Display animated checklist in splash screen with per-item icons
  (ok/warn/error); critical failures block app launch with clear error
  message and Retry button; optional warnings shown but don't block
- New JS: _runStartupCheck(), _startupRetry(); called first in _initApp()
- New HTML elements in #app-preloader: #preloader-checks, #preloader-error-msg,
  #preloader-retry-btn (hidden until startup check completes)
- New CSS: .preloader-checks, .preloader-check-row, .preloader-error-msg,
  .preloader-retry-btn with state colors (ok=green, warn=amber, error=red)
- Translations: startup.* keys (10 per language) in IT, EN, DE, FR, ES
- Asset version bump: v=20260520a
2026-05-17 09:40:11 +00:00
dadaloop82 9b8164b141 docs: highlight zero-waste tips in README 2026-05-17 09:20:02 +00:00
dadaloop82 8750e44687 docs: highlight zero-waste tips prominently in README 2026-05-17 09:19:53 +00:00
github-actions[bot] 57f66c17df chore: auto-merge develop → main
Triggered by: a602726 feat: zero-waste tips during cooking mode (#76)
2026-05-17 09:18:32 +00:00
dadaloop82 2630905146 feat: zero-waste tips during cooking mode (#76) 2026-05-17 09:17:04 +00:00
dadaloop82 a602726531 feat: zero-waste tips during cooking mode (#76) 2026-05-17 09:16:48 +00:00
github-actions[bot] 3f55f07220 chore: auto-merge develop → main
Triggered by: 06f6d58 docs: update README with dark mode + export inventory features (v1.7.18)
2026-05-17 09:03:27 +00:00
dadaloop82 06f6d58fb5 docs: update README with dark mode + export inventory features (v1.7.18) 2026-05-17 09:01:48 +00:00
dadaloop82 c1ef4c5e13 Merge develop: dark mode + export inventory v1.7.18 (#78, #64) 2026-05-17 08:59:46 +00:00
dadaloop82 0a6e653692 feat: dark mode (Off/On/Auto) + export inventory CSV/PDF (#78, #64) 2026-05-17 08:59:40 +00:00
dadaloop82 a99b35225a Merge develop: feat #77 French + Spanish translations (v1.7.17) 2026-05-17 08:36:53 +00:00
dadaloop82 3ba4f7eaad feat: add French and Spanish translations (#77)
- Complete fr.json (1049 keys, 52 sections)
- Complete es.json (1049 keys, 52 sections)
- Language selector updated with Francais and Espanol
- Setup wizard localized for fr/es
- Default fallback language changed from 'it' to 'en'
- Version bump to 1.7.17
2026-05-17 08:36:46 +00:00
dadaloop82 fdfd5cd0ec Merge develop: docs v1.7.16 README 2026-05-17 08:07:58 +00:00
dadaloop82 b973284aeb docs: update README for v1.7.16 (scan history + server-side data sync) 2026-05-17 08:07:56 +00:00
dadaloop82 0a5629e881 Merge develop: feat #68 scan history + server-side data centralisation 2026-05-17 08:03:39 +00:00
dadaloop82 d901939da1 feat: barcode scan history + full server-side data centralisation (#68)
- Add scan history (last 20 products) stored server-side via app_settings
- Render recent chips in scan page; tap to select product without re-scanning
- Migrate shopping_tags, pinned_bring, pref_use_loc, pref_move_loc,
  auto_added_bring, bring_blocklist, no_expiry_dismissed from localStorage
  to server-synced in-memory caches (_saveToServer pattern)
- Extend syncSettingsFromDB to load all 7 data caches + scan_history on startup
- One-time migration: existing localStorage data auto-uploaded to server on
  first load, old keys removed
- Fix dangling try/catch in toggleShoppingTag (was missing opening try)
2026-05-17 08:03:33 +00:00
dadaloop82 245e14cc3b Merge develop → main: fix opened items banner + remove strikethrough 2026-05-16 20:33:03 +00:00
dadaloop82 aaf9323ba5 fix: opened-but-not-edible items missing from banner + remove confusing strikethrough
- Banner (loadBannerAlerts): add step 1b — any item from statsData.opened
  with is_edible=false is queued as expired even when client-side
  getExpiredSafety would consider it 'ok' (e.g. conserve <30d past expiry).
  Applies to all product types, not just conserve.
  Requires fetching stats in the same Promise.all (no extra round-trip since
  loadDashboard already calls stats separately).
- CSS: remove text-decoration:line-through from .alert-item-spoiled .alert-item-name.
  The badge (/⚠️) already communicates the state; strikethrough added no
  information and confused users into thinking the item had been deleted.
2026-05-16 20:32:51 +00:00
dadaloop82 78c3306d9e Merge develop → main: fix camera button intercepted by kiosk overlay 2026-05-16 18:03:11 +00:00
dadaloop82 0f567c4ba0 fix: camera button (📷) intercepted by kiosk native btnSettings overlay
- Kiosk (Android): btnSettings was positioned top|end with alpha=0.12,
  sitting invisibly on top of the HTML scan button in the webapp header.
  Moved to bottom|end (marginBottom=80dp, alpha=0.28) so it never
  overlaps the header. Kiosk versionCode 15→16, versionName 1.7.15.
- Web (Android Chrome/Brave): pointerleave fired before pointerup when
  finger drifted, cancelling the long-press timer and letting a synthetic
  click bubble to an unintended handler. Fixed with setPointerCapture +
  preventDefault + replaced pointerleave with pointercancel. Added
  touch-action:manipulation to .header-scan-btn CSS.
2026-05-16 18:02:36 +00:00
dadaloop82 169e32bff3 Merge develop → main: assets + gitignore cleanup 2026-05-16 16:32:45 +00:00
dadaloop82 d28055a512 chore: add social preview image and demo GIF; untrack opened_shelf_cache 2026-05-16 16:32:40 +00:00
dadaloop82 68f7756e2c Merge develop → main: fix Brave TTS voice proxy crash (#63) 2026-05-16 16:28:10 +00:00
dadaloop82 b82b4d9d94 fix: guard against Brave user-script fake SpeechSynthesisVoice proxy (#63)
Brave on iOS injects a user-script that wraps SpeechSynthesisVoice objects
with a fake proxy. Accessing v.lang on the proxy threw 'undefined is not an
object (evaluating Object.getPrototypeOf(voice))'.

Fix: wrap the v.lang access in _initBrowserTtsVoices filter() inside its
own try/catch — bad proxies are silently discarded.
2026-05-16 16:28:06 +00:00
dependabot[bot] 91b4ecd670 ci: bump actions/checkout from 4 to 6 (#47)
chore: dependabot CI dependency update
2026-05-16 18:27:43 +02:00
dadaloop82 380fa8ee99 Merge develop → main: roadmap → GitHub Project 2026-05-16 16:21:26 +00:00
dadaloop82 89b8686f4f docs: replace static roadmap with link to GitHub Project 2026-05-16 16:21:22 +00:00
dadaloop82 b6aa07a1fd Merge develop → main: v1.7.15 settings centralization 2026-05-16 16:09:59 +00:00
dadaloop82 47c26ffdc8 v1.7.15 — centralize all settings to server (.env + SQLite)
- TTS: tts_engine, tts_rate, tts_pitch, tts_auth_header_name, tts_auth_header_value,
  tts_extra_fields now stored in .env and synced across devices via get_settings/save_settings
- meal_plan: persisted to SQLite app_settings table on every edit (selectMealPlanType,
  resetMealPlan) and restored on startup via syncSettingsFromDB — all devices stay in sync
- tts_voice: also synced to SQLite for best-effort cross-device restore
- saveSettings() sends meal_plan + tts_voice to app_settings_save after env write
- Remove deprecated SPESA_PROVIDER and SPESA_AI_PROMPT from .env
- .env.example: full rewrite documenting all 30+ keys in labelled sections
  (AI, Shopping, TTS, Preferences, Appliances, Scale, Meal Plan, Screensaver, Prices,
  Security, Developer)
2026-05-16 16:09:49 +00:00
dadaloop82 12357db933 v1.7.15 — i18n audit, appliance translation, splash min 3s, demo GIF, decimal precision, gemini key fix 2026-05-16 15:48:53 +00:00
dadaloop82 6def94948b v1.7.15 — appliance translation, gemini key preserve on save
- _applianceDisplayName(): reverse lookup from canonical Italian names
  to settings.appliances.* i18n keys, with emoji stripping — appliance
  chips now show 'Air fryer', 'Heißluftfritteuse', etc. in EN/DE
- renderAppliances(): uses translated display name; remove button title
  uses t('btn.delete') instead of hardcoded 'Rimuovi'
- addApplianceQuick(): toast now uses t('toast.appliance_added') instead
  of hardcoded Italian ' aggiunto'
- saveSettings(): gemini_key in localStorage preserved when input is empty
  (key is not pre-populated for security — blank != user deleted the key)
- saveSettings(): _geminiAvailable re-synced from server after each save
  so recipe buttons immediately reflect correct state without page reload
2026-05-16 15:48:37 +00:00
23 changed files with 4170 additions and 363 deletions
+94 -17
View File
@@ -1,25 +1,102 @@
# EverShelf - Configuration
# Copy this file to .env and fill in your values
# cp .env.example .env
# EverShelf Configuration
# Copy this file to .env and fill in your values:
# cp .env.example .env
#
# All settings here can also be changed from the in-app Settings screen and
# will be written back to this file automatically.
# ─────────────────────────────────────────────────────────────────────────────
# Google Gemini AI API Key (required for AI features)
# Get one at: https://aistudio.google.com/app/apikey
# ── AI ────────────────────────────────────────────────────────────────────────
# Google Gemini API key (required for AI features: expiry reading, recipe gen, …)
# Get one free at: https://aistudio.google.com/app/apikey
GEMINI_API_KEY=
# Bring! Shopping List credentials (optional)
# Sign up at: https://www.getbring.com/
# ── Shopping list (Bring!) ────────────────────────────────────────────────────
# Credentials for the Bring! app (optional — app works without it)
BRING_EMAIL=
BRING_PASSWORD=
# TTS (Text-to-Speech) for cooking mode voice guidance (optional)
# Works with Home Assistant, or any HTTP endpoint that accepts text
TTS_URL=
TTS_TOKEN=
TTS_METHOD=POST
TTS_AUTH_TYPE=bearer
TTS_CONTENT_TYPE=application/json
TTS_PAYLOAD_KEY=message
# ── Text-to-Speech (TTS) ─────────────────────────────────────────────────────
# Works with Home Assistant, a local TTS server, or any HTTP endpoint.
# TTS_ENABLED: master switch (true/false)
TTS_ENABLED=false
# TTS_URL: endpoint that receives the text payload
TTS_URL=
# TTS_TOKEN: Authorization token sent as Bearer header (or empty)
TTS_TOKEN=
# TTS_METHOD: HTTP method (POST or GET)
TTS_METHOD=POST
# TTS_AUTH_TYPE: how the token is sent (bearer | basic | none)
TTS_AUTH_TYPE=bearer
# TTS_CONTENT_TYPE: request Content-Type header
TTS_CONTENT_TYPE=application/json
# TTS_PAYLOAD_KEY: JSON key that carries the text (e.g. "message", "text")
TTS_PAYLOAD_KEY=message
# TTS_ENGINE: preferred browser TTS engine ('browser', 'server', 'custom') — optional
TTS_ENGINE=
# TTS_RATE / TTS_PITCH: speech rate and pitch multipliers (1 = normal)
TTS_RATE=1
TTS_PITCH=1
# TTS_AUTH_HEADER_NAME / VALUE: custom HTTP header for authentication (optional)
TTS_AUTH_HEADER_NAME=
TTS_AUTH_HEADER_VALUE=
# TTS_EXTRA_FIELDS: additional JSON fields as key=value pairs, comma-separated (optional)
TTS_EXTRA_FIELDS=
# GitHub Error Reporting: token is hardcoded in api/index.php (same for all clients).
# No .env entry needed — update GH_ISSUE_TOKEN constant in api/index.php to rotate.
# ── User preferences ─────────────────────────────────────────────────────────
# These mirror the toggle switches in the Settings screen.
DEFAULT_PERSONS=1
PREF_VELOCE=false
PREF_POCAFAME=false
PREF_SCADENZE=true
PREF_HEALTHY=false
PREF_OPENED=true
PREF_ZEROWASTE=false
# Dietary restrictions shown to the AI (e.g. "vegetariano,senza glutine")
DIETARY=
# ── Appliances ────────────────────────────────────────────────────────────────
# Comma-separated list of appliances available in your kitchen.
# Used by the AI when generating recipes.
APPLIANCES=Forno,Microonde,Friggitrice ad aria,Pentola a pressione
# ── Camera ───────────────────────────────────────────────────────────────────
# Default camera for barcode scanning ('environment' = rear, 'user' = front)
CAMERA_FACING=environment
# ── Smart Kitchen Scale ───────────────────────────────────────────────────────
# SCALE_ENABLED: enables the scale integration
SCALE_ENABLED=false
# SCALE_GATEWAY_URL: address of the EverShelf Scale Gateway (Android app)
SCALE_GATEWAY_URL=
# ── Meal Plan ────────────────────────────────────────────────────────────────
# MEAL_PLAN_ENABLED: show the weekly meal planner tab in Settings
MEAL_PLAN_ENABLED=false
# ── Screensaver (kiosk / tablet mode) ────────────────────────────────────────
SCREENSAVER_ENABLED=false
# SCREENSAVER_TIMEOUT: inactivity seconds before screensaver activates (default 5 min)
SCREENSAVER_TIMEOUT=300
# ── Price estimates ───────────────────────────────────────────────────────────
# PRICE_ENABLED: show AI-estimated price column on the shopping list
PRICE_ENABLED=false
# PRICE_COUNTRY: country used for price context (e.g. "Italia", "Germany")
PRICE_COUNTRY=Italia
# PRICE_CURRENCY: ISO 4217 currency code (e.g. EUR, USD, GBP)
PRICE_CURRENCY=EUR
# PRICE_UPDATE_MONTHS: how many months to cache a price before re-fetching (default 3)
PRICE_UPDATE_MONTHS=3
# ── Security ─────────────────────────────────────────────────────────────────
# 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.
SETTINGS_TOKEN=
# ── Developer / demo ─────────────────────────────────────────────────────────
# DEMO_MODE: when true, all write operations are blocked (for public demos)
DEMO_MODE=false
# NOTE: GitHub error reporting uses a token hardcoded in api/index.php.
# To rotate it, update the GH_ISSUE_TOKEN constant there.
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up JDK 17
uses: actions/setup-java@v4
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up JDK 17
uses: actions/setup-java@v4
+6 -6
View File
@@ -11,7 +11,7 @@ jobs:
name: PHP Syntax Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Setup PHP
uses: shivammathur/setup-php@v2
@@ -27,7 +27,7 @@ jobs:
name: JavaScript Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Check JS syntax
run: |
@@ -37,7 +37,7 @@ jobs:
name: Docker Build Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Build Docker image
run: docker build -t evershelf-test .
@@ -53,7 +53,7 @@ jobs:
name: Validate Translation Files
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Validate JSON syntax
run: |
@@ -99,7 +99,7 @@ jobs:
contents: write
steps:
- name: Checkout (full history)
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
@@ -133,7 +133,7 @@ jobs:
contents: write
steps:
- name: Checkout main
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
ref: main
fetch-depth: 0
+2 -2
View File
@@ -22,7 +22,7 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Build Docker image
run: docker build -t evershelf:scan .
@@ -51,7 +51,7 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Run Trivy filesystem scanner
uses: aquasecurity/trivy-action@v0.36.0
+1
View File
@@ -50,3 +50,4 @@ data/error_reports.log
data/latest_release_cache.json
data/food_facts_cache.json
data/category_ai_cache.json
assets/img/logo/*_backup.*
+47
View File
@@ -11,6 +11,49 @@ 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.
## [1.7.21] - 2026-05-20
### Changed
- **Startup health check** — Complete redesign from a banner checklist to a **real-time progress bar**. The bar fills smoothly as each of 29 diagnostic checks runs, with the current check name shown below in real time. Warnings (⚠️) are displayed as amber badges that remain visible for 2 seconds before the app proceeds. Critical failures turn the bar red and show a detailed error block with a Retry button.
- **29 comprehensive checks**: PHP version, 8 PHP extensions (pdo_sqlite, curl, json, mbstring, openssl, fileinfo, zip, intl), PHP memory/timeout/upload config, data directory, rate_limits dir, backups dir, disk write test, free disk space, SQLite connection, required tables, integrity (PRAGMA quick_check), WAL mode, DB size, inventory row count, .env file, Gemini AI key, Bring! credentials, Bring! token, cURL SSL, internet reachability.
- Warnings now clearly visible: each non-critical failure shows as a named amber badge (e.g. "⚠️ Bring! token") that cannot be missed.
## [1.7.20] - 2026-05-20
### Added
- **Startup health check** — During the splash screen, the app now runs a comprehensive server-side diagnostic before loading: PHP version, required extensions (pdo_sqlite, curl, mbstring, json), `data/` directory writability, SQLite database connection and table integrity, `.env` file presence, Gemini AI key and Bring! token. Results are displayed as an animated checklist (✅ / ⚠️ / ❌). Critical failures (DB, extensions, data dir) block the app with a clear error message and a "Retry" button — the app never starts silently broken. Non-critical warnings (missing Gemini key, Bring! token) are shown as amber items but do not block startup.
- New `?action=health_check` PHP endpoint (early-exit, no rate-limit, no auth).
- New translation keys `startup.*` in all 5 languages (IT, EN, DE, FR, ES).
## [1.7.19] - 2026-05-19
### Added
- **Zero-waste tips during cooking** — When cooking mode is active, a ♻️ card appears below each step that generates reusable scraps (peels, cooking water, egg whites, cheese rinds, bread crusts, vegetable tops, etc.). Gemini generates the tips as part of the recipe JSON at no extra API cost. Tips are dismissible per-step and reset on recipe restart. Opt-in toggle in Settings → Zero-waste tips (default OFF). Resolves [#76](https://github.com/dadaloop82/EverShelf/issues/76).
- New translation keys `cooking.zerowaste_*` and `settings.zerowaste.*` in all 5 languages (IT, EN, DE, FR, ES).
## [1.7.18] - 2026-05-19
### Added
- **Dark mode** — New theme selector in Settings (Appearance card): **Off (Light)**, **On (Dark)**, **Auto (follows system)**. Applied immediately on page load to prevent white flash. Resolves [#78](https://github.com/dadaloop82/EverShelf/issues/78).
- **Export inventory** — New 📤 button in inventory page header opens a modal to download the inventory as **CSV** (UTF-8 with BOM, Excel-compatible) or open a **print-ready HTML page** (auto-triggers print dialog for PDF). Export card also available in Settings tab. Resolves [#64](https://github.com/dadaloop82/EverShelf/issues/64).
- `translations/de.json`: fixed missing `log.recipe_prefix` key.
## [1.7.17] - 2026-05-19
### Added
- **French translation (🇫🇷 Français)** — Complete `translations/fr.json` with all 1049 translation keys. Resolves [#77](https://github.com/dadaloop82/EverShelf/issues/77).
- **Spanish translation (🇪🇸 Español)** — Complete `translations/es.json` with all 1049 translation keys. Resolves [#77](https://github.com/dadaloop82/EverShelf/issues/77).
- Language selector in Settings now shows all 5 languages: 🇮🇹 Italiano, 🇬🇧 English, 🇩🇪 Deutsch, 🇫🇷 Français, 🇪🇸 Español.
- Default fallback language changed from Italian to English (for users with unsupported browser locale).
- Setup wizard "Done" screen and navigation buttons localised for French and Spanish.
## [1.7.16] - 2026-05-17
### Added
- **Barcode scan history** — Last 20 scanned products are stored server-side (SQLite `app_settings`) and shown as chips in the scan page (`#scan-recents-chips`). Tapping a chip selects the product directly — no need to scan again. Resolves [#68](https://github.com/dadaloop82/EverShelf/issues/68).
- **Full server-side user-data centralisation** — All user preferences previously siloed in `localStorage` per-device are now synced to the server via `app_settings_save` and loaded back at startup via `app_settings_get`. Affected data: shopping tags, pinned Bring! items, location preferences (use/move), auto-added Bring! entries, Bring! purchased blocklist, no-expiry dismissed products. Data is now shared across all devices (desktop, phone, kiosk, Android app).
- **One-time localStorage migration** — On first load, any data found in the old localStorage keys (`shopping_tags`, `_userPinnedBring`, `_prefUseLoc`, `_prefMoveLoc`, `_autoAddedBring`, `_bringPurchasedBlocklist`, `_noExpiryDismissed`, `evershelf_scan_recents`) is automatically migrated to the server and the local keys are removed.
## [1.7.15] - 2026-05-16
### Added
@@ -20,11 +63,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **`pz`/`conf` unit labels translated** — "pz" now shows as "pcs" in English and "Stk" in German; "conf" shows as "pkg" / "Pkg". All `unitLabels` objects in JS now use `t('units.pz')` / `t('units.conf')`.
### Fixed
- **Camera button (📷) opened kiosk SettingsActivity on Android** — The native `btnSettings` ImageButton in the kiosk layout was positioned `top|end` with `alpha=0.12` (nearly invisible), sitting directly on top of the HTML scan button in the webapp header. Every tap on the 📷 button was intercepted by the native View and opened `SettingsActivity`. Fixed: moved `btnSettings` to `bottom|end` (above the bottom nav bar, `marginBottom=80dp`) and increased `alpha` to `0.28` so it is clearly separate from the header. Kiosk versionCode bumped to 16.
- **Camera button (📷) opened settings on Android Chrome/Brave** — `pointerleave` fired before `pointerup` when finger drifted slightly, cancelling the long-press timer and leaving the browser to dispatch a synthetic `click` that bubbled to an unintended handler. Fixed: added `setPointerCapture` (prevents `pointerleave` during touch) and `preventDefault` (blocks synthetic click); replaced `pointerleave` with `pointercancel` handler. Added `touch-action: manipulation` to `.header-scan-btn` CSS.
- **Logo white background on splash screen** — Re-processed both `logo.png` and `logo_icon.png` with fuzz 35% alpha extraction, removing the white background that was visible against the dark splash background (`#0f172a`).
- **Recipe button label** — Shortened to "Ricetta" / "Recipe" / "Rezept" for compact display in the inventory quick-action modal.
- **Quantity decimal precision** — `qtyNum` in recipe/cooking ingredient buttons and `conf` fallback display in inventory cards now limited to 1 decimal place (was showing 7+ decimal places from raw AI output, e.g. `0.25353223 conf`).
- **"Errore" / "Error" fallback strings** — All remaining Italian hardcoded `'Errore'` fallbacks in `showToast()` calls replaced with `t('error.generic')`. Italian fallback strings removed from buttons that already used `t()`.
- **README Italian phrases** — "La quantità è giusta (2 pz)", "🤖 Spiega", "Latte / Affettato / Panna da cucina", "Buon appetito!", "L'ho buttato" replaced with English equivalents in the README.
- **Appliance chips translated** — `renderAppliances()` now shows translated names (e.g. "Air fryer" in EN, "Heißluftfritteuse" in DE) for all known canonical Italian appliance names via `_applianceDisplayName()` lookup. `addApplianceQuick` toast no longer hardcoded Italian. Remove-button title translated.
- **Gemini API key not preserved on settings save** — `saveSettings()` was overwriting `s.gemini_key = ""` when the Gemini input field was empty (it is intentionally not pre-populated for security). Key is now preserved if the input is blank. `_geminiAvailable` is re-fetched from the server after every settings save so the recipe buttons reflect the real state immediately.
## [1.7.14] - 2026-05-16
+17 -33
View File
@@ -24,8 +24,8 @@
[![PHP](https://img.shields.io/badge/PHP-8.0+-blue.svg)](https://www.php.net/)
[![SQLite](https://img.shields.io/badge/SQLite-3-blue.svg)](https://www.sqlite.org/)
[![Docker](https://img.shields.io/badge/Docker-Ready-2496ED.svg)](Dockerfile)
[![i18n](https://img.shields.io/badge/i18n-IT%20%7C%20EN%20%7C%20DE-orange.svg)](translations/)
[![Version](https://img.shields.io/badge/version-1.7.15-brightgreen.svg)](CHANGELOG.md)
[![i18n](https://img.shields.io/badge/i18n-IT%20%7C%20EN%20%7C%20DE%20%7C%20FR%20%7C%20ES-orange.svg)](translations/)
[![Version](https://img.shields.io/badge/version-1.7.19-brightgreen.svg)](CHANGELOG.md)
[![GitHub stars](https://img.shields.io/github/stars/dadaloop82/EverShelf?style=social)](https://github.com/dadaloop82/EverShelf/stargazers)
[![Last commit](https://img.shields.io/github/last-commit/dadaloop82/EverShelf/main)](https://github.com/dadaloop82/EverShelf/commits/main)
[![Contributors](https://img.shields.io/github/contributors/dadaloop82/EverShelf)](https://github.com/dadaloop82/EverShelf/graphs/contributors)
@@ -38,8 +38,14 @@
## ✨ Features
> ♻️ **New in v1.7.19 — Zero-waste cooking tips**
> During cooking, EverShelf shows a contextual ♻️ tip card for each step that generates reusable scraps — peels, cooking water, egg whites, cheese rinds, bread crusts and more.
> Tips are generated by Gemini *as part of the recipe* at zero extra API cost, shown inline in cooking mode, and dismissible per step.
> Enable the toggle in **Settings → Zero-waste tips** (default: off).
### 📦 Inventory Management
- **Barcode scanning** — Scan products with your phone camera using QuaggaJS
- **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
- **Barcode scanning** — Scan products with your phone camera using QuaggaJS; last 20 scanned products saved as tappable chips so you can re-select them without rescanning
- **AI identification** — Take a photo and let Google Gemini identify the product, with suggestions from your existing inventory; gracefully shows a friendly message when AI quota is exhausted instead of a raw API error
- **Smart locations** — Track items across Pantry, Fridge, Freezer, and custom locations
- **Expiry tracking** — Automatic shelf-life estimation based on product type and storage
@@ -68,6 +74,7 @@
- **Catalog coverage** — All product types resolve to a German Bring! catalog key for icon and category display in the Bring! app
### 🍳 Cooking Mode
- **♻️ Zero-waste tips** — For each cooking step that generates reusable scraps (peels, cooking water, egg whites, cheese rinds, bread crusts, vegetable tops, etc.), a dismissible ♻️ tip card appears with a practical reuse idea; tips are generated by Gemini as part of the recipe at no extra API cost; opt-in toggle in Settings (default OFF)
- **Step-by-step guidance** — Follow recipes with a hands-free cooking interface
- **Text-to-Speech** — Voice readout of recipe steps; supports browser Web Speech API, native Android TTS (kiosk), or a custom REST endpoint (Home Assistant, etc.); retries voice loading for up to 10 seconds with a fallback refresh button; TTS activates automatically without requiring the global TTS setting to be enabled
- **Auto-read on navigate** — Each step is read aloud automatically when you tap Next or Previous; the first step is read when entering cooking mode
@@ -90,10 +97,13 @@
- **Swipe navigation** — Touch swipe or tap arrows/dots to browse banner notifications
- **Quick-access buttons** — Recently used and most popular products shown on the inventory page for fast access
### 🌙 Appearance
- **Dark mode** — Three modes: Light, Dark, and Auto (follows the OS/browser setting); theme is applied before the first render to prevent a white flash on dark-mode systems; toggle in Settings → Appearance
### 📱 Progressive Web App
- **Mobile-first design** — Optimized for phones, works on tablets and desktop
- **Installable** — Add to home screen for a native app experience
- **Multi-device** — Settings and data sync across devices on the same server
- **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
### ⚖️ Smart Scale Integration (Add-on)
- **Bluetooth gateway** — Connects a BLE smart scale to EverShelf via local WebSocket
@@ -352,35 +362,7 @@ The application uses no build tools — edit files directly and refresh.
## 📋 Roadmap
### High Priority
- [ ] **Cooking mode — 3D wheel JS** — swipe navigation, gyroscope tilt, haptic feedback
- [ ] **Cooking mode — step timers** — auto-detect "X minutes" in recipe steps, countdown + alert
- [ ] **Push notifications** — daily expiry alerts via PWA Service Worker + VAPID
- [ ] **Quick search / quick-add bar** — always-visible search above the nav, PWA shortcuts
### Medium Priority
- [ ] **Receipt OCR → bulk add** — photo of receipt → Gemini Vision → auto-fill inventory
- [ ] **CSV/JSON export & import** — download/upload inventory from Settings
- [ ] **Custom storage locations** — user-defined locations beyond Fridge/Freezer/Pantry
- [ ] **Multi-user support** — PIN-based user distinction, action log with user label
- [ ] **AI optimal purchase prediction** — suggest "buy X units of Y within Z days"
- [ ] **Price history sparklines** — per-product price chart from the AI cache data
### Low Priority / Nice to Have
- [ ] **Dark mode** — CSS custom properties are already structured to support it
- [ ] **Full offline mode** — Service Worker cache to show inventory read-only when server is down
- [ ] **French & Spanish translations** (`fr.json`, `es.json`)
- [ ] **Swipe actions on inventory rows** — swipe left to use/discard, right to edit
- [ ] **PHP unit tests** — PHPUnit coverage for shelf-life, price calc, and key helpers
### Completed ✅
- ✅ AI price estimation in shopping list
- ✅ Server heartbeat + offline banner
- ✅ In-app bug reporter → automatic GitHub issue creation
- ✅ Cooking mode (start, steps, 3D wheel CSS)
- ✅ Kiosk ⚙️ Settings overlay button (replaces Android native button)
- ✅ Adaptive consumption anomaly detection
- ✅ CI/CD pipeline (PHP lint, JS lint, Docker build, Trivy security scan)
Feature requests, bug reports and planned work are tracked in the [**EverShelf Roadmap**](https://github.com/users/dadaloop82/projects/2) GitHub Project.
---
@@ -393,6 +375,8 @@ The app supports multiple languages via JSON translation files in the `translati
| 🇮🇹 Italian (it) | ✅ Complete (base) |
| 🇬🇧 English (en) | ✅ Complete |
| 🇩🇪 German (de) | ✅ Complete |
| 🇫🇷 French (fr) | ✅ Complete |
| 🇪🇸 Spanish (es) | ✅ Complete |
**Want to add your language?** See the [Translation Guide](CONTRIBUTING.md#-adding-translations) — just copy `translations/it.json`, translate the values, and submit a PR!
+335 -6
View File
@@ -106,6 +106,209 @@ if (($_GET['action'] ?? '') === 'ping') {
exit;
}
// ── Health check — startup diagnostic (no rate-limit, no auth required) ──────
if (($_GET['action'] ?? '') === 'health_check') {
$checks = [];
// ── 1. PHP version ────────────────────────────────────────────────────────
$checks['php_version'] = [
'ok' => version_compare(PHP_VERSION, '8.0.0', '>='),
'value' => PHP_VERSION,
];
// ── 2. Critical PHP extensions ────────────────────────────────────────────
foreach (['pdo_sqlite', 'curl', 'json', 'mbstring'] as $ext) {
$checks['ext_' . $ext] = ['ok' => extension_loaded($ext)];
}
// ── 3. Optional PHP extensions ────────────────────────────────────────────
foreach (['openssl', 'fileinfo', 'zip', 'intl'] as $ext) {
$checks['ext_' . $ext] = ['ok' => extension_loaded($ext), 'optional' => true];
}
// ── 4. PHP runtime configuration ─────────────────────────────────────────
// Memory limit
$memRaw = ini_get('memory_limit');
$memBytes = (function ($v) {
$v = trim($v);
if ($v === '-1') return PHP_INT_MAX;
$unit = strtolower(substr($v, -1));
$num = (int) $v;
return match ($unit) { 'g' => $num * 1073741824, 'm' => $num * 1048576, 'k' => $num * 1024, default => $num };
})($memRaw);
$checks['php_memory'] = ['ok' => $memBytes >= 64 * 1048576, 'value' => $memRaw, 'optional' => true];
// Max execution time
$maxExec = (int) ini_get('max_execution_time');
$checks['php_max_exec'] = [
'ok' => $maxExec === 0 || $maxExec >= 30,
'value' => $maxExec === 0 ? '∞' : $maxExec . 's',
'optional' => true,
];
// Upload size
$uploadRaw = ini_get('upload_max_filesize');
$checks['php_upload'] = ['ok' => true, 'value' => $uploadRaw, 'optional' => true];
// ── 5. data/ directory ────────────────────────────────────────────────────
$dataDir = __DIR__ . '/../data';
if (!is_dir($dataDir)) @mkdir($dataDir, 0775, true);
$dataDirOk = is_dir($dataDir) && is_writable($dataDir);
$checks['data_dir'] = ['ok' => $dataDirOk, 'path' => realpath($dataDir) ?: $dataDir];
// data/rate_limits/
$rlDir = $dataDir . '/rate_limits';
if (!is_dir($rlDir)) @mkdir($rlDir, 0775, true);
$checks['data_rate_limits'] = ['ok' => is_dir($rlDir) && is_writable($rlDir), 'optional' => true];
// data/backups/
$bkDir = $dataDir . '/backups';
if (!is_dir($bkDir)) @mkdir($bkDir, 0775, true);
$checks['data_backups'] = ['ok' => is_dir($bkDir) && is_writable($bkDir), 'optional' => true];
// ── 6. Actual file-write test ─────────────────────────────────────────────
$testFile = $dataDir . '/_hc_' . getmypid() . '.tmp';
$writeOk = $dataDirOk && (@file_put_contents($testFile, 'hc') !== false);
if ($writeOk) @unlink($testFile);
$checks['data_write_test'] = ['ok' => $writeOk];
// ── 7. Free disk space ────────────────────────────────────────────────────
$freeBytes = $dataDirOk ? @disk_free_space($dataDir) : false;
$freeMB = $freeBytes !== false ? round($freeBytes / 1048576) : null;
$checks['disk_space'] = [
'ok' => $freeBytes === false || $freeBytes > 50 * 1048576,
'value' => $freeMB !== null ? $freeMB . ' MB liberi' : null,
'optional' => true,
];
// ── 8. SQLite database ────────────────────────────────────────────────────
$dbPath = $dataDir . '/dispensa.db';
$isFresh = !file_exists($dbPath) && $dataDirOk;
if ($isFresh) {
// Fresh install: DB will be created automatically on first real API call
$checks['db_connect'] = ['ok' => true, 'fresh' => true, 'value' => 'nuovo impianto'];
$checks['db_tables'] = ['ok' => true, 'fresh' => true];
$checks['db_integrity'] = ['ok' => true, 'fresh' => true];
$checks['db_wal'] = ['ok' => true, 'fresh' => true, 'optional' => true];
$checks['db_size'] = ['ok' => true, 'value' => '0 KB', 'optional' => true];
$checks['db_row_count'] = ['ok' => true, 'value' => '0 prodotti', 'optional' => true];
} else {
$pdo = null; $dbConnOk = false;
try {
$pdo = new PDO('sqlite:' . $dbPath, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$pdo->query('SELECT 1');
$dbConnOk = true;
$checks['db_connect'] = ['ok' => true];
} catch (\Throwable $e) {
$checks['db_connect'] = ['ok' => false, 'error' => $e->getMessage()];
}
if ($dbConnOk && $pdo) {
// Required tables
try {
$tables = $pdo->query("SELECT name FROM sqlite_master WHERE type='table'")->fetchAll(PDO::FETCH_COLUMN);
$required = ['inventory', 'products', 'transactions'];
$missing = array_values(array_diff($required, $tables));
$checks['db_tables'] = ['ok' => empty($missing), 'missing' => $missing];
} catch (\Throwable $e) {
$checks['db_tables'] = ['ok' => false, 'error' => $e->getMessage()];
}
// Integrity (fast)
try {
$integ = $pdo->query("PRAGMA quick_check")->fetchColumn();
$checks['db_integrity'] = ['ok' => $integ === 'ok', 'value' => $integ !== 'ok' ? $integ : null];
} catch (\Throwable $e) {
$checks['db_integrity'] = ['ok' => false, 'error' => $e->getMessage()];
}
// WAL mode
try {
$wal = $pdo->query("PRAGMA journal_mode")->fetchColumn();
$checks['db_wal'] = ['ok' => $wal === 'wal', 'value' => $wal, 'optional' => true];
} catch (\Throwable $e) {
$checks['db_wal'] = ['ok' => false, 'optional' => true];
}
// DB file size
$dbSizeKB = round(filesize($dbPath) / 1024);
$checks['db_size'] = ['ok' => true, 'value' => $dbSizeKB . ' KB', 'optional' => true];
// Row count
try {
$cnt = $pdo->query("SELECT COUNT(*) FROM inventory WHERE quantity > 0")->fetchColumn();
$checks['db_row_count'] = ['ok' => true, 'value' => $cnt . ' prodotti in inventario', 'optional' => true];
} catch (\Throwable $e) {
$checks['db_row_count'] = ['ok' => true, 'value' => '?', 'optional' => true];
}
} else {
foreach (['db_tables', 'db_integrity'] as $k) $checks[$k] = ['ok' => false];
foreach (['db_wal', 'db_size', 'db_row_count'] as $k) $checks[$k] = ['ok' => false, 'optional' => true];
}
}
// ── 9. .env file ──────────────────────────────────────────────────────────
$checks['env_file'] = ['ok' => file_exists(__DIR__ . '/../.env'), 'optional' => true];
// ── 10. Gemini AI key ─────────────────────────────────────────────────────
$checks['gemini_key'] = ['ok' => !empty(env('GEMINI_API_KEY')), 'optional' => true];
// ── 11. Bring! credentials & token ────────────────────────────────────────
$checks['bring_credentials'] = [
'ok' => !empty(env('BRING_EMAIL')) && !empty(env('BRING_PASSWORD')),
'optional' => true,
];
$checks['bring_token'] = ['ok' => !empty(env('BRING_ACCESS_TOKEN')), 'optional' => true];
// ── 12. cURL SSL support ──────────────────────────────────────────────────
if (function_exists('curl_version')) {
$cv = curl_version();
$checks['curl_ssl'] = [
'ok' => !empty($cv['ssl_version']),
'value' => $cv['ssl_version'] ?? null,
'optional' => true,
];
} else {
$checks['curl_ssl'] = ['ok' => false, 'optional' => true];
}
// ── 13. Internet / Gemini API reachability ────────────────────────────────
$internetOk = false;
if (extension_loaded('curl')) {
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => 'https://generativelanguage.googleapis.com/',
CURLOPT_NOBODY => true,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_TIMEOUT => 3,
CURLOPT_CONNECTTIMEOUT => 2,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_SSL_VERIFYPEER => false,
]);
curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErr = curl_errno($ch);
curl_close($ch);
$internetOk = ($httpCode > 0) || ($curlErr === 0);
}
$checks['internet'] = ['ok' => $internetOk, 'optional' => true];
// ── Compute overall result ────────────────────────────────────────────────
$criticalKeys = [
'php_version', 'ext_pdo_sqlite', 'ext_curl', 'ext_json', 'ext_mbstring',
'data_dir', 'data_write_test', 'db_connect', 'db_tables', 'db_integrity',
];
$allOk = array_reduce($criticalKeys, fn($c, $k) => $c && ($checks[$k]['ok'] ?? false), true);
header('Content-Type: application/json');
echo json_encode(['ok' => $allOk, 'checks' => $checks, 'fresh' => $isFresh ?? false], JSON_UNESCAPED_UNICODE);
exit;
}
// ===== RATE LIMITING =====
/**
* Simple file-based rate limiter.
@@ -474,6 +677,10 @@ try {
guessCategoryFromAI();
break;
case 'export_inventory':
exportInventory($db);
break;
default:
http_response_code(404);
echo json_encode(['error' => 'Unknown action: ' . $action]);
@@ -485,6 +692,107 @@ try {
}
endif; // end !CRON_MODE
// ===== EXPORT INVENTORY =====
function exportInventory(PDO $db): void {
$format = strtolower($_GET['format'] ?? 'csv');
$stmt = $db->query("
SELECT p.name, p.brand, p.category, i.location, i.quantity, p.unit,
i.expiry_date, i.added_at, i.opened_at,
COALESCE(i.vacuum_sealed, 0) as vacuum_sealed,
p.barcode, p.notes
FROM inventory i
JOIN products p ON i.product_id = p.id
WHERE i.quantity > 0
ORDER BY p.name ASC
");
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
$date = date('Y-m-d');
if ($format === 'html') {
// Print-ready HTML for browser PDF
header('Content-Type: text/html; charset=utf-8');
$rows_html = '';
foreach ($rows as $r) {
$loc_icon = ['dispensa'=>'🗄️','frigo'=>'🧊','freezer'=>'❄️','altro'=>'📦'][$r['location']] ?? '📦';
$expiry = $r['expiry_date'] ? htmlspecialchars($r['expiry_date']) : '—';
$brand = $r['brand'] ? htmlspecialchars($r['brand']) : '';
$rows_html .= '<tr>'
. '<td>' . htmlspecialchars($r['name']) . ($brand ? '<br><small>' . $brand . '</small>' : '') . '</td>'
. '<td>' . htmlspecialchars(ucfirst($r['category'] ?? '')) . '</td>'
. '<td>' . $loc_icon . ' ' . htmlspecialchars(ucfirst($r['location'])) . '</td>'
. '<td style="text-align:right">' . htmlspecialchars($r['quantity']) . ' ' . htmlspecialchars($r['unit'] ?? 'pz') . '</td>'
. '<td>' . $expiry . '</td>'
. '<td>' . ($r['opened_at'] ? '📭 ' . htmlspecialchars($r['opened_at']) : '') . '</td>'
. '</tr>';
}
$count = count($rows);
echo <<<HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>EverShelf Inventory Export {$date}</title>
<style>
body{font-family:Arial,sans-serif;font-size:12px;margin:24px;color:#1a1a1a}
h1{font-size:18px;margin-bottom:4px}
.subtitle{color:#6b7280;font-size:11px;margin-bottom:16px}
table{width:100%;border-collapse:collapse}
th{background:#2d5016;color:#fff;padding:7px 10px;text-align:left;font-size:11px}
td{padding:6px 10px;border-bottom:1px solid #e5e7eb;vertical-align:top}
tr:nth-child(even) td{background:#f8fafc}
small{color:#6b7280}
@media print{
body{margin:12px}
button{display:none}
@page{margin:15mm}
}
</style>
</head>
<body>
<button onclick="window.print()" style="margin-bottom:16px;padding:8px 16px;background:#2d5016;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:13px">🖨️ Print / Save as PDF</button>
<h1>🏠 EverShelf Inventory</h1>
<div class="subtitle">Exported: {$date} &nbsp;·&nbsp; {$count} items</div>
<table>
<thead><tr>
<th>Name / Brand</th><th>Category</th><th>Location</th><th>Qty</th><th>Expiry</th><th>Opened</th>
</tr></thead>
<tbody>{$rows_html}</tbody>
</table>
<script>window.onload=function(){window.print();}</script>
</body>
</html>
HTML;
exit;
}
// Default: CSV download
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="evershelf-inventory-' . $date . '.csv"');
// UTF-8 BOM for Excel compatibility
echo "\xEF\xBB\xBF";
$out = fopen('php://output', 'w');
fputcsv($out, ['Name','Brand','Category','Location','Quantity','Unit','Expiry Date','Added','Opened At','Vacuum Sealed','Barcode','Notes']);
foreach ($rows as $r) {
fputcsv($out, [
$r['name'],
$r['brand'] ?? '',
$r['category'] ?? '',
$r['location'],
$r['quantity'],
$r['unit'] ?? 'pz',
$r['expiry_date'] ?? '',
$r['added_at'] ?? '',
$r['opened_at'] ?? '',
$r['vacuum_sealed'] ? 'Yes' : 'No',
$r['barcode'] ?? '',
$r['notes'] ?? '',
]);
}
fclose($out);
exit;
}
// ===== TTS PROXY =====
function ttsProxy() {
$body = json_decode(file_get_contents('php://input'), true);
@@ -2273,6 +2581,12 @@ function getServerSettings(): void {
'tts_content_type' => env('TTS_CONTENT_TYPE', 'application/json'),
'tts_payload_key' => env('TTS_PAYLOAD_KEY', 'message'),
'tts_enabled' => env('TTS_ENABLED', 'false') === 'true',
'tts_engine' => env('TTS_ENGINE', ''),
'tts_rate' => (float)env('TTS_RATE', '1'),
'tts_pitch' => (float)env('TTS_PITCH', '1'),
'tts_auth_header_name' => env('TTS_AUTH_HEADER_NAME', ''),
'tts_auth_header_value' => env('TTS_AUTH_HEADER_VALUE', ''),
'tts_extra_fields' => env('TTS_EXTRA_FIELDS', ''),
// User preferences (now server-side)
'default_persons' => intval(env('DEFAULT_PERSONS', '1')),
'pref_veloce' => env('PREF_VELOCE', 'false') === 'true',
@@ -2323,11 +2637,15 @@ function saveSettings(): void {
'tts_auth_type' => 'TTS_AUTH_TYPE',
'tts_content_type'=> 'TTS_CONTENT_TYPE',
'tts_payload_key' => 'TTS_PAYLOAD_KEY',
'camera_facing' => 'CAMERA_FACING',
'dietary' => 'DIETARY',
'scale_gateway_url' => 'SCALE_GATEWAY_URL',
'price_country' => 'PRICE_COUNTRY',
'price_currency' => 'PRICE_CURRENCY',
'camera_facing' => 'CAMERA_FACING',
'dietary' => 'DIETARY',
'scale_gateway_url' => 'SCALE_GATEWAY_URL',
'price_country' => 'PRICE_COUNTRY',
'price_currency' => 'PRICE_CURRENCY',
'tts_engine' => 'TTS_ENGINE',
'tts_auth_header_name' => 'TTS_AUTH_HEADER_NAME',
'tts_auth_header_value' => 'TTS_AUTH_HEADER_VALUE',
'tts_extra_fields' => 'TTS_EXTRA_FIELDS',
];
// Boolean keys
$boolMap = [
@@ -2349,6 +2667,11 @@ function saveSettings(): void {
'screensaver_timeout' => 'SCREENSAVER_TIMEOUT',
'price_update_months' => 'PRICE_UPDATE_MONTHS',
];
// Float keys
$floatMap = [
'tts_rate' => 'TTS_RATE',
'tts_pitch' => 'TTS_PITCH',
];
foreach ($keyMap as $inKey => $envKey) {
if (array_key_exists($inKey, $input)) {
@@ -2365,6 +2688,11 @@ function saveSettings(): void {
$envVars[$envKey] = (string)intval($input[$inKey]);
}
}
foreach ($floatMap as $inKey => $envKey) {
if (array_key_exists($inKey, $input)) {
$envVars[$envKey] = (string)(float)$input[$inKey];
}
}
// Arrays stored as comma-separated
if (array_key_exists('appliances', $input)) {
$envVars['APPLIANCES'] = is_array($input['appliances']) ? implode(',', $input['appliances']) : (string)$input['appliances'];
@@ -4333,13 +4661,14 @@ REGOLE:
6. Includi nella lista ingredienti TUTTI quelli citati nei passi (tranne acqua/sale/pepe/olio).
7. Language rule: {$recipeLangName} only for all textual fields (`title`, `tags`, `expiry_note`, `ingredients.qty`, `steps`, `nutrition_note`, `tools_needed`). Keep `meal` unchanged.
8. `tools_needed`: array of kitchen tools/appliances actually required by this recipe (e.g. ["Forno","Frullatore"]). Use the same language as all other text fields. Empty array [] if only stovetop/knife/pan needed.
9. `zero_waste_tips`: array of zero-waste tips for steps that generate reusable scraps (peels, leftover cooking water, egg whites, cheese rinds, bread crusts, vegetable tops, etc.). Each entry: {"step": 0-based_step_index, "scrap": "scrap name", "tip": "short practical reuse tip (max 20 words)"}. Use the same language as other text fields. Empty array [] if no reusable scraps are generated.
DISPENSA:
$ingredientsText
Rispondi SOLO JSON valido (no markdown):
{$promptLanguageRule}
{"title":"","meal":"$mealType","persons":$persons,"prep_time":"","cook_time":"","tags":[""],"expiry_note":"","tools_needed":[""],"ingredients":[{"name":"","qty":"200 g","qty_number":200,"from_pantry":true}],"steps":["{$promptStepExample}"],"nutrition_note":""}
{"title":"","meal":"$mealType","persons":$persons,"prep_time":"","cook_time":"","tags":[""],"expiry_note":"","tools_needed":[""],"ingredients":[{"name":"","qty":"200 g","qty_number":200,"from_pantry":true}],"steps":["{$promptStepExample}"],"nutrition_note":"","zero_waste_tips":[{"step":0,"scrap":"","tip":""}]}
PROMPT;
$genConfig = [
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 MiB

+413 -1
View File
@@ -116,6 +116,86 @@ body {
letter-spacing: 0.5px;
margin-top: -8px;
}
/* ── Startup progress bar ───────────────────────────────────────────── */
.preloader-progress-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
width: 250px;
max-width: 88vw;
animation: zwFadeIn 0.2s ease;
}
.preloader-bar-track {
width: 100%;
height: 6px;
background: rgba(255,255,255,0.12);
border-radius: 99px;
overflow: hidden;
}
.preloader-bar {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #4ade80, #22c55e);
border-radius: 99px;
transition: width 0.18s ease, background 0.3s ease;
}
.preloader-bar.bar-error { background: linear-gradient(90deg, #f87171, #ef4444); }
.preloader-bar.bar-warn { background: linear-gradient(90deg, #fbbf24, #f59e0b); }
.preloader-check-label {
color: rgba(255,255,255,0.60);
font-size: 0.74rem;
font-family: monospace;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
min-height: 1.1em;
letter-spacing: 0.01em;
}
.preloader-warnings {
display: flex;
flex-wrap: wrap;
gap: 5px;
justify-content: center;
max-width: 270px;
animation: zwFadeIn 0.25s ease;
}
.preloader-warn-badge {
background: rgba(251,191,36,0.15);
color: #fcd34d;
border: 1px solid rgba(251,191,36,0.35);
border-radius: 99px;
padding: 3px 10px;
font-size: 0.71rem;
white-space: nowrap;
}
.preloader-error-msg {
color: #fca5a5;
background: rgba(239,68,68,0.18);
border: 1px solid rgba(239,68,68,0.4);
border-radius: 10px;
padding: 12px 18px;
font-size: 0.84rem;
text-align: center;
max-width: 280px;
line-height: 1.5;
white-space: pre-line;
animation: zwFadeIn 0.3s ease;
}
.preloader-retry-btn {
background: #ef4444;
color: #fff;
border: none;
border-radius: 8px;
padding: 9px 22px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
animation: zwFadeIn 0.3s ease;
}
.preloader-retry-btn:active { opacity: 0.8; }
.header-logo-icon {
height: 28px;
width: auto;
@@ -279,6 +359,7 @@ body {
height: 48px;
box-shadow: 0 2px 8px rgba(0,0,0,0.18);
animation: pulse-scan 2s ease-in-out infinite;
touch-action: manipulation; /* prevent 300ms delay and double-tap zoom on mobile */
}
.header-scan-btn:active {
background: rgba(255,255,255,0.45);
@@ -5750,7 +5831,6 @@ body.cooking-mode-active .app-header {
opacity: 0.75;
}
.alert-item-spoiled .alert-item-name {
text-decoration: line-through;
color: var(--text-light);
}
@@ -6857,3 +6937,335 @@ body.cooking-mode-active .app-header {
color: #9ca3af;
font-size: 0.8em;
}
/* ===== PAGE HEADER ACTION BUTTON (export etc.) ===== */
.page-header-action-btn {
margin-left: auto;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 12px;
font-size: 1.1rem;
cursor: pointer;
transition: all 0.2s;
color: var(--primary);
}
.page-header-action-btn:active {
transform: scale(0.95);
opacity: 0.8;
}
/* ===== ZERO-WASTE TIP CARD (cooking mode) ===== */
.cooking-zerowaste-tip {
display: flex;
align-items: flex-start;
gap: 10px;
background: rgba(16, 185, 129, 0.10);
border: 1.5px solid rgba(16, 185, 129, 0.35);
border-radius: 12px;
padding: 12px 14px;
margin: 10px 16px 0;
position: relative;
animation: zwFadeIn 0.3s ease;
flex-direction: column;
}
@keyframes zwFadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.cooking-zerowaste-label {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #059669;
}
.cooking-zerowaste-scrap {
font-size: 0.85rem;
font-weight: 700;
color: #065f46;
margin-left: 4px;
}
.cooking-zerowaste-text {
font-size: 0.85rem;
color: var(--text);
margin: 4px 0 0;
line-height: 1.45;
}
.cooking-zerowaste-close {
position: absolute;
top: 8px;
right: 10px;
background: none;
border: none;
font-size: 0.85rem;
cursor: pointer;
color: #6b7280;
padding: 2px 4px;
line-height: 1;
}
.cooking-zerowaste-close:hover { color: #374151; }
[data-theme="dark"] .cooking-zerowaste-tip {
background: rgba(16, 185, 129, 0.08);
border-color: rgba(16, 185, 129, 0.25);
}
[data-theme="dark"] .cooking-zerowaste-scrap { color: #6ee7b7; }
[data-theme="dark"] .cooking-zerowaste-label { color: #34d399; }
[data-theme="dark"] .cooking-zerowaste-close { color: #9ca3af; }
/* ===== DARK MODE ===== */
[data-theme="dark"] {
--bg: #0f172a;
--bg-card: #1e293b;
--bg-dark: #020617;
--text: #e2e8f0;
--text-light: #94a3b8;
--text-muted: #64748b;
--text-secondary: #94a3b8;
--border: #334155;
--shadow: 0 2px 8px rgba(0,0,0,0.45);
--shadow-lg: 0 4px 16px rgba(0,0,0,0.6);
color-scheme: dark;
}
[data-theme="dark"] body {
background: var(--bg);
color: var(--text);
}
/* Bottom nav */
[data-theme="dark"] .bottom-nav {
box-shadow: 0 -2px 10px rgba(0,0,0,0.4);
}
/* Location tabs */
[data-theme="dark"] .tab {
background: var(--bg-card);
color: var(--text-light);
}
[data-theme="dark"] .tab.active {
background: var(--primary);
color: #fff;
}
/* Location selector (add/use modal) */
[data-theme="dark"] .location-option {
background: var(--bg-card);
border-color: var(--border);
color: var(--text);
}
[data-theme="dark"] .location-option.selected {
border-color: var(--primary);
background: rgba(45,80,22,0.3);
}
/* Inputs & selects */
[data-theme="dark"] .form-input,
[data-theme="dark"] .form-control,
[data-theme="dark"] input[type="text"],
[data-theme="dark"] input[type="email"],
[data-theme="dark"] input[type="password"],
[data-theme="dark"] input[type="number"],
[data-theme="dark"] textarea,
[data-theme="dark"] select {
background: var(--bg-card);
color: var(--text);
border-color: var(--border);
}
[data-theme="dark"] input::placeholder,
[data-theme="dark"] textarea::placeholder {
color: var(--text-muted);
}
/* Buttons */
[data-theme="dark"] .btn-secondary,
[data-theme="dark"] .btn-outline,
[data-theme="dark"] .back-btn,
[data-theme="dark"] .page-header-action-btn {
background: var(--bg-card);
color: var(--text);
border-color: var(--border);
}
[data-theme="dark"] .btn-outline {
color: var(--primary-light);
border-color: var(--primary-light);
}
/* Inventory items */
[data-theme="dark"] .inventory-item {
background: var(--bg-card);
}
[data-theme="dark"] .inv-location-badge {
background: rgba(45,80,22,0.35);
color: #86efac;
}
/* Shopping items */
[data-theme="dark"] .shopping-item {
background: var(--bg-card) !important;
}
[data-theme="dark"] .shopping-item-tag-menu-container {
background: var(--bg-card);
border-color: var(--border);
}
[data-theme="dark"] .shopping-item-tag-btn {
background: #1e293b;
color: var(--text-light);
border-color: var(--border);
}
[data-theme="dark"] .badge-local-tag {
background: #0c2a4e;
color: #7dd3fc;
}
[data-theme="dark"] .badge-freq-med {
background: #2e1a4a;
color: #c4b5fd;
}
[data-theme="dark"] .badge-freq-low {
background: #1e293b;
color: #94a3b8;
}
/* Settings rows */
[data-theme="dark"] .settings-row {
border-color: var(--border);
}
[data-theme="dark"] .settings-label {
color: var(--text);
}
[data-theme="dark"] .settings-hint {
color: var(--text-muted);
}
/* Toggle switch */
[data-theme="dark"] .toggle-slider {
background: #334155;
}
/* Search bar */
[data-theme="dark"] .search-bar input {
background: var(--bg-card);
color: var(--text);
border-color: var(--border);
}
/* Action modal location selector */
[data-theme="dark"] .action-location-btn {
background: var(--bg-card);
border-color: var(--border);
color: var(--text);
}
/* Scan page */
[data-theme="dark"] .scan-input-row {
background: var(--bg-card);
}
[data-theme="dark"] .scan-result-item {
background: var(--bg-card);
border-color: var(--border);
}
/* Quick access chips */
[data-theme="dark"] .quick-access-chip {
background: var(--bg-card);
border-color: var(--border);
color: var(--text);
}
/* Scan recents */
[data-theme="dark"] .scan-recent-chip {
background: var(--bg-card);
border-color: var(--border);
color: var(--text-light);
}
/* Alert banners */
[data-theme="dark"] .alert-banner {
background: #1e293b;
border-color: #334155;
}
[data-theme="dark"] .alert-banner.banner-expiring {
background: #1c1300;
border-color: #78350f;
}
[data-theme="dark"] .alert-banner.banner-expired {
background: #1f0808;
border-color: #7f1d1d;
}
[data-theme="dark"] .alert-banner.banner-finished {
background: #0f1f0f;
border-color: #166534;
}
[data-theme="dark"] .alert-banner.banner-anomaly {
background: #1a1a2e;
border-color: #4c1d95;
}
/* Recipe dialog */
[data-theme="dark"] .recipe-dialog-content {
background: var(--bg-card);
}
[data-theme="dark"] .recipe-option-btn {
background: var(--bg-card);
border-color: var(--border);
color: var(--text);
}
[data-theme="dark"] .recipe-option-btn.active {
background: rgba(45,80,22,0.4);
border-color: var(--primary-light);
color: var(--primary-light);
}
/* Log rows */
[data-theme="dark"] .log-item {
background: var(--bg-card);
border-color: var(--border);
}
/* Dashboard stat cards */
[data-theme="dark"] .stat-card {
background: var(--bg-card);
}
/* Screensaver */
[data-theme="dark"] .screensaver-overlay {
background: #020617;
}
/* Charts / nutrition */
[data-theme="dark"] .nutrition-chart-bg {
background: var(--bg-card);
}
/* AW badges */
[data-theme="dark"] .aw-badge-rate { background: #2e1a4a; color: #c4b5fd; border-color: #6d28d9; }
[data-theme="dark"] .aw-badge-money { background: #1c1300; color: #fde047; border-color: #78350f; }
[data-theme="dark"] .aw-badge-meals { background: #0f1f0f; color: #4ade80; border-color: #166534; }
[data-theme="dark"] .aw-badge-co2 { background: #0c1f3a; color: #7dd3fc; border-color: #1e3a5f; }
[data-theme="dark"] .aw-badge-wasted{ background: #1f0808; color: #fca5a5; border-color: #7f1d1d; }
[data-theme="dark"] .aw-badge-better{ background: #0f1f0f; color: #4ade80; border-color: #166534; }
/* Chat */
[data-theme="dark"] .chat-input {
background: var(--bg-card);
color: var(--text);
border-color: var(--border);
}
[data-theme="dark"] .chat-message.user {
background: var(--primary-dark);
}
[data-theme="dark"] .chat-message.bot {
background: var(--bg-card);
}
/* Smart shopping forecast */
[data-theme="dark"] .smart-item {
background: var(--bg-card);
border-color: var(--border);
}
[data-theme="dark"] .smart-filter-btn {
background: var(--bg-card);
color: var(--text-light);
border-color: var(--border);
}
[data-theme="dark"] .smart-filter-btn.active {
background: var(--primary);
color: #fff;
border-color: var(--primary);
}
/* Offline banner */
[data-theme="dark"] #offline-banner {
background: #450a0a;
border-color: #7f1d1d;
}
/* Setup wizard */
[data-theme="dark"] .setup-content {
background: var(--bg-card);
}
[data-theme="dark"] .setup-lang-btn {
background: var(--bg-card);
color: var(--text);
border-color: var(--border);
}
[data-theme="dark"] .setup-lang-btn.selected {
background: rgba(45,80,22,0.4);
border-color: var(--primary-light);
color: var(--primary-light);
}
/* @media prefers-color-scheme: auto handled in JS */
Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

+553 -107
View File
@@ -1043,9 +1043,19 @@ async function discoverScaleGateway() {
// ===== i18n TRANSLATION SYSTEM =====
let _i18nStrings = null; // current language translations (flat)
let _i18nFallback = null; // Italian fallback (flat)
let _currentLang = localStorage.getItem('evershelf_lang') || navigator.language?.slice(0, 2) || 'it';
const _SUPPORTED_LANGS = { it: 'Italiano', en: 'English', de: 'Deutsch' };
if (!_SUPPORTED_LANGS[_currentLang]) _currentLang = 'it';
let _currentLang = localStorage.getItem('evershelf_lang') || navigator.language?.slice(0, 2) || 'en';
const _SUPPORTED_LANGS = { it: 'Italiano', en: 'English', de: 'Deutsch', fr: 'Français', es: 'Español' };
if (!_SUPPORTED_LANGS[_currentLang]) _currentLang = 'en';
// Apply theme IMMEDIATELY to prevent flash of unstyled content
(function _earlyTheme() {
try {
const s = JSON.parse(localStorage.getItem('evershelf_settings') || '{}');
const mode = s.dark_mode || 'auto';
const dark = mode === 'on' || (mode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
} catch(e) {}
})();
// Flatten nested JSON: { a: { b: "x" } } → { "a.b": "x" }
function _flattenI18n(obj, prefix = '') {
@@ -1156,6 +1166,69 @@ function changeLanguage(lang) {
location.reload();
}
// ===== DARK MODE =====
function _applyTheme() {
const s = getSettings();
const mode = s.dark_mode || 'auto';
let isDark;
if (mode === 'on') {
isDark = true;
} else if (mode === 'off') {
isDark = false;
} else {
isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
}
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
}
function _setThemeMode(mode) {
const s = getSettings();
s.dark_mode = mode;
saveSettingsToStorage(s);
_applyTheme();
}
// Listen to system theme changes (for 'auto' mode)
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
const s = getSettings();
if ((s.dark_mode || 'auto') === 'auto') _applyTheme();
});
// ===== EXPORT INVENTORY =====
function exportInventory(format) {
const url = `api/index.php?action=export_inventory&format=${encodeURIComponent(format)}&_t=${Date.now()}`;
if (format === 'csv') {
// Direct download via <a> trick
const a = document.createElement('a');
a.href = url;
a.download = `evershelf-inventory-${new Date().toISOString().slice(0,10)}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
} else {
// Open print-ready HTML in new tab
window.open(url, '_blank', 'noopener');
}
}
function _showExportModal() {
const html = `
<div class="modal-header">
<h3>📤 ${t('export.title')}</h3>
<button class="modal-close" onclick="closeModal()"></button>
</div>
<div style="padding:16px;display:flex;flex-direction:column;gap:12px">
<p style="color:var(--text-light);font-size:0.9rem">${t('export.hint')}</p>
<button class="btn btn-primary full-width" onclick="exportInventory('csv');closeModal()">
📊 ${t('export.btn_csv')}
</button>
<button class="btn btn-outline full-width" onclick="exportInventory('html');closeModal()">
🖨 ${t('export.btn_pdf')}
</button>
</div>`;
openModal(html);
}
const LOCATIONS = {
'dispensa': { icon: '🗄️', label: t('locations.dispensa') },
'frigo': { icon: '🧊', label: t('locations.frigo') },
@@ -1833,24 +1906,20 @@ function switchScanTab(tab) {
}
}
// ===== SCAN RECENTS (localStorage) =====
const _SCAN_RECENTS_KEY = 'evershelf_scan_recents';
const _SCAN_RECENTS_MAX = 6;
function _getScanRecents() {
try { return JSON.parse(localStorage.getItem(_SCAN_RECENTS_KEY) || '[]'); } catch(_) { return []; }
}
// ===== SCAN HISTORY (server-synced via app_settings key "scan_history") =====
const _SCAN_HISTORY_MAX = 20;
function addToScanRecents(product) {
if (!product || !product.id) return;
let list = _getScanRecents().filter(r => r.id !== product.id);
list.unshift({ id: product.id, name: product.name, brand: product.brand || '', category: product.category || '' });
if (list.length > _SCAN_RECENTS_MAX) list = list.slice(0, _SCAN_RECENTS_MAX);
try { localStorage.setItem(_SCAN_RECENTS_KEY, JSON.stringify(list)); } catch(_) {}
let list = (_scanHistoryCache || []).filter(r => r.id !== product.id);
list.unshift({ id: product.id, barcode: product.barcode || '', name: product.name, brand: product.brand || '', category: product.category || '', ts: Date.now() });
if (list.length > _SCAN_HISTORY_MAX) list = list.slice(0, _SCAN_HISTORY_MAX);
_scanHistoryCache = list;
_saveToServer('scan_history', list);
}
function updateScanRecents() {
const list = _getScanRecents();
const list = (_scanHistoryCache || []).slice(0, 6);
const wrap = document.getElementById('scan-recents');
const chips = document.getElementById('scan-recents-chips');
if (!wrap || !chips) return;
@@ -2038,14 +2107,64 @@ async function syncSettingsFromDB() {
// Primary: load from server .env (only when not already done via _applySyncedSettings)
const serverSettings = await api('get_settings');
_applySyncedSettings(serverSettings);
// Also load review_confirmed from DB
// Load all server-persisted user data from SQLite app_settings
const res = await api('app_settings_get');
if (res.success && res.settings) {
if (res.settings.review_confirmed) {
_reviewConfirmedCache = res.settings.review_confirmed;
const srv = res.settings;
if (srv.review_confirmed) _reviewConfirmedCache = srv.review_confirmed;
// meal_plan is stored in SQLite app_settings so all devices stay in sync
if (srv.meal_plan) {
const s = getSettings();
s.meal_plan = srv.meal_plan;
_settingsCache = s;
localStorage.setItem('evershelf_settings', JSON.stringify(s));
if (document.getElementById('meal-plan-grid')) renderMealPlanEditor();
}
// tts_voice preference (best-effort cross-device — falls back if voice unavailable)
if (srv.tts_voice) {
const s = getSettings();
if (!s.tts_voice) { s.tts_voice = srv.tts_voice; _settingsCache = s; localStorage.setItem('evershelf_settings', JSON.stringify(s)); }
}
// ── User data previously stored in localStorage, now server-synced ──
if (srv.scan_history) _scanHistoryCache = srv.scan_history;
if (srv.shopping_tags) _shoppingTagsCache = srv.shopping_tags;
if (srv.pinned_bring) _pinnedBringCache = srv.pinned_bring;
if (srv.pref_use_loc) _prefUseLocCache = srv.pref_use_loc;
if (srv.pref_move_loc) _prefMoveLocCache = srv.pref_move_loc;
if (srv.auto_added_bring) _autoAddedBringCache = srv.auto_added_bring;
if (srv.bring_blocklist) _bringBlocklistCache = srv.bring_blocklist;
if (srv.no_expiry_dismissed) _noExpiryDismissedCache = srv.no_expiry_dismissed;
// ── One-time migration: if server has nothing yet, seed from old localStorage ──
if (!srv.shopping_tags) {
try { const v = localStorage.getItem('shopping_tags'); if (v) { _shoppingTagsCache = JSON.parse(v); _saveToServer('shopping_tags', _shoppingTagsCache); localStorage.removeItem('shopping_tags'); } } catch(_) {}
}
if (!srv.pinned_bring) {
try { const v = localStorage.getItem('_userPinnedBring'); if (v) { _pinnedBringCache = JSON.parse(v); _saveToServer('pinned_bring', _pinnedBringCache); localStorage.removeItem('_userPinnedBring'); } } catch(_) {}
}
if (!srv.pref_use_loc) {
try { const v = localStorage.getItem('_prefUseLoc'); if (v) { _prefUseLocCache = JSON.parse(v); _saveToServer('pref_use_loc', _prefUseLocCache); localStorage.removeItem('_prefUseLoc'); } } catch(_) {}
}
if (!srv.pref_move_loc) {
try { const v = localStorage.getItem('_prefMoveLoc'); if (v) { _prefMoveLocCache = JSON.parse(v); _saveToServer('pref_move_loc', _prefMoveLocCache); localStorage.removeItem('_prefMoveLoc'); } } catch(_) {}
}
if (!srv.auto_added_bring) {
try { const v = localStorage.getItem('_autoAddedBring'); if (v) { _autoAddedBringCache = JSON.parse(v); _saveToServer('auto_added_bring', _autoAddedBringCache); localStorage.removeItem('_autoAddedBring'); } } catch(_) {}
}
if (!srv.bring_blocklist) {
try { const v = localStorage.getItem('_bringPurchasedBlocklist'); if (v) { _bringBlocklistCache = JSON.parse(v); _saveToServer('bring_blocklist', _bringBlocklistCache); localStorage.removeItem('_bringPurchasedBlocklist'); } } catch(_) {}
}
if (!srv.no_expiry_dismissed) {
try { const v = localStorage.getItem('_noExpiryDismissed'); if (v) { _noExpiryDismissedCache = JSON.parse(v); _saveToServer('no_expiry_dismissed', _noExpiryDismissedCache); localStorage.removeItem('_noExpiryDismissed'); } } catch(_) {}
}
if (!srv.scan_history) {
try { const v = localStorage.getItem('evershelf_scan_recents'); if (v) { _scanHistoryCache = JSON.parse(v); _saveToServer('scan_history', _scanHistoryCache); localStorage.removeItem('evershelf_scan_recents'); } } catch(_) {}
}
}
} catch(e) { /* offline, use local */ }
} catch(e) { /* offline — in-memory caches stay at their defaults */ }
}
/**
@@ -2064,6 +2183,7 @@ function _applySyncedSettings(serverSettings) {
'camera_facing','scale_enabled','scale_gateway_url',
'meal_plan_enabled','tts_enabled','tts_url','tts_token',
'tts_method','tts_auth_type','tts_content_type','tts_payload_key',
'tts_engine','tts_rate','tts_pitch','tts_auth_header_name','tts_auth_header_value','tts_extra_fields',
'screensaver_enabled','screensaver_timeout',
'price_enabled','price_country','price_currency','price_update_months'];
let changed = false;
@@ -2415,6 +2535,13 @@ async function loadSettingsUI() {
if (nativePanel) nativePanel.style.display = '';
}
// Dark mode setting
const dmEl = document.getElementById('setting-dark-mode');
if (dmEl) dmEl.value = s.dark_mode || 'auto';
// Zero-waste tips setting
const zwEl = document.getElementById('setting-zerowaste-tips');
if (zwEl) zwEl.checked = s.zerowaste_tips_enabled === true;
// Populate About section version
_loadAboutSection();
}
@@ -2599,6 +2726,53 @@ function _injectKioskOverlay() {
headerLeft.appendChild(wrap);
}
const _APPLIANCE_KEY_MAP = {
'forno': 'settings.appliances.oven',
'oven': 'settings.appliances.oven',
'backofen': 'settings.appliances.oven',
'microonde': 'settings.appliances.microwave',
'microwave': 'settings.appliances.microwave',
'mikrowelle': 'settings.appliances.microwave',
'friggitrice ad aria': 'settings.appliances.air_fryer',
'air fryer': 'settings.appliances.air_fryer',
'heißluftfritteuse': 'settings.appliances.air_fryer',
'macchina del pane': 'settings.appliances.bread_maker',
'macchina pane': 'settings.appliances.bread_maker',
'bread maker': 'settings.appliances.bread_maker',
'bread machine': 'settings.appliances.bread_maker',
'brotbackmaschine': 'settings.appliances.bread_maker',
'brotbackautomat': 'settings.appliances.bread_maker',
'bimby/moulinex cookeo': 'settings.appliances.bimby',
'moulinex cookeo': 'settings.appliances.bimby',
'bimby/cookeo': 'settings.appliances.bimby',
'bimby': 'settings.appliances.bimby',
'thermomix': 'settings.appliances.bimby',
'thermomix/cookeo': 'settings.appliances.bimby',
'planetaria': 'settings.appliances.mixer',
'stand mixer': 'settings.appliances.mixer',
'küchenmaschine': 'settings.appliances.mixer',
'vaporiera': 'settings.appliances.steamer',
'steamer': 'settings.appliances.steamer',
'dampfgarer': 'settings.appliances.steamer',
'pentola a pressione': 'settings.appliances.pressure_cooker',
'pentola pressione': 'settings.appliances.pressure_cooker',
'pressure cooker': 'settings.appliances.pressure_cooker',
'schnellkochtopf': 'settings.appliances.pressure_cooker',
'tostapane': 'settings.appliances.toaster',
'toaster': 'settings.appliances.toaster',
'frullatore/mixer': 'settings.appliances.blender',
'frullatore': 'settings.appliances.blender',
'blender': 'settings.appliances.blender',
'mixer': 'settings.appliances.blender',
};
function _applianceDisplayName(name) {
const key = _APPLIANCE_KEY_MAP[name.toLowerCase().trim()];
if (!key) return name;
// Strip leading emoji/symbols from the translated button label (e.g. "🔥 Oven" → "Oven")
return t(key).replace(/^[^\p{L}]+/u, '').trim() || name;
}
function renderAppliances(appliances) {
const container = document.getElementById('appliances-list');
if (!appliances || appliances.length === 0) {
@@ -2607,8 +2781,8 @@ function renderAppliances(appliances) {
}
container.innerHTML = appliances.map((a, i) => `
<div class="appliance-item">
<span>🔌 ${escapeHtml(a)}</span>
<button class="appliance-remove" onclick="removeAppliance(${i})" title="Rimuovi"></button>
<span>🔌 ${escapeHtml(_applianceDisplayName(a))}</span>
<button class="appliance-remove" onclick="removeAppliance(${i})" title="${t('btn.delete')}"></button>
</div>
`).join('');
}
@@ -2657,7 +2831,7 @@ function addApplianceQuick(name) {
s.appliances.push(name);
saveSettingsToStorage(s);
renderAppliances(s.appliances);
showToast(`${name} aggiunto`, 'success');
showToast(t('toast.appliance_added'), 'success');
}
function removeAppliance(idx) {
@@ -2670,7 +2844,9 @@ function removeAppliance(idx) {
async function saveSettings() {
const s = getSettings();
s.gemini_key = document.getElementById('setting-gemini-key').value.trim();
// Only update gemini_key if user actually typed something; preserve existing key otherwise
const _newGeminiKey = document.getElementById('setting-gemini-key').value.trim();
if (_newGeminiKey) s.gemini_key = _newGeminiKey;
s.bring_email = document.getElementById('setting-bring-email').value.trim();
s.bring_password = document.getElementById('setting-bring-password').value.trim();
s.default_persons = parseInt(document.getElementById('setting-default-persons').value) || 1;
@@ -2688,6 +2864,12 @@ async function saveSettings() {
if (ssEl) s.screensaver_enabled = ssEl.checked;
const ssTimeoutEl = document.getElementById('setting-screensaver-timeout');
if (ssTimeoutEl) s.screensaver_timeout = parseInt(ssTimeoutEl.value, 10) || 5;
// Dark mode
const dmSaveEl = document.getElementById('setting-dark-mode');
if (dmSaveEl) { s.dark_mode = dmSaveEl.value; _applyTheme(); }
// Zero-waste tips
const zwSaveEl = document.getElementById('setting-zerowaste-tips');
if (zwSaveEl) s.zerowaste_tips_enabled = zwSaveEl.checked;
// Meal plan enabled toggle
const mpEnabledEl = document.getElementById('setting-meal-plan-enabled');
if (mpEnabledEl) s.meal_plan_enabled = mpEnabledEl.checked;
@@ -2765,6 +2947,12 @@ async function saveSettings() {
tts_auth_type: s.tts_auth_type,
tts_content_type: s.tts_content_type,
tts_payload_key: s.tts_payload_key,
tts_engine: s.tts_engine || '',
tts_rate: s.tts_rate || 1,
tts_pitch: s.tts_pitch || 1,
tts_auth_header_name: s.tts_auth_header_name || '',
tts_auth_header_value: s.tts_auth_header_value || '',
tts_extra_fields: s.tts_extra_fields || '',
price_enabled: s.price_enabled,
price_country: s.price_country,
price_currency: s.price_currency,
@@ -2790,6 +2978,21 @@ async function saveSettings() {
statusEl.style.display = 'block';
setTimeout(() => statusEl.style.display = 'none', 4000);
}
// Re-sync _geminiAvailable after save (key may have been set/confirmed on server)
try {
const refreshed = await api('get_settings');
if (refreshed && refreshed.gemini_key_set !== undefined) {
_geminiAvailable = !!(refreshed.gemini_key_set);
_updateGeminiButtonState();
}
} catch(e) {}
// Persist meal_plan and tts_voice to SQLite for cross-device sync
try {
const appData = {};
if (s.meal_plan) appData.meal_plan = s.meal_plan;
if (s.tts_voice) appData.tts_voice = s.tts_voice;
if (Object.keys(appData).length) await api('app_settings_save', {}, 'POST', { settings: appData });
} catch(e) {}
// Re-init screensaver watcher in case it was just enabled
initInactivityWatcher();
}
@@ -3802,6 +4005,20 @@ function getReviewConfirmed() {
return _reviewConfirmedCache || {};
}
let _reviewConfirmedCache = {};
// ===== SERVER-SYNCED APP DATA CACHES =====
// Loaded at startup from app_settings (SQLite). Reads are synchronous (from cache).
// Writes update cache + fire-and-forget to server via app_settings_save.
let _shoppingTagsCache = {};
let _pinnedBringCache = {};
let _prefUseLocCache = {};
let _prefMoveLocCache = {};
let _autoAddedBringCache = {};
let _bringBlocklistCache = {};
let _noExpiryDismissedCache = {};
let _scanHistoryCache = [];
function _saveToServer(key, value) {
api('app_settings_save', {}, 'POST', { settings: { [key]: value } }).catch(() => {});
}
function setReviewConfirmed(inventoryId) {
const c = getReviewConfirmed();
@@ -3812,13 +4029,14 @@ function setReviewConfirmed(inventoryId) {
/** Return map of product IDs the user has marked as "no expiry needed". */
function _getNoExpiryDismissed() {
try { return JSON.parse(localStorage.getItem('_noExpiryDismissed') || '{}'); } catch { return {}; }
return _noExpiryDismissedCache || {};
}
/** Permanently mark a product as "no expiry needed" for this browser. */
function _dismissNoExpiry(productId) {
const m = _getNoExpiryDismissed();
const m = Object.assign({}, _noExpiryDismissedCache || {});
m[String(productId)] = Date.now();
localStorage.setItem('_noExpiryDismissed', JSON.stringify(m));
_noExpiryDismissedCache = m;
_saveToServer('no_expiry_dismissed', m);
}
// === ALERT BANNER SYSTEM (replaces old review table) ===
@@ -3842,11 +4060,12 @@ async function loadBannerAlerts() {
if (!banner) { _bannerLoading = false; console.warn('[Banner] #alert-banner not found'); return; }
try {
const [invData, predData, anomalyData, finishedData] = await Promise.all([
const [invData, predData, anomalyData, finishedData, statsData] = await Promise.all([
api('inventory_list'),
api('consumption_predictions').catch(err => { console.warn('[Banner] predictions fetch failed:', err); return { predictions: [] }; }),
api('inventory_anomalies').catch(err => { console.warn('[Banner] anomalies fetch failed:', err); return { anomalies: [] }; }),
api('inventory_finished_items').catch(err => { console.warn('[Banner] finished_items fetch failed:', err); return { finished: [] }; }),
api('stats').catch(() => ({ opened: [] })),
]);
const items = invData.inventory || [];
const confirmed = getReviewConfirmed();
@@ -3887,6 +4106,18 @@ async function loadBannerAlerts() {
_queuedItemIds.add(item.id);
});
// 1b. Opened items the SERVER considers not edible (is_edible=false from stats).
// The client-side getExpiredSafety check above uses conservative thresholds (e.g.
// conserve are 'ok' for 30 days past), but the server uses product-specific AI shelf
// life. Trust the server: any opened item with is_edible=false that isn't already
// queued goes into the banner as expired.
const openedNotEdible = (statsData.opened || []).filter(oi => !oi.is_edible && !_queuedItemIds.has(oi.id) && !confirmed['exp_' + oi.id]);
openedNotEdible.forEach(oi => {
const daysOI = Math.abs(oi.days_to_expiry ?? 0);
_bannerQueue.push({ type: 'expired', data: { ...oi, days_expired: daysOI } });
_queuedItemIds.add(oi.id);
});
// 2. Suspicious quantities ("expiring soon" shown only in dashboard sections, not in banner)
// Group items by product identity to detect sibling entries in other locations.
// A "low quantity" alert is suppressed when other stock of the same product exists
@@ -7904,33 +8135,28 @@ function selectUseLocation(btn, loc) {
// ── PREFERRED USE LOCATION ───────────────────────────────────────────────
// After 3+ consistent choices from the same location for a product,
// auto-selects it and hides the location picker (user can still tap "cambia").
const _PREF_LOC_KEY = '_prefUseLoc';
const _PREF_LOC_NEEDED = 2; // choices needed to confirm a preference
// ── PREFERRED MOVE-AFTER-USE LOCATION ────────────────────────────────────
// Tracks where the user puts the remainder after using a product.
// After _PREF_MOVE_NEEDED consistent choices, the modal is skipped entirely.
const _PREF_MOVE_KEY = '_prefMoveLoc';
const _PREF_MOVE_NEEDED = 2;
let _pendingMoveCtx = null; // { productId, fromLoc, openedId } — set before showing modal
function _getMoveLocHistory(productId, fromLoc) {
try {
const all = JSON.parse(localStorage.getItem(_PREF_MOVE_KEY) || '{}');
return all[`${productId}|${fromLoc}`] || [];
} catch { return []; }
const all = _prefMoveLocCache || {};
return all[`${productId}|${fromLoc}`] || [];
}
function _recordMoveLocChoice(productId, fromLoc, toLoc) {
try {
const all = JSON.parse(localStorage.getItem(_PREF_MOVE_KEY) || '{}');
const key = `${productId}|${fromLoc}`;
const hist = all[key] || [];
hist.push(toLoc);
if (hist.length > 8) hist.splice(0, hist.length - 8);
all[key] = hist;
localStorage.setItem(_PREF_MOVE_KEY, JSON.stringify(all));
} catch { }
const all = Object.assign({}, _prefMoveLocCache || {});
const key = `${productId}|${fromLoc}`;
const hist = (all[key] || []).slice();
hist.push(toLoc);
if (hist.length > 8) hist.splice(0, hist.length - 8);
all[key] = hist;
_prefMoveLocCache = all;
_saveToServer('pref_move_loc', all);
}
function _getPreferredMoveLoc(productId, fromLoc) {
@@ -7944,22 +8170,19 @@ function _getPreferredMoveLoc(productId, fromLoc) {
}
function _getPrefLocHistory(productId) {
try {
const all = JSON.parse(localStorage.getItem(_PREF_LOC_KEY) || '{}');
return all[String(productId)] || [];
} catch { return []; }
const all = _prefUseLocCache || {};
return all[String(productId)] || [];
}
function _recordUseLocationChoice(productId, loc) {
try {
const all = JSON.parse(localStorage.getItem(_PREF_LOC_KEY) || '{}');
const key = String(productId);
const hist = all[key] || [];
hist.push(loc);
if (hist.length > 8) hist.splice(0, hist.length - 8); // keep last 8
all[key] = hist;
localStorage.setItem(_PREF_LOC_KEY, JSON.stringify(all));
} catch { }
const all = Object.assign({}, _prefUseLocCache || {});
const key = String(productId);
const hist = (all[key] || []).slice();
hist.push(loc);
if (hist.length > 8) hist.splice(0, hist.length - 8);
all[key] = hist;
_prefUseLocCache = all;
_saveToServer('pref_use_loc', all);
}
function _getPreferredUseLocation(productId) {
@@ -8235,9 +8458,10 @@ async function addLowStockToBring() {
const data = await api('bring_add', {}, 'POST', payload);
if (data.success && data.added > 0) {
// Pin as user-added so cleanup never auto-removes it
const pinned = JSON.parse(localStorage.getItem('_userPinnedBring') || '{}');
const pinned = Object.assign({}, _pinnedBringCache || {});
pinned[bringName.toLowerCase()] = Date.now();
localStorage.setItem('_userPinnedBring', JSON.stringify(pinned));
_pinnedBringCache = pinned;
_saveToServer('pinned_bring', pinned);
showToast(t('shopping.added_to_bring').replace('{n}', data.added), 'success');
} else if (data.success && data.skipped > 0) {
showToast(t('shopping.already_in_list_short'), 'info');
@@ -9212,27 +9436,26 @@ function updateShoppingTabCounts() {
document.getElementById('shopping-tabs')?.style.setProperty('display', 'flex');
}
// ===== LOCAL SHOPPING TAGS =====
// ===== LOCAL SHOPPING TAGS (server-synced) =====
function getShoppingTags(itemName) {
try {
const tags = JSON.parse(localStorage.getItem('shopping_tags') || '{}');
return tags[itemName.toLowerCase()] || [];
} catch { return []; }
const tags = _shoppingTagsCache || {};
return tags[itemName.toLowerCase()] || [];
}
function toggleShoppingTag(itemIdx, tag) {
const item = shoppingItems[itemIdx];
if (!item) return;
const key = item.name.toLowerCase();
try {
const tags = JSON.parse(localStorage.getItem('shopping_tags') || '{}');
const existing = tags[key] || [];
const key = item.name.toLowerCase();
const tags = Object.assign({}, _shoppingTagsCache || {});
const existing = (tags[key] || []).slice();
const pos = existing.indexOf(tag);
if (pos >= 0) existing.splice(pos, 1);
else existing.push(tag);
if (existing.length) tags[key] = existing;
else delete tags[key];
localStorage.setItem('shopping_tags', JSON.stringify(tags));
_shoppingTagsCache = tags;
_saveToServer('shopping_tags', tags);
// Sync urgente/presto tag to Bring specification so it's visible in the Bring app
if (tag === 'urgente' && shoppingListUUID) {
@@ -9294,54 +9517,57 @@ function _urgencyToSpec(urgency, brand) {
* function only ever removes those, never manually-added ones.
*/
function _getAutoAddedBring() {
try {
const raw = localStorage.getItem('_autoAddedBring');
const map = raw ? JSON.parse(raw) : {};
const now = Date.now();
let changed = false;
for (const k of Object.keys(map)) {
if (now - map[k] > 30 * 24 * 60 * 60 * 1000) { delete map[k]; changed = true; }
}
if (changed) localStorage.setItem('_autoAddedBring', JSON.stringify(map));
return map;
} catch(e) { return {}; }
const map = Object.assign({}, _autoAddedBringCache || {});
const now = Date.now();
let changed = false;
for (const k of Object.keys(map)) {
if (now - map[k] > 30 * 24 * 60 * 60 * 1000) { delete map[k]; changed = true; }
}
if (changed) {
_autoAddedBringCache = map;
_saveToServer('auto_added_bring', map);
}
return map;
}
function _markAutoAddedBring(names) {
const map = _getAutoAddedBring();
const now = Date.now();
for (const n of names) map[n.toLowerCase()] = now;
localStorage.setItem('_autoAddedBring', JSON.stringify(map));
_autoAddedBringCache = map;
_saveToServer('auto_added_bring', map);
}
function _unmarkAutoAddedBring(names) {
const map = _getAutoAddedBring();
for (const n of names) delete map[n.toLowerCase()];
localStorage.setItem('_autoAddedBring', JSON.stringify(map));
_autoAddedBringCache = map;
_saveToServer('auto_added_bring', map);
}
// ===== BRING! PURCHASED BLOCKLIST =====
// ===== BRING! PURCHASED BLOCKLIST (server-synced) =====
// When an item disappears from Bring (user bought it), we block auto-re-add for 4h.
const _BRING_PURCHASED_TTL = 4 * 60 * 60 * 1000; // 4 hours
function _getBringPurchasedBlocklist() {
try {
const raw = localStorage.getItem('_bringPurchasedBlocklist');
const map = raw ? JSON.parse(raw) : {};
const now = Date.now();
// Prune expired entries
let changed = false;
for (const key of Object.keys(map)) {
if (now - map[key] > _BRING_PURCHASED_TTL) { delete map[key]; changed = true; }
}
if (changed) localStorage.setItem('_bringPurchasedBlocklist', JSON.stringify(map));
return map;
} catch(e) { return {}; }
const map = Object.assign({}, _bringBlocklistCache || {});
const now = Date.now();
// Prune expired entries
let changed = false;
for (const key of Object.keys(map)) {
if (now - map[key] > _BRING_PURCHASED_TTL) { delete map[key]; changed = true; }
}
if (changed) {
_bringBlocklistCache = map;
_saveToServer('bring_blocklist', map);
}
return map;
}
function _markBringPurchased(names) {
const map = _getBringPurchasedBlocklist();
const now = Date.now();
for (const n of names) map[n.toLowerCase()] = now;
localStorage.setItem('_bringPurchasedBlocklist', JSON.stringify(map));
_bringBlocklistCache = map;
_saveToServer('bring_blocklist', map);
}
function _isBringPurchased(name, urgency) {
@@ -9404,10 +9630,10 @@ async function forceSyncBring() {
if (btn) { btn.disabled = true; btn.textContent = `${t('shopping.syncing')}`; }
// Clear auto-add/cleanup guards so the next run is unconditional.
// Do NOT clear _userPinnedBring — items the user manually added must stay protected.
localStorage.removeItem('_bringPurchasedBlocklist');
_bringBlocklistCache = {}; _saveToServer('bring_blocklist', {});
localStorage.removeItem('_autoAddedCriticalTs');
localStorage.removeItem('_bringCleanupTs');
localStorage.removeItem('_autoAddedBring');
_autoAddedBringCache = {}; _saveToServer('auto_added_bring', {});
logOperation('force_sync_bring', {});
// Reload everything from scratch
await loadShoppingList();
@@ -9657,10 +9883,10 @@ async function fetchAllPrices(forceRefresh = false) {
if (btn) { btn.disabled = true; btn.textContent = `${t('shopping.syncing')}`; }
// Clear auto-add/cleanup guards so the next run is unconditional.
// Do NOT clear _userPinnedBring — items the user manually added must stay protected.
localStorage.removeItem('_bringPurchasedBlocklist');
_bringBlocklistCache = {}; _saveToServer('bring_blocklist', {});
localStorage.removeItem('_autoAddedCriticalTs');
localStorage.removeItem('_bringCleanupTs');
localStorage.removeItem('_autoAddedBring');
_autoAddedBringCache = {}; _saveToServer('auto_added_bring', {});
logOperation('force_sync_bring', {});
// Reload everything from scratch
await loadShoppingList();
@@ -10216,10 +10442,11 @@ async function addSmartToBring() {
showToast(msg, result.added > 0 ? 'success' : 'info');
// Mark all manually-added items as user-pinned so cleanupObsoleteBringItems never removes them
if (result.added > 0) {
const pinned = JSON.parse(localStorage.getItem('_userPinnedBring') || '{}');
const pinned = Object.assign({}, _pinnedBringCache || {});
const now = Date.now();
for (const it of itemsToAdd) pinned[it.name.toLowerCase()] = now;
localStorage.setItem('_userPinnedBring', JSON.stringify(pinned));
_pinnedBringCache = pinned;
_saveToServer('pinned_bring', pinned);
}
// Reload to refresh badges
loadShoppingList();
@@ -10272,12 +10499,12 @@ async function loadShoppingCount() {
*/
function _syncTagsFromBringSpec() {
try {
const tags = JSON.parse(localStorage.getItem('shopping_tags') || '{}');
const tags = Object.assign({}, _shoppingTagsCache || {});
let changed = false;
for (const item of shoppingItems) {
const key = item.name.toLowerCase();
const spec = (item.specification || '').toLowerCase();
const existing = tags[key] || [];
const existing = (tags[key] || []).slice();
const hasUrgente = existing.includes('urgente');
const smartMatch = _matchBringToSmart(item.name, smartShoppingItems);
const smartIsCritical = smartMatch && (smartMatch.urgency === 'critical' || smartMatch.urgency === 'high');
@@ -10292,7 +10519,10 @@ function _syncTagsFromBringSpec() {
changed = true;
}
}
if (changed) localStorage.setItem('shopping_tags', JSON.stringify(tags));
if (changed) {
_shoppingTagsCache = tags;
_saveToServer('shopping_tags', tags);
}
} catch (e) { /* ignore */ }
}
@@ -11413,6 +11643,8 @@ function selectMealPlanType(dow, slot, typeId) {
saveSettingsToStorage(s);
closeMealPlanPicker();
renderMealPlanEditor();
// Persist to server for cross-device sync
api('app_settings_save', {}, 'POST', { settings: { meal_plan: s.meal_plan } }).catch(() => {});
}
function resetMealPlan() {
const s = getSettings();
@@ -11420,6 +11652,7 @@ function resetMealPlan() {
saveSettingsToStorage(s);
renderMealPlanEditor();
showToast(t('meal_plan.reset_success'), 'success');
api('app_settings_save', {}, 'POST', { settings: { meal_plan: s.meal_plan } }).catch(() => {});
}
// ===== RECIPE GENERATION =====
@@ -12202,6 +12435,7 @@ function startCookingMode() {
_cookingRecipe = JSON.parse(JSON.stringify(recipe));
_cookingStep = 0;
_cookingVisited = new Set();
_dismissedZeroWasteTips = new Set();
clearAllCookingTimers();
}
_cookingTTS = true;
@@ -12251,6 +12485,7 @@ function restartCookingMode() {
_cookingStep = 0;
_cookingWheelLastDelta = 0;
_cookingVisited = new Set();
_dismissedZeroWasteTips = new Set();
clearAllCookingTimers();
renderCookingStep();
}
@@ -12472,10 +12707,42 @@ function renderCookingStep() {
// Timer: detect duration in step text and show suggestion
setupCookingTimerSuggestion(cleanStep);
// Zero-waste tip for this step
_renderZeroWasteTip(_cookingStep);
// TTS: auto-speak is handled by navigateCookingStep() and startCookingMode() callers.
// Use replayCookingTTS() to re-read the current step manually ("Rileggi" button).
}
// ===== ZERO-WASTE TIPS =====
let _dismissedZeroWasteTips = new Set(); // dismissed tip indices for this cooking session
function _renderZeroWasteTip(stepIdx) {
const tipEl = document.getElementById('cooking-zerowaste-tip');
if (!tipEl) return;
// Check setting
const s = getSettings();
if (!s.zerowaste_tips_enabled) { tipEl.style.display = 'none'; return; }
// Already dismissed for this step in this session
if (_dismissedZeroWasteTips.has(stepIdx)) { tipEl.style.display = 'none'; return; }
// Find tip for current step
const tips = (_cookingRecipe && _cookingRecipe.zero_waste_tips) || [];
const tip = tips.find(t => t.step === stepIdx);
if (!tip) { tipEl.style.display = 'none'; return; }
// Populate and show
const scrapEl = document.getElementById('cooking-zerowaste-scrap');
const textEl = document.getElementById('cooking-zerowaste-text');
if (scrapEl) scrapEl.textContent = tip.scrap || '';
if (textEl) textEl.textContent = tip.tip || '';
tipEl.style.display = 'flex';
}
function _dismissZeroWasteTip() {
_dismissedZeroWasteTips.add(_cookingStep);
const tipEl = document.getElementById('cooking-zerowaste-tip');
if (tipEl) tipEl.style.display = 'none';
}
function _buildTtsRequest(text, s) {
const url = s.tts_url || '';
const method = s.tts_method || 'POST';
@@ -12576,7 +12843,10 @@ function _initBrowserTtsVoices(selectedVoice) {
const populate = () => {
let voices = [];
try {
voices = (window.speechSynthesis.getVoices() || []).filter(v => v != null && v.lang);
voices = (window.speechSynthesis.getVoices() || []).filter(v => {
try { return v != null && typeof v.lang === 'string' && v.lang.length > 0; }
catch (_) { return false; }
});
} catch (_) { return false; }
if (!voices.length) return false;
// Italian voices first, then others
@@ -14186,6 +14456,8 @@ function initSpesaMode() {
if (!btn) return;
btn.addEventListener('pointerdown', (e) => {
e.preventDefault(); // prevent browser-generated synthetic click + 300ms delay
btn.setPointerCapture(e.pointerId); // ensure pointerup always fires on this element even if finger drifts
_longPressTimer = setTimeout(() => {
_longPressTimer = null;
startSpesaMode();
@@ -14199,12 +14471,14 @@ function initSpesaMode() {
showPage('scan');
}
});
btn.addEventListener('pointerleave', () => {
btn.addEventListener('pointercancel', () => {
// OS cancelled gesture (e.g. home swipe) — discard timer, do nothing
if (_longPressTimer) {
clearTimeout(_longPressTimer);
_longPressTimer = null;
}
});
// Note: no pointerleave handler needed — setPointerCapture prevents it from firing during touch
}
function startSpesaMode() {
@@ -14412,9 +14686,11 @@ function _setupSteps() {
`
},
{
title: '✅ ' + (_currentLang === 'it' ? 'Tutto pronto!' : _currentLang === 'de' ? 'Alles bereit!' : 'All set!'),
title: '✅ ' + (_currentLang === 'it' ? 'Tutto pronto!' : _currentLang === 'de' ? 'Alles bereit!' : _currentLang === 'fr' ? 'Tout est prêt !' : _currentLang === 'es' ? '¡Todo listo!' : 'All set!'),
desc: _currentLang === 'it' ? 'La configurazione è completata. Puoi sempre modificare queste impostazioni dalla pagina Configurazione.'
: _currentLang === 'de' ? 'Die Konfiguration ist abgeschlossen. Du kannst diese Einstellungen jederzeit ändern.'
: _currentLang === 'fr' ? 'La configuration est terminée. Vous pouvez toujours modifier ces paramètres depuis la page Paramètres.'
: _currentLang === 'es' ? 'La configuración está completa. Puedes cambiar estos ajustes desde la página Ajustes.'
: 'Setup is complete. You can always change these settings from the Settings page.',
render: () => {
let summary = '<div style="text-align:center;font-size:2.5rem;margin:12px 0">🎉</div>';
@@ -14464,9 +14740,9 @@ function _renderSetupStep() {
prevBtn.textContent = t('btn.back');
if (_setupStep === totalPending - 1) {
nextBtn.textContent = _currentLang === 'it' ? '🚀 Inizia!' : _currentLang === 'de' ? '🚀 Los geht\'s!' : '🚀 Start!';
nextBtn.textContent = _currentLang === 'it' ? '🚀 Inizia!' : _currentLang === 'de' ? '🚀 Los geht\'s!' : _currentLang === 'fr' ? '🚀 Allons-y !' : _currentLang === 'es' ? '🚀 ¡Empezar!' : '🚀 Start!';
} else {
nextBtn.textContent = _currentLang === 'it' ? 'Avanti →' : _currentLang === 'de' ? 'Weiter →' : 'Next →';
nextBtn.textContent = _currentLang === 'it' ? 'Avanti →' : _currentLang === 'de' ? 'Weiter →' : _currentLang === 'fr' ? 'Suivant →' : _currentLang === 'es' ? 'Siguiente →' : 'Next →';
}
}
@@ -14597,12 +14873,182 @@ function _heartbeatRetry() {
_runHeartbeat();
}
// ── Startup / Splash health check ────────────────────────────────────────────
/**
* Run a comprehensive server-side diagnostic during the splash screen.
* Shows a real-time progress bar + current check label.
* Returns true if the app can proceed, false if a critical check failed.
*/
async function _runStartupCheck() {
const spinnerEl = document.getElementById('preloader-spinner');
const wrapEl = document.getElementById('preloader-progress-wrap');
const barEl = document.getElementById('preloader-bar');
const labelEl = document.getElementById('preloader-check-label');
const warningsEl = document.getElementById('preloader-warnings');
const errorEl = document.getElementById('preloader-error-msg');
const retryBtn = document.getElementById('preloader-retry-btn');
if (!wrapEl) return true; // preloader already removed
const tl = (key, fallback) => { try { return t('startup.' + key); } catch(e) { return fallback; } };
// Switch from spinner to progress bar
if (spinnerEl) spinnerEl.style.display = 'none';
wrapEl.style.display = '';
// Helper: set progress bar + label
let _curPct = 0;
const setProgress = (pct, label, state) => {
_curPct = pct;
if (barEl) {
barEl.style.width = pct + '%';
barEl.className = 'preloader-bar' + (state === 'error' ? ' bar-error' : state === 'warn' ? ' bar-warn' : '');
}
if (labelEl) labelEl.textContent = label || '';
};
// Phase 1: animate 0→15% while fetching (so it never looks stuck)
setProgress(0, tl('connecting', 'Connessione al server...'));
let _fetchDone = false;
const slowAnim = setInterval(() => {
if (!_fetchDone && _curPct < 13) setProgress(_curPct + 1, labelEl?.textContent);
}, 100);
// Make the request
let result = null;
try {
const ctrl = new AbortController();
const tid = setTimeout(() => ctrl.abort(), 12000);
const resp = await fetch('api/index.php?action=health_check', { signal: ctrl.signal });
clearTimeout(tid);
result = await resp.json();
} catch(e) {
clearInterval(slowAnim);
setProgress(100, tl('error_network', 'Impossibile contattare il server'), 'error');
errorEl.textContent = tl('error_network', 'Impossibile contattare il server. Controlla la connessione di rete.');
errorEl.style.display = '';
retryBtn.style.display = '';
return false;
}
clearInterval(slowAnim);
_fetchDone = true;
// ── Ordered check definitions (must match PHP keys) ───────────────────────
// { key, label, critical }
const CHECKS = [
// PHP runtime
{ key: 'php_version', label: 'PHP', critical: true },
{ key: 'ext_pdo_sqlite', label: 'PDO SQLite', critical: true },
{ key: 'ext_curl', label: 'cURL', critical: true },
{ key: 'ext_json', label: 'JSON', critical: true },
{ key: 'ext_mbstring', label: 'mbstring', critical: true },
{ key: 'ext_openssl', label: 'OpenSSL', critical: false },
{ key: 'ext_fileinfo', label: 'Fileinfo', critical: false },
{ key: 'ext_zip', label: 'ZIP', critical: false },
{ key: 'ext_intl', label: 'Intl', critical: false },
{ key: 'php_memory', label: tl('check_php_memory', 'Memoria PHP'), critical: false },
{ key: 'php_max_exec', label: tl('check_php_timeout', 'Timeout PHP'), critical: false },
{ key: 'php_upload', label: tl('check_php_upload', 'Upload PHP'), critical: false },
// Filesystem
{ key: 'data_dir', label: tl('check_data_dir', 'Cartella dati'), critical: true },
{ key: 'data_rate_limits', label: tl('check_rate_limits', 'Rate limits dir'),critical: false },
{ key: 'data_backups', label: tl('check_backups', 'Backup dir'), critical: false },
{ key: 'data_write_test', label: tl('check_write_test', 'Test scrittura'), critical: true },
{ key: 'disk_space', label: tl('check_disk_space', 'Spazio disco'), critical: false },
// Database
{ key: 'db_connect', label: tl('check_db_connect', 'Connessione DB'), critical: true },
{ key: 'db_tables', label: tl('check_db_tables', 'Tabelle DB'), critical: true },
{ key: 'db_integrity', label: tl('check_db_integrity','Integrità DB'), critical: true },
{ key: 'db_wal', label: tl('check_db_wal', 'WAL mode'), critical: false },
{ key: 'db_size', label: tl('check_db_size', 'Dimensione DB'), critical: false },
{ key: 'db_row_count', label: tl('check_db_rows', 'Dati inventario'),critical: false },
// Config
{ key: 'env_file', label: tl('check_env', 'File .env'), critical: false },
{ key: 'gemini_key', label: tl('check_gemini', 'Gemini AI key'), critical: false },
{ key: 'bring_credentials', label: tl('check_bring_creds', 'Bring! credenziali'), critical: false },
{ key: 'bring_token', label: tl('check_bring_token', 'Bring! token'), critical: false },
// Network
{ key: 'curl_ssl', label: tl('check_curl_ssl', 'cURL SSL'), critical: false },
{ key: 'internet', label: tl('check_internet', 'Internet'), critical: false },
];
const checks = result.checks || {};
const warnings = [];
const errors = [];
const total = CHECKS.filter(d => checks[d.key] !== undefined).length;
let done = 0;
// Phase 2: step through each check with real-time label
for (const def of CHECKS) {
const c = checks[def.key];
if (c === undefined) continue; // not returned by server
done++;
const pct = 15 + Math.round((done / total) * 83); // 15→98%
const isOk = c.ok === true;
const isOpt = c.optional === true || !def.critical;
const isFresh = c.fresh === true;
// Build label: "check name (extra value)"
let lbl = def.label;
if (c.value) lbl += ` (${c.value})`;
if (isFresh) lbl += `${tl('fresh_install', 'nuovo impianto')}`;
if (!isOk && c.error) lbl += `${c.error}`;
if (!isOk && c.missing?.length) lbl += ` — mancanti: ${c.missing.join(', ')}`;
const icon = isOk ? '✅' : isOpt ? '⚠️' : '❌';
setProgress(pct, `${icon} ${lbl}`);
if (!isOk && !isFresh) {
(isOpt ? warnings : errors).push({ def, c });
}
await new Promise(r => setTimeout(r, 45)); // ~45ms per step → ~1.3s total
}
// ── Completed ─────────────────────────────────────────────────────────────
if (errors.length > 0) {
setProgress(100, `${tl('critical_error_short', 'Errore critico')}`, 'error');
const errDetail = errors.map(e => e.def.label + (e.c.error ? `: ${e.c.error}` : '')).join('\n');
errorEl.textContent = `${tl('critical_error', 'Errore critico: l\'app non può avviarsi.')}${errDetail ? '\n' + errDetail : ''}`;
errorEl.style.display = '';
retryBtn.style.display = '';
return false;
}
// Warnings only
if (warnings.length > 0) {
setProgress(100, `⚠️ ${warnings.length} ${tl('warnings_found', 'avvisi rilevati')}`, 'warn');
warningsEl.innerHTML = warnings
.map(w => `<span class="preloader-warn-badge">⚠️ ${w.def.label}</span>`)
.join('');
warningsEl.style.display = '';
await new Promise(r => setTimeout(r, 2200)); // show warnings for 2.2s
warningsEl.style.display = 'none';
} else {
setProgress(100, `${tl('all_ok', 'Sistema OK')}`);
await new Promise(r => setTimeout(r, 700));
}
wrapEl.style.display = 'none';
return true;
}
/** Retry button handler in the startup error screen. */
function _startupRetry() {
location.reload();
}
/** Start the heartbeat loop (called once from _initApp). */
function startHeartbeat() {
_runHeartbeat(); // immediate first probe
}
async function _initApp() {
// ── Startup health check (runs during splash, blocks app if critical) ──────
const _startupOk = await _runStartupCheck();
if (!_startupOk) return; // preloader stays visible with error; app does not start
// Check for setup wizard resume (after language change)
const resumeStep = localStorage.getItem('evershelf_setup_step');
const resumeData = localStorage.getItem('evershelf_setup_data');
-170
View File
@@ -1,170 +0,0 @@
{
"226887def70e33ef73290ebfe75ed4d0": {
"days": 7,
"source": "ai",
"name": "Polpa di pomodoro finissima",
"location": "frigo",
"ts": 1777444819
},
"0ed51c9496aa9edfe38caf41772f54ed": {
"days": 7,
"source": "rule",
"name": "Latte di Montagna",
"location": "frigo",
"ts": 1777444820
},
"2d63d0216a75d46b465150e925d2e7ad": {
"days": 30,
"source": "rule",
"name": "Burro",
"location": "frigo",
"ts": 1777444821
},
"9afdf35c4a256867ef47c32495349eb6": {
"days": 5,
"source": "rule",
"name": "Yaourt Vanille",
"location": "frigo",
"ts": 1777480477
},
"584f57418733a1f2acd29fe2e8816129": {
"days": 5,
"source": "rule",
"name": "Passata di pomodoro",
"location": "frigo",
"ts": 1778133522
},
"baeb7f2021b4bb91c368c9131a61f07c": {
"days": 10,
"source": "rule",
"name": "Formaggio Monte Maria",
"location": "frigo",
"ts": 1778133523
},
"063f2d534407214786d039bb2bffbb93": {
"days": 5,
"source": "rule",
"name": "Carote",
"location": "frigo",
"ts": 1778133524
},
"10a3d07c19bb1f889ebc9293862b4b36": {
"days": 60,
"source": "rule",
"name": "Ovomaltine",
"location": "dispensa",
"ts": 1778419084
},
"0fbad7ccd8b6155c06aaa6b3c17a67d3": {
"days": 365,
"source": "rule",
"name": "Linguine pasta di Gragnano Igp",
"location": "dispensa",
"ts": 1778419084
},
"b4a03e7356e7a0983b9c8af5f3cd8c57": {
"days": 60,
"source": "rule",
"name": "Polpa di pomodoro finissima",
"location": "dispensa",
"ts": 1778419085
},
"b8334ff0febd5c0440c9b24c9f3132ed": {
"days": 180,
"source": "rule",
"name": "Basilico tritato surgelato",
"location": "freezer",
"ts": 1778419086
},
"0cb14384d0ba763ccf12e079d6aa8d34": {
"days": 60,
"source": "rule",
"name": "Salsa Pronta Ciliegini",
"location": "dispensa",
"ts": 1778419086
},
"188634f49edb8b014a46942ee9fad689": {
"days": 180,
"source": "rule",
"name": "Farina Barilla",
"location": "dispensa",
"ts": 1778419204
},
"c8db359d8709c69a95f0e6f68216d220": {
"days": 9999,
"source": "rule",
"name": "Bicarbonato",
"location": "dispensa",
"ts": 1778419205
},
"a6d16a09fd9a6bfbd0a915f05dd71780": {
"days": 7,
"source": "ai",
"name": "Salsa Pronta Ciliegini",
"location": "frigo",
"ts": 1778419205
},
"4f8f1bb04a00e5fc62d7a9cfb21e1796": {
"days": 365,
"source": "rule",
"name": "Riso Chicchi Ricchi Gran Risparmio",
"location": "dispensa",
"ts": 1778419206
},
"e116e4c11084a463f9aaac02e1749fe7": {
"days": 90,
"source": "rule",
"name": "Salsa di soia",
"location": "dispensa",
"ts": 1778419207
},
"b1ad9afd4139b3f225b79af4dae256ce": {
"days": 60,
"source": "rule",
"name": "Tè Al limone",
"location": "dispensa",
"ts": 1778419504
},
"7ff2b7d326dcba52a664cebbf12f78a2": {
"days": 3,
"source": "ai",
"name": "Piselli fini 1\/2 vapore",
"location": "frigo",
"ts": 1778419505
},
"71062dc7ffd82b3ee4f40bad076a7c91": {
"days": 60,
"source": "rule",
"name": "Cioccolato bianco",
"location": "frigo",
"ts": 1778419506
},
"38a0eaea422dfe970eba125494e75981": {
"days": 180,
"source": "rule",
"name": "Zucca a pezzi",
"location": "freezer",
"ts": 1778419506
},
"cde21270e1cd50c431742e49117b225d": {
"days": 7,
"source": "rule",
"name": "Pancetta Dolce",
"location": "frigo",
"ts": 1778419507
},
"9e4189bd3f8cb1121e7389967dd4f74c": {
"days": 180,
"source": "rule",
"name": "Farina di grano tenero tipo rossa",
"location": "dispensa",
"ts": 1778427005
},
"e3472dd051ed13ae18fc96bbebedc1ba": {
"days": 60,
"source": "rule",
"name": "Lievito di birra",
"location": "dispensa",
"ts": 1778427005
}
}
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "it.dadaloop.evershelf.kiosk"
minSdk = 24
targetSdk = 34
versionCode = 15
versionName = "1.7.14"
versionCode = 16
versionName = "1.7.15"
}
signingConfigs {
@@ -43,17 +43,18 @@
android:layout_height="match_parent"
android:visibility="gone" />
<!-- Settings gear (shown after setup, over WebView) — top-right corner to avoid overlapping modals -->
<!-- Settings gear (shown after setup, over WebView) — bottom-right corner so it never
overlaps the webapp header buttons (e.g. the 📷 scan button at top-right) -->
<ImageButton
android:id="@+id/btnSettings"
android:layout_width="44dp"
android:layout_height="44dp"
android:layout_gravity="top|end"
android:layout_marginTop="8dp"
android:layout_gravity="bottom|end"
android:layout_marginBottom="80dp"
android:layout_marginEnd="8dp"
android:background="@android:color/transparent"
android:src="@android:drawable/ic_menu_manage"
android:alpha="0.12"
android:alpha="0.28"
android:contentDescription="Settings"
android:scaleType="centerInside"
android:visibility="gone" />
+58 -5
View File
@@ -11,7 +11,7 @@
<title>EverShelf</title>
<link rel="manifest" href="manifest.json">
<link rel="icon" type="image/png" href="assets/img/logo/logo_icon.png">
<link rel="stylesheet" href="assets/css/style.css?v=20260516b">
<link rel="stylesheet" href="assets/css/style.css?v=20260520b">
<!-- QuaggaJS for barcode scanning -->
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2@1.8.4/dist/quagga.min.js"></script>
<!-- @xenova/transformers: ES-module bootstrap that exposes a lazy category-classifier as window._categoryPipelinePromise -->
@@ -54,8 +54,17 @@
<div id="app-preloader" aria-hidden="true">
<div class="app-preloader-inner">
<img src="assets/img/logo/logo.png" alt="EverShelf" class="app-preloader-logo" />
<div class="app-preloader-spinner"></div>
<span class="app-preloader-version" id="preloader-version">v1.7.15</span>
<div class="app-preloader-spinner" id="preloader-spinner"></div>
<div id="preloader-progress-wrap" class="preloader-progress-wrap" style="display:none">
<div class="preloader-bar-track">
<div id="preloader-bar" class="preloader-bar"></div>
</div>
<div id="preloader-check-label" class="preloader-check-label">&nbsp;</div>
</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>
<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.21</span>
</div>
</div>
@@ -68,7 +77,7 @@
<!-- Title — left-aligned; grows to fill space -->
<div class="header-title-wrap">
<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.15</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.21</span>
</h1>
<!-- Update badge — shown alongside title, never replaces it -->
<span class="header-update-badge" id="header-update-badge" style="display:none"></span>
@@ -185,6 +194,7 @@
<div class="page-header">
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
<h2 id="inventory-title" data-i18n="inventory.title">Dispensa</h2>
<button class="page-header-action-btn" onclick="_showExportModal()" title="Export" data-i18n-title="export.btn_title">📤</button>
</div>
<div class="location-tabs" id="location-tabs">
<button class="tab active" onclick="filterLocation('')" data-loc="" data-i18n="inventory.filter_all">Tutti</button>
@@ -1287,6 +1297,43 @@
</select>
</div>
</div>
<div class="settings-card">
<h4 data-i18n="settings.theme.title">🌙 Tema / Aspetto</h4>
<p class="settings-hint" data-i18n="settings.theme.hint">Scegli il tema dell'interfaccia.</p>
<div class="form-group">
<label data-i18n="settings.theme.label">🌙 Tema</label>
<select id="setting-dark-mode" class="form-input" onchange="_setThemeMode(this.value)">
<option value="off" data-i18n="settings.theme.off">☀️ Chiaro</option>
<option value="auto" selected data-i18n="settings.theme.auto">🔄 Automatico (sistema)</option>
<option value="on" data-i18n="settings.theme.on">🌙 Scuro</option>
</select>
</div>
</div>
<div class="settings-card">
<h4 data-i18n="settings.zerowaste.card_title">♻️ Suggerimenti zero-waste</h4>
<p class="settings-hint" data-i18n="settings.zerowaste.card_hint">Durante la cottura, mostra consigli su come riutilizzare gli scarti prodotti in ogni passo (bucce, acqua di cottura, ecc.). Disattivo per impostazione predefinita.</p>
<div class="form-group">
<label class="toggle-row">
<span data-i18n="settings.zerowaste.label">Mostra suggerimenti durante la cottura</span>
<span class="toggle-switch">
<input type="checkbox" id="setting-zerowaste-tips">
<span class="toggle-slider"></span>
</span>
</label>
</div>
</div>
<div class="settings-card">
<h4 data-i18n="export.title">📤 Esporta inventario</h4>
<p class="settings-hint" data-i18n="export.hint">Scarica l'inventario corrente in CSV o apri una versione stampabile (PDF).</p>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn btn-outline" onclick="exportInventory('csv')" style="flex:1;min-width:120px">
📊 <span data-i18n="export.btn_csv">CSV</span>
</button>
<button class="btn btn-outline" onclick="exportInventory('html')" style="flex:1;min-width:120px">
🖨️ <span data-i18n="export.btn_pdf">PDF / Stampa</span>
</button>
</div>
</div>
</div>
</div>
@@ -1553,6 +1600,12 @@
</button>
</div>
<div class="cooking-step-ings" id="cooking-step-ings" style="display:none"></div>
<div id="cooking-zerowaste-tip" class="cooking-zerowaste-tip" style="display:none">
<span class="cooking-zerowaste-label" data-i18n="cooking.zerowaste_label">♻️ Scarto</span>
<span id="cooking-zerowaste-scrap" class="cooking-zerowaste-scrap"></span>
<p id="cooking-zerowaste-text" class="cooking-zerowaste-text"></p>
<button class="cooking-zerowaste-close" onclick="_dismissZeroWasteTip()" aria-label="Chiudi"></button>
</div>
</div>
<div class="cooking-nav">
<button class="cooking-nav-btn cooking-prev-btn" id="cooking-prev" onclick="navigateCookingStep(-1)">◀ Precedente</button>
@@ -1560,6 +1613,6 @@
</div>
</div>
<script src="assets/js/app.js?v=20260516b"></script>
<script src="assets/js/app.js?v=20260520b"></script>
</body>
</html>
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "EverShelf",
"short_name": "EverShelf",
"description": "Gestione completa della dispensa di casa con scansione barcode",
"version": "1.7.15",
"version": "1.7.20",
"start_url": "/evershelf/",
"display": "standalone",
"background_color": "#f0f4e8",
+56 -3
View File
@@ -500,7 +500,8 @@
"undo_success": "↩ Vorgang rückgängig gemacht für {name}",
"already_undone": "Vorgang bereits rückgängig gemacht",
"too_old": "Vorgänge älter als 24 Stunden können nicht rückgängig gemacht werden",
"undo_error": "Fehler beim Rückgängigmachen"
"undo_error": "Fehler beim Rückgängigmachen",
"recipe_prefix": "Rezept"
},
"chat": {
"title": "Gemini Chef",
@@ -539,7 +540,9 @@
"recipe_done_tts": "Rezept abgeschlossen! Guten Appetit!",
"expires_chip": "läuft ab {date}",
"finish": "✅ Fertig",
"step_fallback": "Schritt {n}"
"step_fallback": "Schritt {n}",
"zerowaste_label": "♻️ Abfall",
"zerowaste_tip_title": "Zero-Waste-Tipp"
},
"settings": {
"title": "⚙️ Einstellungen",
@@ -743,7 +746,20 @@
},
"saved": "✅ Konfiguration gespeichert!",
"saved_local": "✅ Konfiguration lokal gespeichert",
"saved_local_error": "⚠️ Lokal gespeichert, Serverfehler: {error}"
"saved_local_error": "⚠️ Lokal gespeichert, Serverfehler: {error}",
"theme": {
"title": "🌙 Erscheinungsbild",
"hint": "Wähle das Interface-Design.",
"label": "🌙 Design",
"off": "☀️ Hell",
"on": "🌙 Dunkel",
"auto": "🔄 Automatisch (System)"
},
"zerowaste": {
"card_title": "♻️ Zero-Waste-Tipps",
"card_hint": "Zeige während des Kochens Tipps zur Wiederverwendung von Abfällen (Schalen, Kochwasser usw.). Standardmäßig deaktiviert.",
"label": "Tipps beim Kochen anzeigen"
}
},
"expiry": {
"today": "HEUTE",
@@ -1178,5 +1194,42 @@
"report_bug_error": "Bericht konnte nicht gesendet werden. Verbindung prüfen.",
"changelog": "Changelog",
"github": "GitHub-Repository"
},
"export": {
"title": "Inventar exportieren",
"hint": "Lade das aktuelle Inventar als CSV herunter oder öffne die druckfertige Version (PDF).",
"btn_csv": "CSV herunterladen",
"btn_pdf": "PDF / Drucken",
"btn_title": "Exportieren"
},
"startup": {
"connecting": "Serververbindung wird hergestellt...",
"check_php_memory": "PHP-Speicher",
"check_php_timeout": "PHP-Timeout",
"check_php_upload": "PHP-Upload",
"check_data_dir": "Datenverzeichnis",
"check_rate_limits": "Rate-Limits-Verzeichnis",
"check_backups": "Backup-Verzeichnis",
"check_write_test": "Schreibtest",
"check_disk_space": "Speicherplatz",
"check_db_connect": "Datenbankverbindung",
"check_db_tables": "Datenbanktabellen",
"check_db_integrity": "Datenbankintegrität",
"check_db_wal": "WAL-Modus",
"check_db_size": "Datenbankgröße",
"check_db_rows": "Inventardaten",
"check_env": ".env-Datei",
"check_gemini": "Gemini-AI-Schlüssel",
"check_bring_creds": "Bring!-Anmeldedaten",
"check_bring_token": "Bring!-Token",
"check_curl_ssl": "cURL-SSL",
"check_internet": "Internetverbindung",
"fresh_install": "Neuinstallation",
"warnings_found": "Warnungen",
"all_ok": "System OK",
"critical_error_short": "Kritischer Fehler",
"critical_error": "Kritischer Fehler: Die App kann nicht gestartet werden. Prüfe die Serverlogs.",
"error_network": "Server nicht erreichbar. Bitte Netzwerkverbindung prüfen.",
"retry": "Erneut versuchen"
}
}
+54 -2
View File
@@ -540,7 +540,9 @@
"recipe_done_tts": "Recipe complete! Enjoy your meal!",
"expires_chip": "exp. {date}",
"finish": "✅ Finish",
"step_fallback": "Step {n}"
"step_fallback": "Step {n}",
"zerowaste_label": "♻️ Scrap",
"zerowaste_tip_title": "Zero-waste tip"
},
"settings": {
"title": "⚙️ Settings",
@@ -744,7 +746,20 @@
},
"saved": "✅ Configuration saved!",
"saved_local": "✅ Configuration saved locally",
"saved_local_error": "⚠️ Saved locally, server error: {error}"
"saved_local_error": "⚠️ Saved locally, server error: {error}",
"theme": {
"title": "🌙 Appearance",
"hint": "Choose the interface theme.",
"label": "🌙 Theme",
"off": "☀️ Light",
"on": "🌙 Dark",
"auto": "🔄 Auto (system)"
},
"zerowaste": {
"card_title": "♻️ Zero-waste tips",
"card_hint": "During cooking, show tips on how to reuse scraps generated in each step (peels, cooking water, etc.). Disabled by default.",
"label": "Show tips during cooking"
}
},
"expiry": {
"today": "TODAY",
@@ -1179,5 +1194,42 @@
"report_bug_error": "Could not send the report. Check your connection.",
"changelog": "Changelog",
"github": "GitHub Repository"
},
"export": {
"title": "Export inventory",
"hint": "Download the current inventory as CSV or open a print-ready version (PDF).",
"btn_csv": "Download CSV",
"btn_pdf": "PDF / Print",
"btn_title": "Export"
},
"startup": {
"connecting": "Connecting to server...",
"check_php_memory": "PHP memory",
"check_php_timeout": "PHP timeout",
"check_php_upload": "PHP upload",
"check_data_dir": "Data directory",
"check_rate_limits": "Rate limits dir",
"check_backups": "Backup dir",
"check_write_test": "Disk write test",
"check_disk_space": "Disk space",
"check_db_connect": "Database connection",
"check_db_tables": "Database tables",
"check_db_integrity": "Database integrity",
"check_db_wal": "WAL mode",
"check_db_size": "Database size",
"check_db_rows": "Inventory data",
"check_env": ".env file",
"check_gemini": "Gemini AI key",
"check_bring_creds": "Bring! credentials",
"check_bring_token": "Bring! token",
"check_curl_ssl": "cURL SSL",
"check_internet": "Internet connection",
"fresh_install": "fresh install",
"warnings_found": "warnings found",
"all_ok": "System OK",
"critical_error_short": "Critical error",
"critical_error": "Critical error: the app cannot start. Check your server logs.",
"error_network": "Cannot reach the server. Check your network connection.",
"retry": "Retry"
}
}
+1235
View File
File diff suppressed because it is too large Load Diff
+1235
View File
File diff suppressed because it is too large Load Diff
+54 -2
View File
@@ -540,7 +540,9 @@
"recipe_done_tts": "Ricetta completata! Buon appetito!",
"expires_chip": "scade {date}",
"finish": "✅ Fine",
"step_fallback": "Passo {n}"
"step_fallback": "Passo {n}",
"zerowaste_label": "♻️ Scarto",
"zerowaste_tip_title": "Consiglio anti-spreco"
},
"settings": {
"title": "⚙️ Configurazione",
@@ -744,7 +746,20 @@
},
"saved": "✅ Configurazione salvata!",
"saved_local": "✅ Configurazione salvata localmente",
"saved_local_error": "⚠️ Salvato localmente, errore server: {error}"
"saved_local_error": "⚠️ Salvato localmente, errore server: {error}",
"theme": {
"title": "🌙 Tema / Aspetto",
"hint": "Scegli il tema dell interfaccia.",
"label": "🌙 Tema",
"off": "☀️ Chiaro",
"on": "🌙 Scuro",
"auto": "🔄 Automatico (sistema)"
},
"zerowaste": {
"card_title": "♻️ Suggerimenti zero-waste",
"card_hint": "Durante la cottura, mostra consigli su come riutilizzare gli scarti prodotti in ogni passo (bucce, acqua di cottura, ecc.). Disattivo per impostazione predefinita.",
"label": "Mostra suggerimenti durante la cottura"
}
},
"expiry": {
"today": "OGGI",
@@ -1179,5 +1194,42 @@
"report_bug_error": "Impossibile inviare la segnalazione. Controlla la connessione.",
"changelog": "Changelog",
"github": "Repository GitHub"
},
"export": {
"title": "Esporta inventario",
"hint": "Scarica l inventario corrente in CSV o apri la versione stampabile (PDF).",
"btn_csv": "Scarica CSV",
"btn_pdf": "PDF / Stampa",
"btn_title": "Esporta"
},
"startup": {
"connecting": "Connessione al server...",
"check_php_memory": "Memoria PHP",
"check_php_timeout": "Timeout PHP",
"check_php_upload": "Upload PHP",
"check_data_dir": "Cartella dati",
"check_rate_limits": "Dir rate limits",
"check_backups": "Dir backup",
"check_write_test": "Test scrittura disco",
"check_disk_space": "Spazio disco",
"check_db_connect": "Connessione database",
"check_db_tables": "Tabelle database",
"check_db_integrity": "Integrità database",
"check_db_wal": "WAL mode",
"check_db_size": "Dimensione database",
"check_db_rows": "Dati inventario",
"check_env": "File .env",
"check_gemini": "Chiave Gemini AI",
"check_bring_creds": "Credenziali Bring!",
"check_bring_token": "Token Bring!",
"check_curl_ssl": "cURL SSL",
"check_internet": "Connessione internet",
"fresh_install": "nuovo impianto",
"warnings_found": "avvisi rilevati",
"all_ok": "Sistema OK",
"critical_error_short": "Errore critico",
"critical_error": "Errore critico: l'app non può avviarsi. Controlla i log del server.",
"error_network": "Impossibile contattare il server. Controlla la connessione di rete.",
"retry": "Riprova"
}
}