Compare commits

...

166 Commits

Author SHA1 Message Date
dadaloop82 d80199e4f1 release: v1.7.27 2026-05-29 05:43:47 +00:00
dadaloop82 1637cc1020 feat: HA sensor enrichment, inventory edit guard, recipe ingredient fix, bread shelf-life
- HA sensor: expiring_list now includes full product details (location, brand,
  category, days_remaining, opened_at, vacuum_sealed, default_quantity, etc.)
- HA sensor: new expired_list attribute with full product details per expired item
- HA sensor: new low_stock_list attribute (items with quantity ≤ 1, full details)
- HA sensor: new sensor=product endpoint (?action=ha_sensor&sensor=product)
  with optional filters: &id=, &name=, &location=
- HA cron webhook: expiry alert items now carry full product details
- Inventory edit: confirm dialog when quantity exceeds unit-specific threshold
  (prevents data loss from unit-confusion typos, e.g. 183 conf instead of 0.183)
- Recipe AI: explicit rule against ingredient form substitution
  (fresh tomatoes ≠ passata, fresh milk ≠ UHT ≠ cream, etc.)
- Shelf-life: opened bread rules (piadina 2d, bauletto/pancarrè 4d, pane 3d)
- docs/wiki: HA page updated with new schema, examples, product endpoint

Closes #125
2026-05-29 05:40:25 +00:00
github-actions[bot] 904a398009 chore: auto-merge develop → main
Triggered by: bc39361 feat: barcode scan visual feedback + EAN checksum validation
2026-05-27 05:28:31 +00:00
dadaloop82 bc39361246 feat: barcode scan visual feedback + EAN checksum validation
- Add scan-status-bar overlay inside scanner viewport showing:
  - Active scan method (Native API / Quagga / Native + Quagga)
  - Scanning state: ready, scanning, partial read, invalid, confirmed
- Validate EAN-8/EAN-13/UPC checksums in Quagga path before confirming
  (native BarcodeDetector validates internally; Quagga can return false positives)
- Show 'invalid barcode, retrying' message with invalid code highlighted
- Reset invalid barcode confidence counter on invalid read so scanner retries
- Spawn parallel Quagga scan with 'combined scan active' status message
- Add 6 translation keys (scan.status_*) in all 5 language files
2026-05-27 05:26:47 +00:00
github-actions[bot] 7f173770fc chore: auto-merge develop → main
Triggered by: b83db76 fix: remove background/border from persons +/- buttons
2026-05-26 18:23:27 +00:00
dadaloop82 b83db76a8d fix: remove background/border from persons +/- buttons 2026-05-26 18:21:53 +00:00
github-actions[bot] cfd089a0a3 chore: auto-merge develop → main
Triggered by: ade121f fix: align recipe persons-ctrl inline, enlarge fav star
2026-05-26 18:20:23 +00:00
dadaloop82 ade121f43f fix: align recipe persons-ctrl inline, enlarge fav star 2026-05-26 18:18:42 +00:00
github-actions[bot] 2f665f777b chore: auto-merge develop → main
Triggered by: f46b12e feat: recipe favorites (#124), portion rescaler (#123), financial waste report (#117), macronutrient panel (#118)
2026-05-26 18:11:28 +00:00
dadaloop82 f46b12e3ad feat: recipe favorites (#124), portion rescaler (#123), financial waste report (#117), macronutrient panel (#118)
- #124: star toggle on recipe view + favorites shown first in archive with gold border
- #123: +/- persons buttons on recipe to scale ingredient quantities
- #117: wasted value in EUR displayed in monthly stats section
- #118: macronutrient breakdown panel (P/C/F/fiber bars) with 4th insight rotation phase
- DB: is_favorite column on recipes, nutriments_json on products (auto-migrated)
- OFF API: nutriments fields fetched and stored per product
- Translations: it/en/de/fr/es updated with new keys
2026-05-26 18:09:32 +00:00
github-actions[bot] a932d3de11 chore: auto-merge develop → main
Triggered by: 6120fad chore: update CHANGELOG for v1.7.26
2026-05-26 17:31:48 +00:00
dadaloop82 6120fad40b chore: update CHANGELOG for v1.7.26 2026-05-26 17:30:17 +00:00
github-actions[bot] 8ac6fec5a2 chore: auto-merge develop → main
Triggered by: 4f68925 fix: insight banner rotation 30s → 60s (1 minute per panel)
2026-05-26 17:29:50 +00:00
github-actions[bot] fe7587e9e4 chore: auto-merge develop → main
Triggered by: f4ea9e7 fix: banner rotation (30s interval, skip empty phases) + normalize OFF categories
2026-05-26 17:28:44 +00:00
dadaloop82 4f68925a7c fix: insight banner rotation 30s → 60s (1 minute per panel) 2026-05-26 17:28:22 +00:00
dadaloop82 f4ea9e74e6 fix: banner rotation (30s interval, skip empty phases) + normalize OFF categories
- _startInsightAlternation: 3_600_000ms → 30_000ms so rotation is actually
  visible without waiting an hour
- _applyInsightPhase: smart phase-skip instead of always falling back to waste;
  if current phase has no content, advance to the next non-empty phase
- PHP: add _normalizeCat() mapping OpenFoodFacts slug format ('en:dairies',
  'en:plant-based-foods-and-beverages', …) to Italian app categories; also
  re-aggregates counts after normalization so 'en:dairies' + 'en:milk' both
  count toward 'latticini' correctly
- JS _renderMonthlyStatsSection: guard t() fallback — if t() returns the key
  itself (not found), display a cleaned-up slug instead of the raw key
2026-05-26 17:26:57 +00:00
github-actions[bot] 8f217fd166 chore: auto-merge develop → main
Triggered by: b985247 feat: monthly stats panel in rotating insight banner (closes #100)
2026-05-26 17:21:45 +00:00
dadaloop82 b985247b95 feat: monthly stats panel in rotating insight banner (closes #100)
- Add PHP action 'monthly_stats' + getMonthlyStats() function:
  items consumed/added/wasted this month, trend vs prev month,
  top 5 categories and top 3 products by transaction count
- Expand insight rotation from 2 → 3 phases (waste → nutrition → monthly)
- Add _renderMonthlyStatsSection() following nutrition section styling:
  large indigo number, trend arrow with %, horizontal category bars
  animated on show, badges for added/wasted/top-product
- Add ms-* CSS classes (bar chart layout matching nutr-* design)
- Add stats_monthly translations in it/en/de/fr/es (10 keys each)
2026-05-26 17:19:54 +00:00
github-actions[bot] efbed479df chore: auto-merge develop → main
Triggered by: 695c23f feat: smart shopping extended horizon for staple items (closes #98)
2026-05-26 17:10:43 +00:00
dadaloop82 695c23fc21 feat: smart shopping extended horizon for staple items (closes #98)
Products used ≥ 4x/month (daily staples: latte, pane, uova) now appear in
the smart shopping list when stock will deplete within 28 days (instead of 14).
Products used ≥ 2x/month (weekly staples: yogurt, frutta, carne) appear
within 21 days. The existing ≥1.5x/month rule at 14 days is unchanged.

No manual pin or UI needed: frequency is detected automatically from
transaction history. justRestocked guard prevents noise for freshly
bought items.
2026-05-26 17:09:04 +00:00
github-actions[bot] 7a34406b07 chore: auto-merge develop → main
Triggered by: 50660f6 fix: TTS test ask user if heard + PHP 8 end() ref bug + DB migration guard for fresh volumes
2026-05-26 15:01:40 +00:00
dadaloop82 50660f634f fix: TTS test ask user if heard + PHP 8 end() ref bug + DB migration guard for fresh volumes
- app.js: TTS kiosk timeout 4s → 10s; fires interactive 'Hai sentito?' YES/NO
  prompt instead of showing error (TTS can take 6-8s; UtteranceProgressListener
  may not fire on all firmware); YES → success, NO → troubleshooting steps
- translations: add heard_question/heard_yes/heard_no/test_ok_kiosk/test_fail_steps
  to all 5 languages (it/en/de/fr/es) under settings.tts
- api/index.php: fix end() PHP 8.0+ reference error in _offFetchProduct()
  (categories_hierarchy stored in temp var before calling end())  (fixes #130)
- api/database.php: migrateDB() now checks sqlite_master for 'products' table;
  if missing, calls initializeDB() and returns — no ALTER on nonexistent table
  (fixes #133, covers #131)
- api/index.php: health_check db_row_count query guarded against missing
  inventory table (fixes #131)
2026-05-26 14:59:17 +00:00
github-actions[bot] fb06b42107 chore: auto-merge develop → main
Triggered by: c16067d fix(kiosk): scale retry restores nav buttons + settings link to webapp
2026-05-26 14:44:57 +00:00
dadaloop82 c16067d9e5 fix(kiosk): scale retry restores nav buttons + settings link to webapp
SetupActivity:
- btnTestRetry click handler: add step3NextButtons.visibility = VISIBLE
  so the Indietro/Avanti buttons reappear after pressing No-retry
  (previously they stayed hidden → user was stuck with no way to go back)
- onDisconnected(): always re-enable btnTestRetry so user is never stuck
  when scale drops unexpectedly before a weight reading arrives

SettingsActivity / activity_settings.xml:
- Add 'IMPOSTAZIONI AVANZATE' section explaining that HA, Gemini AI,
- Add '← Torna all'app per le impostazioni avanzate' button (finish())
2026-05-26 14:42:27 +00:00
github-actions[bot] 605d8590f6 chore: auto-merge develop → main
Triggered by: 149cff3 fix(kiosk): force STREAM_MUSIC for TTS + JS feedback callbacks
2026-05-26 14:04:23 +00:00
dadaloop82 149cff3ca5 fix(kiosk): force STREAM_MUSIC for TTS + JS feedback callbacks
Android TTS was silently failing because engine.speak() used the default
audio stream, which may be muted while the media stream is not.

Changes:
- KioskActivity: speak() now passes Bundle with KEY_PARAM_STREAM=STREAM_MUSIC
  so TTS plays on the same channel as beep/media audio
- KioskActivity: add UtteranceProgressListener that calls window._kioskTtsDone()
  or window._kioskTtsError(uid, code) back into the WebView on completion/failure
- app.js testTTS(): kiosk path now shows real result from Android callbacks
  (success/error) with a 4s fallback timeout showing actionable troubleshooting hints
2026-05-26 14:02:31 +00:00
github-actions[bot] ec7d172ed9 chore: auto-merge develop → main
Triggered by: 0479e34 ci: retry after GitHub CDN outage
2026-05-26 13:53:56 +00:00
dadaloop82 0479e34c7f ci: retry after GitHub CDN outage 2026-05-26 13:52:15 +00:00
dadaloop82 730efe4d87 ci: force Node.js 24 for JS actions (deadline June 2, 2026)
Add FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true env var to ci.yml,
security.yml and build-kiosk.yml to prevent action failures when
GitHub removes Node 20 runner support on 2026-06-02.

Also re-triggers CI to bypass transient 403 infrastructure failure
that hit the previous run (GitHub CDN outage).
2026-05-26 12:31:37 +00:00
dadaloop82 be3dceeebb feat: add separate sound beep test button + always show move modal on open
- Add testSound() function: plays AudioContext beep independently of TTS
- Add '🔔 Esegui Test Suono' button in TTS settings (above voice test btn)
- Add translations: settings.tts.test_sound_btn (it/en/de)
- Remove hasOtherLocs guard in showMoveAfterUseModal: always show
  location-choice modal when opening a package, regardless of whether
  the product already exists in other locations (e.g. peas only in pantry
  → user can now choose to move opened portion to fridge)
2026-05-26 12:17:18 +00:00
dadaloop82 875250626d fix: prefer offline voices in TTS; add beep + real diagnostics to test
_speakBrowser: voice fallback now prefers localService=true (offline)
voices over cloud voices. On Android Chrome, cloud voices fail silently
when no internet. New priority: local Italian → any Italian → local
any-lang → first available → lang-only.

testTTS (browser engine):
- Plays _playCookingTimerSound('done') beep FIRST (AudioContext, always
  works) so user can confirm audio hardware/volume is working
- Checks if only cloud voices exist → shows actionable error
- Builds utterance with onerror → shows error code on failure
- Adds onstart → shows actual voice name/lang/online status on success
- 2s watchdog: if onstart never fires → 'nessuna risposta' warning
- Shows '🔊 Beep + TTS in corso...' during the test
2026-05-26 12:11:52 +00:00
dadaloop82 245d007e29 fix: Chrome speechSynthesis paused-state bug + better TTS diagnostics
_speakBrowser: Chrome sets speechSynthesis.paused=true after tab is
backgrounded/minimized. Calling cancel() does NOT clear this state.
Adding resume() before speak() inside the setTimeout fixes silent
failure when user returns to the tab (e.g. switch app → back to browser).

testTTS: add diagnostic messages for:
- Android kiosk: check isTtsReady() and show actionable error if
  the native TTS engine hasn't initialized yet
- Web browser: check getVoices().length before speaking; if empty,
  show error asking user to install a voice pack in OS settings
  (instead of always showing ' Riproduzione in corso' even when silent)
2026-05-26 12:04:31 +00:00
github-actions[bot] 63a9f70f86 chore: auto-merge develop → main
Triggered by: 1a6e0c8 fix: robust browser TTS voice selection and Chrome cancel+speak workaround
2026-05-25 17:56:33 +00:00
dadaloop82 1a6e0c87ce fix: robust browser TTS voice selection and Chrome cancel+speak workaround
_speakBrowser() was silently failing in two scenarios:

1. Chrome cancel+speak bug: calling speechSynthesis.speak() immediately
   after cancel() is silently dropped in Chrome. Fixed by adding a 50ms
   setTimeout between cancel() and speak().

2. No voice fallback: when tts_voice preference is empty (never saved)
   and the browser has no default 'it-IT' voice, speech fails silently.
   Now tries: preferred by name → first Italian voice → first any voice
   → lang-only as last resort.

3. Async voice loading: Chrome/Android load voices asynchronously.
   When getVoices() returns empty, now waits for onvoiceschanged (with
   a 500ms safety timeout) before speaking.
2026-05-25 17:55:04 +00:00
github-actions[bot] 73f43cb296 chore: auto-merge develop → main
Triggered by: baed815 fix: translate CATEGORY_LABELS and #pf-category after i18n load
2026-05-25 17:43:55 +00:00
dadaloop82 baed815a48 fix: translate CATEGORY_LABELS and #pf-category after i18n load
CATEGORY_LABELS was built at parse time before translations were
available, causing raw keys like 'categories.verdura' to appear
in all category labels, dropdowns and inventory filters.

_applyI18nToLabels() now also:
- Rebuilds each CATEGORY_LABELS entry using the loaded i18n strings
  and the corresponding CATEGORY_ICONS icon prefix
- Regenerates the static #pf-category <select> options so the
  product-add form also shows translated category names
2026-05-25 17:42:10 +00:00
github-actions[bot] 8aa934f5ca chore: auto-merge develop → main
Triggered by: 83b5eb3 fix: browser TTS ignored when HA TTS configured; wrong ha_hint i18n key
2026-05-25 10:38:53 +00:00
dadaloop82 83b5eb3063 fix: browser TTS ignored when HA TTS configured; wrong ha_hint i18n key
- speakCookingStep: respect tts_engine='browser' explicitly; if user
  chose browser engine, always use _speakBrowser regardless of HA.
  Also add per-engine try/catch so HA or server TTS failures fall back
  to browser TTS instead of silently doing nothing.
- index.html: fix data-i18n='settings.tts.ha_hint' → 'settings.ha.ha_hint'
  (key lived in settings.ha not settings.tts)
2026-05-25 10:37:17 +00:00
github-actions[bot] 59c6f9d76c chore: auto-merge develop → main
Triggered by: bac9485 fix: hide ingredients panel in cooking mode
2026-05-25 10:21:51 +00:00
dadaloop82 bac9485e4e fix: hide ingredients panel in cooking mode
Ingredients are already shown (with use buttons) in the recipe
result screen. In cooking mode only steps should appear.
2026-05-25 10:20:17 +00:00
github-actions[bot] 11178af001 chore: auto-merge develop → main
Triggered by: 4e4a736 feat: ask replace vs save-to-archive on regenerate recipe
2026-05-25 10:13:43 +00:00
dadaloop82 4e4a736dba feat: ask replace vs save-to-archive on regenerate recipe
When user clicks 'Generate another', show a choice:
- Replace (discard current, generate new) — former behavior
- Save to archive & generate new — saves current recipe first

All 5 languages (it/en/de/fr/es) with regen_choice_title,
regen_replace, regen_save_new keys.
2026-05-25 10:11:56 +00:00
dadaloop82 52afdd6bfa fix(recipes): steps shown as raw JSON when AI uses instruction/appliance_function objects
- _stepStr: parse JSON-string steps; handle s.instruction key (backward-compat with already-saved recipes)
- _stepAppliance: new helper to extract appliance_function hint; returns null for 'Nessuno'/'None'
- renderRecipe steps list: shows appliance badge inline after step text when present
- CSS: .recipe-step-appliance badge (green chip, dark-mode variant)
- Prompt (both generateRecipe + generateRecipeStream): rule 9/10 explicitly forbids step objects;
  appliance info must be embedded in the step text string directly
2026-05-25 10:01:10 +00:00
github-actions[bot] 3d27433eb3 chore: auto-merge develop → main
Triggered by: eddb622 feat(offline): full offline mode — cache sync, write queue, startup recovery
2026-05-25 09:27:09 +00:00
dadaloop82 eddb622c85 feat(offline): full offline mode — cache sync, write queue, startup recovery
- Full-screen network error overlay (z-index 300000, above screensaver)
- 'Continue offline' button after 3s, auto-enter after 8s
- Inventory + settings synced to localStorage at startup (during health check)
- inventory_summary and stats computed from local cache while offline
- Write queue (add/use/update/delete): optimistic UI + sync on reconnect
- Pending ops survive page refresh — detected and re-synced at next startup
- Buffered remoteLog/reportError flushed to server (GitHub issues) on restore
- AI/network sections hidden in offline mode (CSS body.offline-mode)
- Banner: pulsing dot while loading cache, item count when ready
- Broken external images replaced with grey SVG placeholder
- Fix: opened items marked is_edible:true offline (was flooding banner)
- Fix: _semverGt() prevents update badge for older GitHub releases
- Bump version to v1.7.25
2026-05-25 09:05:19 +00:00
github-actions[bot] 95c20adbbd chore: auto-merge develop → main
Triggered by: 6fa2e4d docs: HA integration section first in README, with HACS buttons and full feature table
2026-05-23 20:43:02 +00:00
dadaloop82 6fa2e4d830 docs: HA integration section first in README, with HACS buttons and full feature table
- Replaces the old 'New' callout with a proper dedicated section at the top of Features
- Shows all 16 sensors, 6 binary sensors, 5 buttons, todo, calendar, text, 6 services
- HACS and config_flow_start my.home-assistant.io badge buttons

Also: fix dark mode not persisting — _setThemeMode now saves immediately to server .env
2026-05-23 20:41:29 +00:00
github-actions[bot] 6ff1dfe0cc chore: auto-merge develop → main
Triggered by: 43e0ac9 feat(ha): v1.1.0 backend — haCalendar, haSuggestRecipe, haRefreshPrices, haClearExpired + enriched haInventorySensor
2026-05-23 20:24:46 +00:00
dadaloop82 43e0ac9da3 feat(ha): v1.1.0 backend — haCalendar, haSuggestRecipe, haRefreshPrices, haClearExpired + enriched haInventorySensor
New endpoints:
- ha_calendar: returns all expiry dates as calendar events
- ha_suggest_recipe: AI recipe suggestion from expiring items (Gemini)
- ha_refresh_prices: recompute shopping total from price cache only
- ha_clear_expired: delete zero-stock expired rows

haInventorySensor now returns:
- items_dispensa, items_frigo, items_freezer, items_other
- low_stock_items, zero_stock_items
- ai_calls_month, last_backup_at
- days_to_next_expiry, next_expiry_name, next_expiry_date
- bring_connected, shopping_total, price_tracking_enabled, price_currency
2026-05-23 20:22:51 +00:00
github-actions[bot] 1ce32cb5f0 chore: auto-merge develop → main
Triggered by: d75cde7 feat(ha): add expiring_today, next_expiry_name/date, expires_today flag
2026-05-23 20:09:32 +00:00
dadaloop82 d75cde7eb6 feat(ha): add expiring_today, next_expiry_name/date, expires_today flag 2026-05-23 20:07:54 +00:00
github-actions[bot] 43fe1c7bb5 chore: auto-merge develop → main
Triggered by: b2c87ae feat(ha): enrich ha_sensor with opened_items, shopping_total, price_tracking, expiring_3d
2026-05-23 20:00:36 +00:00
dadaloop82 b2c87ae343 feat(ha): enrich ha_sensor with opened_items, shopping_total, price_tracking, expiring_3d 2026-05-23 19:58:49 +00:00
github-actions[bot] fbdae35516 chore: auto-merge develop → main
Triggered by: d9ebc51 fix: ha_sensor JOIN products for name/unit columns (was HTTP 500)
2026-05-23 14:39:37 +00:00
dadaloop82 d9ebc51e71 fix: ha_sensor JOIN products for name/unit columns (was HTTP 500) 2026-05-23 14:38:01 +00:00
github-actions[bot] 56ca58bc18 chore: auto-merge develop → main
Triggered by: b2e0f6d feat: add ha_info, ha_shopping_items endpoints and avahi mDNS service file for HACS integration
2026-05-23 13:25:19 +00:00
dadaloop82 b2e0f6d683 feat: add ha_info, ha_shopping_items endpoints and avahi mDNS service file for HACS integration
- api/index.php: new haGetInfo() endpoint (unique_id, version, instance, items count)
- api/index.php: new haGetShoppingItems() endpoint (Bring! + internal shopping list)
- api/index.php: haInventorySensor() now accepts ?expiry_days=N query param
- api/cron_smart_shopping.php: auto-register avahi mDNS service if avahi-daemon present
- docker/avahi-evershelf.xml: Zeroconf _evershelf._tcp service declaration
- .env.example: add INSTANCE_NAME variable (used by HA integration for device label)
2026-05-23 13:23:28 +00:00
github-actions[bot] ddb9bd9f75 chore: auto-merge develop → main
Triggered by: 965a672 feat: full Home Assistant integration
2026-05-23 12:30:02 +00:00
dadaloop82 965a672abe feat: full Home Assistant integration
- PHP: _fireHaWebhook(), _sendHaNotify(), haInventorySensor(), haTestConnection()
- PHP: ha_sensor + ha_test routing actions
- PHP: getServerSettings() exposes ha_token (consistent with tts_token)
- PHP: saveSettings() handles all HA_* env keys (url, token, tts_entity, webhook_id, events, notify_service, expiry_days)
- PHP: bringAddItems(), shoppingAdd(), updateInventory() fire shopping_add / stock_update webhooks
- Cron: daily HA expiry/expired webhook + push notify with flag-file guard
- HTML: 🏠 Settings tab button + full HA panel (connection, TTS, webhook, notify, sensor cards)
- JS: serverKeys + loadSettingsUI extended with HA fields
- JS: _applyHaSettingsUI(), _loadHaTab(), _renderHaSensorYaml()
- JS: onHaEnabledChange(), testHaConnection(), applyHaTtsPreset()
- JS: saveHaSettings(), copyHaSensorYaml(), showHaWebhookHelp()
- JS: _buildHaTtsRequest() for HA media_player TTS
- JS: speakCookingStep() now supports HA TTS as first-priority path
- JS: onTtsEngineChange() fixed to show server section for both 'server' and 'custom'
- Translations: settings.ha.* (52 keys) in all 5 languages (it/en/de/fr/es)
- .env.example: HA_ENABLED/URL/TOKEN/TTS_ENTITY/WEBHOOK_ID/EVENTS/NOTIFY_SERVICE/EXPIRY_DAYS
- docs/wiki/Home-Assistant.md: new wiki page (REST sensors, webhooks, TTS, push notify, troubleshooting)
- README: HA integration highlighted as first feature block
2026-05-23 12:28:09 +00:00
github-actions[bot] 7249daa8eb chore: auto-merge develop → main
Triggered by: ec53f75 docs: update README
2026-05-23 12:11:11 +00:00
dadaloop82 ec53f7529c docs: update README 2026-05-23 12:09:37 +00:00
github-actions[bot] 1074dff87d chore: auto-merge develop → main
Triggered by: 3989d11 fix: step.replace is not a function when Gemini returns steps as objects
2026-05-23 11:57:33 +00:00
dadaloop82 3989d11094 fix: step.replace is not a function when Gemini returns steps as objects
- PHP generateRecipeStream: normalize recipe.steps to plain strings after
  parsing Gemini JSON (handles [{text:'...'}, ...] objects gracefully)
- JS: add _stepStr(s) helper near cooking mode — safely extracts text from
  a step regardless of type (string or object {text/description/step key})
  and strips leading 'Passo N:' prefix in one place
- JS: replace all 7 manual step.replace(/^Passo.../) calls with _stepStr()
  across renderRecipe, renderCookingStep, startCookingMode, replayCookingTTS,
  toggleCookingTTS, navigateCookingStep — no more crash if Gemini schema drifts
2026-05-23 11:55:55 +00:00
github-actions[bot] b010ced1a6 chore: auto-merge develop → main
Triggered by: cc0fa09 fix: recipe errors now show specific cause instead of generic 'connection error'
2026-05-23 11:47:00 +00:00
dadaloop82 cc0fa09219 fix: recipe errors now show specific cause instead of generic 'connection error'
- PHP generateRecipeStream: wrap entire body in try/catch(\Throwable) to catch
  any PHP fatal/exception mid-stream and send it as a proper SSE error event
- PHP: curl timeout raised 60s→90s; capture curl errno/errmsg on failure
- PHP: HTTP error messages now include a human-readable status label
  (e.g. 'Quota API esaurita (429)', 'Nessuna risposta da Gemini (cURL: ...)')
- JS catch block: show err.message alongside error.connection so the actual
  JS network error (NetworkError, AbortError, etc.) is visible
- JS no-recipe+no-error path: show recipes.stream_interrupted instead of
  generic error.connection
- Translation: added recipes.stream_interrupted in it/en/de
2026-05-23 11:45:26 +00:00
github-actions[bot] c0a076749e chore: auto-merge develop → main
Triggered by: 6a41b53 feat: shopping list pantry hints, barcode multi-API fallback (OPF/beauty/Gemini), README disclaimer
2026-05-23 09:54:56 +00:00
dadaloop82 6a41b53174 feat: shopping list pantry hints, barcode multi-API fallback (OPF/beauty/Gemini), README disclaimer
- Shopping list: each item now shows 'Hai già Xg in dispensa' for same-family inventory stock
  - Lazy-loads inventory once per shopping page visit (_getShoppingInventoryCache)
  - Matches by first significant token (same logic as related-stock on action page)
  - Green hint below item badges, dark-mode aware (.shopping-pantry-hint)
- Barcode lookup: added Open Products Facts + Open Beauty Facts as step 3;
  Gemini AI (_barcodeLookupGemini) as final step 4 fallback
- Added stockForName PHP endpoint (stock_for_name action) for future use
- Restored missing function signatures for _offFetchProduct() and saveProduct()
  that were accidentally lost when stockForName was added in a previous session
- Translation: added shopping.pantry_hint in it/en/de
2026-05-23 09:53:17 +00:00
github-actions[bot] 1d04236bc0 chore: auto-merge develop → main
Triggered by: 561c6e9 ci: fix auto-merge — clear checkout extraheader so WORKFLOW_PAT actually reaches git push
2026-05-23 09:37:26 +00:00
dadaloop82 561c6e9809 ci: fix auto-merge — clear checkout extraheader so WORKFLOW_PAT actually reaches git push 2026-05-23 09:35:53 +00:00
dadaloop82 6857c20893 ci: fix checkout to use github.token, WORKFLOW_PAT only for push 2026-05-23 09:23:48 +00:00
dadaloop82 964de98203 ci: test auto-merge with WORKFLOW_PAT secret 2026-05-23 09:18:03 +00:00
dadaloop82 e28a6e4e39 ci: use WORKFLOW_PAT for auto-merge to allow pushing workflow file changes 2026-05-23 08:37:30 +00:00
dadaloop82 fd9e2471e0 fix: remove invalid 'workflows' permission from ci.yml 2026-05-23 08:30:57 +00:00
dadaloop82 3c8a9693b2 fix: isSuspiciousDefaultQty — use package_unit threshold for pz+g/ml products 2026-05-23 08:28:58 +00:00
dadaloop82 b38bdc45f5 fix: add workflows:write permission to auto-merge job 2026-05-23 08:27:04 +00:00
dadaloop82 83a0df272a fix: CI auto-merge push auth — set remote URL with GITHUB_TOKEN before push 2026-05-23 08:21:26 +00:00
dadaloop82 6320b575e0 v1.7.25 — partial throw from banner, barcode fallback, related stock, Bring! re-add fix
- Fix: Bring! items re-appearing after manual removal (missing _markBringPurchased call in removeBringItem / confirmShoppingItemFound; autoAddCriticalItems now respects blocklist for qty=0 items)
- Fix: barcode false 'not found' — new _offFetchProduct() helper tries UPC-A↔EAN-13 candidates × 2 locales with auto-retry; UPCItemDB fallback also iterates candidates
- Fix: bannerThrowAway() now opens partial-throw modal (location + qty input + throw-all button) instead of immediately discarding everything
- Add: related stock card on action page — shows same-family inventory items when scanning a branded product
2026-05-23 08:17:20 +00:00
github-actions[bot] 8ccd218c5a chore: auto-merge develop → main
Triggered by: 5c1afaa feat: redesign action-page hero card (match use-page style)
2026-05-22 21:25:12 +00:00
dadaloop82 5c1afaaaf5 feat: redesign action-page hero card (match use-page style) 2026-05-22 21:23:18 +00:00
github-actions[bot] 6245b15420 chore: auto-merge develop → main
Triggered by: 02f673a fix: include dark_mode in save_settings payload (was never written to .env)
2026-05-22 21:15:51 +00:00
dadaloop82 02f673a164 fix: include dark_mode in save_settings payload (was never written to .env) 2026-05-22 21:14:16 +00:00
github-actions[bot] 61bb1b5552 chore: auto-merge develop → main
Triggered by: cbf4bd5 feat: show expiry + qty pills on use-page product hero card; redesign card with accent border
2026-05-22 21:11:53 +00:00
dadaloop82 cbf4bd54da feat: show expiry + qty pills on use-page product hero card; redesign card with accent border 2026-05-22 21:10:19 +00:00
github-actions[bot] 1cdbdb3b25 chore: auto-merge develop → main
Triggered by: 837d62c fix: add missing _ensureAudioUnlocked() definition; use shared AudioContext in _playCookingTimerSound
2026-05-22 13:15:59 +00:00
dadaloop82 837d62c335 fix: add missing _ensureAudioUnlocked() definition; use shared AudioContext in _playCookingTimerSound 2026-05-22 13:14:21 +00:00
github-actions[bot] fa36ba83bf chore: auto-merge develop → main
Triggered by: 1efeaf9 fix: skip use-all confirm when only one item row and no open packages
2026-05-22 04:51:02 +00:00
dadaloop82 1efeaf9236 fix: skip use-all confirm when only one item row and no open packages 2026-05-22 04:49:27 +00:00
dadaloop82 573bcd1102 Merge branch 'main' of github-evershelf:dadaloop82/EverShelf 2026-05-21 18:45:46 +00:00
dadaloop82 426cc9df7e release: merge develop → main for v1.7.24 2026-05-21 18:45:35 +00:00
dadaloop82 6f2d6d9944 release: v1.7.24 — changelog, readme badge, manifest version 2026-05-21 18:45:17 +00:00
github-actions[bot] d3eb82eee2 chore: auto-merge develop → main
Triggered by: 98426bf fix: dark_mode persisted in server .env (not localStorage) — add to saveSettings, getServerSettings, applySyncedSettings
2026-05-21 18:42:49 +00:00
dadaloop82 98426bf861 fix: dark_mode persisted in server .env (not localStorage) — add to saveSettings, getServerSettings, applySyncedSettings 2026-05-21 18:41:12 +00:00
github-actions[bot] 264b1f648e chore: auto-merge develop → main
Triggered by: b89df96 fix: dark mode resets to auto on reload — bootstrap dark_mode from localStorage in getSettings()
2026-05-21 18:35:19 +00:00
dadaloop82 b89df961a6 fix: dark mode resets to auto on reload — bootstrap dark_mode from localStorage in getSettings() 2026-05-21 18:33:33 +00:00
github-actions[bot] 5e34bc90b3 chore: auto-merge develop → main
Triggered by: 3b100df fix: cooking timer sound/TTS — shared pre-unlocked AudioContext; always speak on alarm regardless of TTS toggle; kiosk bridge TTS check
2026-05-21 18:20:24 +00:00
dadaloop82 3b100df26c fix: cooking timer sound/TTS — shared pre-unlocked AudioContext; always speak on alarm regardless of TTS toggle; kiosk bridge TTS check 2026-05-21 18:18:45 +00:00
github-actions[bot] 2ecb3cbac6 chore: auto-merge develop → main
Triggered by: c2004fd fix: scale use — auto-switch conf→sub (g/ml) when scale is active; show scale btn for conf+weight products
2026-05-21 06:02:55 +00:00
dadaloop82 c2004fd0f8 fix: scale use — auto-switch conf→sub (g/ml) when scale is active; show scale btn for conf+weight products 2026-05-21 06:01:04 +00:00
github-actions[bot] fba0947945 chore: auto-merge develop → main
Triggered by: 3a1f6cf fix: cooking timer — AudioContext.resume() on mobile; always play beep; show done card 3s before dismiss
2026-05-20 18:32:10 +00:00
dadaloop82 3a1f6cfd1e fix: cooking timer — AudioContext.resume() on mobile; always play beep; show done card 3s before dismiss 2026-05-20 18:30:25 +00:00
github-actions[bot] 37fb522e8b chore: auto-merge develop → main
Triggered by: 66f5a03 fix: wrap updateInventory DB writes in a transaction to prevent concurrent lock errors (#109 #110)
2026-05-20 15:40:15 +00:00
dadaloop82 66f5a03503 fix: wrap updateInventory DB writes in a transaction to prevent concurrent lock errors (#109 #110) 2026-05-20 15:38:34 +00:00
dadaloop82 a37d97dfcd fix: kiosk — permanently hide native settings btn; add web ⚙️ in overlay; remove scattered show/hide calls 2026-05-20 15:37:57 +00:00
github-actions[bot] 47197d0d66 chore: auto-merge develop → main
Triggered by: 1496216 fix: depleted items urgency based on usesPerMonth only (not recency)
2026-05-20 13:47:41 +00:00
dadaloop82 149621651d fix: depleted items urgency based on usesPerMonth only (not recency) 2026-05-20 13:46:00 +00:00
github-actions[bot] b5a6daa557 chore: auto-merge develop → main
Triggered by: ccc2f89 fix: depleted items urgency — use buyCount/useCount from internal history to assign medium/low
2026-05-20 13:42:45 +00:00
dadaloop82 ccc2f8907d fix: depleted items urgency — use buyCount/useCount from internal history to assign medium/low 2026-05-20 13:41:04 +00:00
github-actions[bot] 9e80915a61 chore: auto-merge develop → main
Triggered by: 7b60f1d fix: 0.5 conf use page (default conf mode + fraction btns); depleted items always in shopping; conf decimals in history log
2026-05-20 13:37:35 +00:00
dadaloop82 7b60f1dbe3 fix: 0.5 conf use page (default conf mode + fraction btns); depleted items always in shopping; conf decimals in history log 2026-05-20 13:35:27 +00:00
github-actions[bot] 7019160704 chore: auto-merge develop → main
Triggered by: ac8b5ac fix: restore Bring! health check; token warning only when truly invalid
2026-05-19 17:27:50 +00:00
dadaloop82 ac8b5acc0c fix: restore Bring! health check; token warning only when truly invalid
- Restore the if($bringEnabled) block that was accidentally removed in fa0442e
- Check is skipped entirely when SHOPPING_MODE != bring or credentials not set
- Missing token file = first launch, auto-created on next shopping open → ok:true
- Warning shown only if token file exists but access_token field is missing (corrupt)
- Expired tokens are OK (refreshed automatically)

Fixes spurious 'Token Bring!' warning on installs without Bring! configured
2026-05-19 17:26:06 +00:00
github-actions[bot] 34df755ba3 chore: auto-merge develop → main
Triggered by: 87eac17 fix: recipe quantities for conf+weight; move modal remembers location
2026-05-19 16:53:15 +00:00
dadaloop82 87eac171bf fix: recipe quantities for conf+weight; move modal remembers location
- PHP (all 3 recipe endpoints): conf products with weight/volume package_unit
  (e.g., 300g/conf) now keep qty_number in sub-units (grams/ml) instead of
  converting to fractional conf. Post-normalisation block converts any AI-
  returned fractional conf values to grams automatically.
- JS submitRecipeUse: vacuum state now read from actual inventory item at the
  used location (_recipeUseContext.items), not from recipe ingredient data.
- JS showRecipeMoveModal: now uses _prefMoveLocCache preference system —
  after 2 consistent choices the modal is skipped automatically. 'Stay' button
  records the choice. Added _recipeMoveCancelStay() helper.
- JS confirmRecipeMove: records move choice via _recordMoveLocChoice();
  accepts optional forcedVacuum param for preference-triggered auto-moves.

Closes #108 (duplicate of #107 — data dir permissions)
2026-05-19 16:51:37 +00:00
github-actions[bot] ef15f3536c chore: auto-merge develop → main
Triggered by: f77b325 refactor: remove localStorage for settings — all settings server-centralised
2026-05-19 16:42:55 +00:00
dadaloop82 f77b3259ad refactor: remove localStorage for settings — all settings server-centralised
- getSettings() no longer reads from localStorage; uses _settingsCache only
- saveSettingsToStorage() no longer writes to localStorage
- _applySyncedSettings() no longer writes to localStorage
- syncSettingsFromDB() meal_plan/tts_voice blocks no longer write to localStorage
- loadSettingsUI() server-merge block no longer writes to localStorage
- flipCamera() saves camera_facing directly to server via _saveSettingToServer()
- New helper _saveSettingToServer(data): calls save_settings API for partial updates
- onShoppingEnabledChange() / onShoppingModeChange() now immediately persist to
  server .env via _saveSettingToServer() — no wait for Save button
- Early-theme IIFE: reads dedicated evershelf_dark_mode key (falls back to old
  evershelf_settings for backward compat) — only dark_mode kept in localStorage
  as a technical necessity for pre-render theme application
2026-05-19 16:41:07 +00:00
github-actions[bot] 5ad24ed73b chore: auto-merge develop → main
Triggered by: 84934c1 fix: sync shopping settings across clients (serverKeys was missing shopping_* keys)
2026-05-19 16:37:41 +00:00
dadaloop82 84934c1908 fix: sync shopping settings across clients (serverKeys was missing shopping_* keys) 2026-05-19 16:35:54 +00:00
github-actions[bot] dd0625b253 chore: auto-merge develop → main
Triggered by: fa0442e feat: native shopping list — decouple from Bring! (#105)
2026-05-19 16:07:33 +00:00
dadaloop82 fa0442e2f6 feat: native shopping list — decouple from Bring! (#105)
- New shopping_list SQLite table (migration in migrateDB)
- shoppingGetList/Add/Remove — delegates to Bring! or internal DB
  based on SHOPPING_MODE env var (default: internal)
- isShoppingBringMode() guard: requires mode=bring + BRING credentials
- bringQuickSyncProduct updated to support both modes
- All bring_* JS calls replaced with shopping_* (bring_migrate_names kept)
- New settings tab 'Lista spesa' (tab-bring) with:
  - Enable/disable shopping list toggle
  - Provider radio: internal vs Bring!
  - Bring! sub-section (shown only when mode=bring)
  - AI smart suggestions toggle
  - Forecast toggle
  - Auto-add threshold (qty slider)
  - Price estimation section
- _applyShoppingSettingsUI, onShoppingEnabledChange, onShoppingModeChange
- SHOPPING_* env vars documented in .env.example
- cron_smart_shopping respects SHOPPING_MODE and SHOPPING_SMART_SUGGESTIONS
- Translations: 12 new keys in all 5 languages (it/en/de/fr/es)
- DB busy_timeout=5000ms + WAL pragma in getDB() (fixes #95)
2026-05-19 16:05:49 +00:00
github-actions[bot] a85414d790 chore: auto-merge develop → main
Triggered by: c07439f docs: add Contributing + Community section to README
2026-05-18 19:12:04 +00:00
dadaloop82 c07439fea4 docs: add Contributing + Community section to README
- New '🤝 Contributing' section with translation table, skill matrix,
  and links to good first issues and help wanted labels
- New '💬 Community' section linking to GitHub Discussions
- Visible call-to-action for translators pointing to issue #93
2026-05-18 19:10:00 +00:00
github-actions[bot] 8f6934485a chore: auto-merge develop → main
Triggered by: d7aadff fix(kiosk): target SDK 35 + setInstallReason for Android 16 compatibility
2026-05-18 19:06:15 +00:00
dadaloop82 d7aadff598 fix(kiosk): target SDK 35 + setInstallReason for Android 16 compatibility
- compileSdk/targetSdk 34 → 35 (Android 15 stable)
- versionCode 17 → 18, versionName 1.7.16 → 1.7.17
- PackageInstaller.SessionParams: add setInstallReason(INSTALL_REASON_USER)
  on API 26+ — required on Android 14+ to avoid STATUS_FAILURE=1 on self-update
- DownloadManager failure: report dm_status + dm_reason in error payload
  so future issues include the HTTP error code (e.g. 404 vs network error)

Fixes #91 #92
2026-05-18 19:04:32 +00:00
github-actions[bot] d8aff8ac04 chore: auto-merge develop → main
Triggered by: 7364e75 feat: Google Drive OAuth via http://localhost redirect (no public domain required)
2026-05-18 18:44:00 +00:00
dadaloop82 7364e75881 feat: Google Drive OAuth via http://localhost redirect (no public domain required)
- Switch redirect URI from server IP to http://localhost (works everywhere)
- Add manual code exchange flow: user copies URL from browser, pastes in app
- New PHP action gdrive_oauth_exchange to exchange auth code for refresh token
- Fix  null bug in gdrive_oauth_exchange (was read before initialization)
- Add #gdrive-code-section UI with input + submit button in index.html
- Update _gdriveAuthorize() to show code section and store redirect_uri
- Add _gdriveSubmitCode() JS function for manual code submission
- Update setup wizard and backup tab to show http://localhost as redirect URI
- Add 5 new translation keys (gdrive_redirect_uri_hint, gdrive_code_title,
  gdrive_code_hint, gdrive_code_submit, gdrive_code_empty) in all 5 languages
- Update gdrive_oauth_steps in all translations to reflect new flow
- Document Google Drive OAuth setup in README.md
- Dark mode: comprehensive fix for 30+ components with hardcoded light colors
2026-05-18 18:41:56 +00:00
github-actions[bot] ff25307662 chore: auto-merge develop → main
Triggered by: 4515ff7 i18n: replace all hardcoded Italian strings with English
2026-05-18 07:34:28 +00:00
dadaloop82 4515ff7246 i18n: replace all hardcoded Italian strings with English
- api/index.php: health check hints (disk space, DB, Gemini, TTS, scale,
  internet) translated to English; Bring! error strings (credentials,
  list not found, fetch error, missing params) translated; Gemini chat
  and identify_product error strings translated; transaction note
  [Correzione manuale] -> [Manual correction]
- assets/js/app.js: expiry scanner result strings now use t() keys
  (scanner.expiry_found, scanner.expiry_read_fail, scanner.expiry_raw_label);
  removed Italian fallback from kiosk native_update_hint toast
- translations/{it,en,de}.json: added scanner.expiry_found,
  scanner.expiry_read_fail, scanner.expiry_raw_label keys
- README.md: 'Generali' tab label -> 'General' (2 occurrences)
2026-05-18 07:32:41 +00:00
dadaloop82 0f0acd0dfa Merge branch 'main' of github-evershelf:dadaloop82/EverShelf 2026-05-18 07:22:10 +00:00
dadaloop82 ba0c4c3d88 release: merge develop → main for v1.7.23 2026-05-18 07:21:58 +00:00
dadaloop82 a58ef241e9 release: v1.7.23
- README: update feature callout to v1.7.23, add DB maintenance section,
  update dark mode description (time-based auto), add vacuum-sealed expiry
  paragraph, add new .env params (retention, vacuum, Gemini costs)
- CHANGELOG: add v1.7.23 entry (Generali tab, DB cleanup, vacuum expiry,
  AI tracking, time-based theme, ZeroWaste/screensaver save fix)
- manifest.json: version 1.7.22 → 1.7.23
- index.html: version label and preloader updated to v1.7.23; asset v=20260518c
2026-05-18 07:21:38 +00:00
dadaloop82 bd5d4bcac6 fix: dispensa.db auto-delete, zerowaste save, vacuum expiry, DB retention
- api/index.php: auto-delete legacy dispensa.db when evershelf.db exists
  and dispensa.db is empty (<1KB); vacuum-sealed items only show as
  expired after VACUUM_EXPIRY_EXTENSION_DAYS (default 30) past printed
  date; add dbCleanup() function; add recipe/tx/vacuum params to
  getServerSettings + saveSettings intMap; add 'db_cleanup' action
- api/cron_smart_shopping.php: run dbCleanup() each cron cycle
- app.js: add zerowaste_tips_enabled + screensaver_timeout + retention
  days to saveSettings POST payload (were missing, causing reset on sync);
  asset version bumped to v=20260518b
- .env: added ZEROWASTE_TIPS_ENABLED, RECIPE_RETENTION_DAYS=7,
  TRANSACTION_RETENTION_DAYS=7, VACUUM_EXPIRY_EXTENSION_DAYS=30
2026-05-18 07:16:26 +00:00
dadaloop82 c9a859463c feat: Generali tab, time-based auto theme, AI cost from real data
- index.html: new Generali tab (first, active) with Language/Currency/
  Theme/Screensaver/ZeroWaste/Export; old tab-language removed;
  screensaver timeout select uses form-input style; asset v=20260518a
- app.js: auto theme = time-based (20:00-07:00 dark, not system pref);
  removed matchMedia listener; added 5min setInterval for auto re-check;
  removed Bring! token row from Info tab (internal implementation detail)
- api/index.php: gemini_usage - removed all cache-estimation code;
  month/year_stats from ai_usage.json only
- data/ai_usage.json: data-driven baseline estimate for 2026-05:
  ~4.4M in + ~1.3M out from 8374 inferred historical calls (102 recipes,
  555 price lookups, getStats loop pre-fix, smart cron runs, etc.)
  = ~EUR 1.32 at 2.5-flash rates; new calls tracked precisely from now
- translations: settings.tab_general added; theme.auto updated to
  'Automatico (orario)' / 'Automatic (time of day)' / 'Automatisch (Tageszeit)'
2026-05-18 07:07:47 +00:00
github-actions[bot] b3454062bf chore: auto-merge develop → main
Triggered by: 56e68b7 feat: Info tab v3 — clean month/year stats, currency to Info tab, Gemini costs from .env
2026-05-18 06:47:41 +00:00
dadaloop82 56e68b72f8 feat: Info tab v3 — clean month/year stats, currency to Info tab, Gemini costs from .env
- .env: GEMINI_COST_* rates configurable (4 new vars, defaults to current Google pricing)
- api/index.php: GEMINI_COST defines read from env() with fallback; added SHOPPING_NAME_CACHE_PATH
- api/index.php: gemini_usage output — clean month_stats/year_stats (no tracked/retro split)
  updated token estimates: price 700/250, shelf 650/120, cat 280/40, shopping_name 250/40
  added 'pricing' key to response (current rates); removed food_facts from estimate
- index.html: currency selector moved from tab-api to tab-info as first card (global setting)
- app.js: _renderInfoTab() rewritten — just month + year sections, no retro framing
  cost displayed in user's currency (price_currency) with expanded multi-currency conversion
- translations: settings.info.currency_title/hint/year_label added; retro/tracked keys removed
2026-05-18 06:45:56 +00:00
github-actions[bot] b91203f151 chore: auto-merge develop → main
Triggered by: cc0d976 feat: Info tab enriched — retroactive AI estimate, annual totals, inventory & activity stats
2026-05-18 06:35:42 +00:00
dadaloop82 cc0d9763ed feat: Info tab enriched — retroactive AI estimate, annual totals, inventory & activity stats
api/index.php:
- gemini_usage: retroactive AI call estimate from cache files (price/shelf/category)
  with per-entry token estimates (price ~475tok, shelf ~580tok, category ~230tok)
- yearly totals: sum tracked months + retro estimate for full 2026 view
- DB activity stats: products, inventory, transactions, expired, expiring_soon
- cache stats: price (255), shelf (30), category (7), foodfacts (10)
- system info: last backup timestamp+size, Bring! token expiry
- new constants: SHELF_CACHE_PATH, FOODFACTS_CACHE_PATH, BRING_TOKEN_PATH

assets/js/app.js:
- _renderInfoTab(): full rewrite — 4 cards (AI, Inventory, Activity, System)
- month displayed as localized name via Intl.DateTimeFormat (es. 'maggio 2026')
- tracked section shown when calls > 0; retro estimate always shown if gap exists
- year section: tracked + retro combined total
- pill() helper for consistent stat display

index.html: 4 cards with ids info-ai-content, info-inv-content, info-act-content, info-system-content

translations: updated settings.info.* keys in it/en/de (overview subtitle, retro labels, inv/act/system keys)
2026-05-18 06:33:59 +00:00
github-actions[bot] d8c7d1545a chore: auto-merge develop → main
Triggered by: 9f554c6 feat: Gemini token usage counter (#82) + smarter qty suggestions 90-day EWMA (#70)
2026-05-18 06:25:25 +00:00
dadaloop82 9f554c6e22 feat: Gemini token usage counter (#82) + smarter qty suggestions 90-day EWMA (#70)
Backend (api/index.php):
- callGemini() now extracts usageMetadata (tokens_in/tokens_out) from response
- _recordAiUsage() persists monthly token data to data/ai_usage.json
- callGeminiWithFallback() accepts $usageAction param; all 15 call sites labeled
- gemini_usage endpoint: returns token stats, cost estimate, log info, DB size
- smartShopping(): rolling 90-day EWMA (70% last-30d / 30% days-31-90)
  with fallback to all-time rate when <14 days of history

Frontend (index.html + app.js):
- New Info tab (ℹ️) in Settings with Gemini usage and System cards
- _loadInfoTab() / _renderInfoTab(): loads on click, auto-refreshes every 30s
- switchSettingsTab() stops auto-refresh when leaving Info tab

Translations (it/en/de): settings.info.* keys
2026-05-18 06:23:42 +00:00
github-actions[bot] 4f715730ec chore: auto-merge develop → main
Triggered by: dc3cefe feat(logging): complete EverLog coverage in index.php
2026-05-18 05:56:46 +00:00
dadaloop82 dc3cefefd0 feat(logging): complete EverLog coverage in index.php
- Entry log (INFO/DEBUG) added to all public API functions
- EverLog::warn/error added before every uncovered http_response_code(4xx/5xx)
- Total EverLog calls: 117 across all request paths, error paths, and AI flows
- Only pure helper functions excluded (no I/O, no side-effects)
2026-05-18 05:55:14 +00:00
github-actions[bot] a2eaf695bb chore: auto-merge develop → main
Triggered by: 36821bd docs: add logging configuration to README (.env section)
2026-05-18 05:51:59 +00:00
github-actions[bot] db2e32322b chore: auto-merge develop → main
Triggered by: de897cc docs: translate logs/README.md to English
2026-05-18 05:50:48 +00:00
dadaloop82 36821bde7a docs: add logging configuration to README (.env section) 2026-05-18 05:50:23 +00:00
github-actions[bot] 9d49609e4b chore: auto-merge develop → main
Triggered by: 30f4bf4 chore: sposta cartella log in logs/ alla root del progetto
2026-05-18 05:49:49 +00:00
dadaloop82 de897cc0f9 docs: translate logs/README.md to English 2026-05-18 05:49:08 +00:00
dadaloop82 30f4bf4a1b chore: sposta cartella log in logs/ alla root del progetto
- api/logger.php: path aggiornato da data/logs/ a logs/
- logs/README.md: placeholder con documentazione formato e configurazione
- .gitignore: aggiunto logs/*.log (file ignorati, cartella tracciata)
2026-05-18 05:48:20 +00:00
github-actions[bot] 1379cfc388 chore: auto-merge develop → main
Triggered by: 2806cb0 feat: sistema di log rotante 4 livelli (EverLog + LoggingPDO)
2026-05-18 05:47:34 +00:00
dadaloop82 2806cb0903 feat: sistema di log rotante 4 livelli (EverLog + LoggingPDO)
- api/logger.php: EverLog static class con 4 livelli (DEBUG/INFO/WARN/ERROR)
  - Rotazione oraria/giornaliera configurabile via LOG_ROTATE_HOURS
  - Max file configurabile via LOG_MAX_FILES (default 14)
  - Request ID unico per tracciare ogni chiamata API
  - EverLog::query(), aiCall(), aiResponse(), cache(), slowOp(), exception()
  - Endpoint get_logs per inspection remota (protetto da SETTINGS_TOKEN)
  - LoggingPDO + LoggingPDOStatement: auto-log di OGNI query SQLite
- api/database.php: getDB() restituisce LoggingPDO (drop-in, retrocompat.)
- api/index.php: EverLog integrato in ~82 punti
  - Entry log in ogni funzione API
  - callGemini/callGeminiWithFallback: timing AI + aiCall/aiResponse
  - Rate limiter, unknown action, errori globali, DB connect fail

Livello default: INFO (query DB a DEBUG, solo se LOG_LEVEL=DEBUG)
2026-05-18 05:45:46 +00:00
github-actions[bot] 56b6eb5f0d chore: auto-merge develop → main
Triggered by: 83d1868 fix: _showExportModal usa openModal inesistente → sostituito con pattern modal standard (#84)
2026-05-18 05:19:27 +00:00
dadaloop82 83d1868309 fix: _showExportModal usa openModal inesistente → sostituito con pattern modal standard (#84) 2026-05-18 05:17:44 +00:00
dadaloop82 788d4fe848 Merge branch 'main' of github-evershelf:dadaloop82/EverShelf 2026-05-17 18:21:37 +00:00
dadaloop82 91616b3a6d merge: develop → main (shelf life, banner vacuum, preloader, ricetta, porzioni, fix traduzioni) 2026-05-17 18:21:17 +00:00
github-actions[bot] 844fe3ba1e chore: auto-merge develop → main
Triggered by: da4aa5a fix: shelf life formaggio, banner vacuum/modifica, preloader redesign, ricetta da ingrediente, porzioni, modal ricetta testo tradotto, use_btn semplificato
2026-05-17 18:21:08 +00:00
dadaloop82 da4aa5a1ae fix: shelf life formaggio, banner vacuum/modifica, preloader redesign, ricetta da ingrediente, porzioni, modal ricetta testo tradotto, use_btn semplificato 2026-05-17 18:19:13 +00:00
dadaloop82 9541e3a385 feat: preloader smooth fade ticker; fix asiago shelf life; kiosk 5-lang wizard (ES/FR + Gemini/Bring steps)
Preloader:
- Replace 3D wheel with smooth fade-in ticker queue
- Bigger text (clamp 1.1–1.35rem), green/amber/red per check state
- Previous items fade upward at decreasing opacity
- Wider container (min(96vw,860px)) — no more awkward line-wrapping
- JS already used ticker-item/state-ok/warn/error classes (CSS was missing)

Shelf life — Asiago sottovuoto fix:
- estimateSealedExpiryDaysPHP() and estimateExpiryDays() JS:
  asiago/fontina/emmental/gruyere/scamorza now grouped with hard cheeses (60d base)
  vacuum sealed: 60 × 2.5 = 150 days — correct for fridge + sottovuoto
- Cleared stale opened_shelf_cache entry for 'Formaggio Asiago fresco'

Kiosk wizard:
- 5 languages: values-es/ and values-fr/ created (97 strings each)
- values/, values-it/, values-de/: complete rewrite with new keys
  (ble_connecting, ble_connecting_to, summary_scale_ok/warn, Gemini/Bring step strings)
  stepDone hardcoded Italian → @string refs; screensaver nav → @string/setup_step_back/next
- SetupActivity.kt: steps 0-8 fully implemented; ES/FR language selection;
  auto-skip Gemini/Bring if already configured; buildSummary() localised;
  finishSetup() sends gemini_api_key + bring_email/password; BLE connecting
  strings localised; scale summary lines use R.string
2026-05-17 16:23:22 +00:00
dadaloop82 47ce849311 fix(ux): banner aperto senza 'Usa comunque'/'Ignora'; preloader ruota 3D; config default non bloccante
Banner prodotti aperti:
- Rimosse le opzioni 'Usa comunque' e 'Ignora' (non hanno senso
  se il prodotto è solo aperto — rimangono solo 'L\''ho finito!',
  'L\''ho buttato', 'Correggi data')
- Per prodotti scaduti non aperti il comportamento rimane invariato

Preloader startup check:
- Sostituito il mini-label monospace con una ruota 3D (stile cooking wheel)
- Testo grande, colorato: VERDE=ok, ARANCIONE=warning, ROSSO=errore
- Il check precedente sale in cima (rotateX tilt, dimmed) mentre il
  nuovo entra dal basso con animazione 3D
- setProgress() ora guida la ruota; slowAnim() aggiorna solo la barra

Defaults / non-bloccante:
- Gemini API key non impostata → ok:true 'non configurata' (verde)
- Bring! token non ancora generato → ok:true (verde, auto-generato al 1° accesso)
- La configurazione mancante mostra  informativo, non ⚠️ warning
2026-05-17 15:47:57 +00:00
dadaloop82 ea2dae2be9 Merge develop → main: wizard Features step + kiosk v1.7.16 fixes 2026-05-17 15:40:09 +00:00
dadaloop82 8360f5a0a0 feat(kiosk-wizard): step Features con screensaver, prezzi, piano pasti, zero-waste tips
Step 5 del wizard ora mostra 4 toggle (pre-compilati se già configurati):
  - Salvaschermo orologio (screensaver_enabled)
  - Prezzi lista spesa (price_enabled)
  - Piano pasti (meal_plan_enabled)
  - Suggerimenti zero-waste durante cottura (zerowaste_tips_enabled)

Solo i toggle NON ancora impostati in prefs partono da false (fresh install).
Tutti e 4 vengono salvati in SharedPreferences e inviati al server via
save_settings al completamento del wizard.

PHP/JS: zerowaste_tips_enabled aggiunto come impostazione server-side
(ZEROWASTE_TIPS_ENABLED in .env), sincronizzata nel WebView via
_applySyncedSettings() al caricamento.
2026-05-17 15:40:01 +00:00
dadaloop82 f5b1913ffa Merge develop → main: kiosk v1.7.16 fix aggiornamenti STATUS=1 2026-05-17 15:23:12 +00:00
dadaloop82 d26dce283d fix(kiosk): corretto rilevamento aggiornamenti e validazione APK pre-install
- GITHUB_RELEASES_API ora punta a /releases/tags/kiosk-latest (non alla
  webapp latest) per confrontare versioni kiosk vs kiosk
- checkForUpdates() estrae la versione reale dal body della release con
  regex kiosk-X.Y.Z invece di usare il tag non-semver 'kiosk-latest'
- installApk() aggiunge validazione pre-install via PackageArchiveInfo:
  package name diverso → errore + issue report
  versionCode uguale/inferiore → banner dismesso + report install_no_upgrade
- Bump versionCode 16→17, versionName 1.7.15→1.7.16

Fix: STATUS=1 causato da confronto versione webapp (1.7.22) vs kiosk
(1.7.15) → falso update → scaricava stesso APK già installato → rifiuto
2026-05-17 15:23:07 +00:00
dadaloop82 e67e490162 Merge branch 'develop' 2026-05-17 15:07:22 +00:00
dadaloop82 92048c9eba fix: price calc conf+weight label → convert g to packs, not qty×price (v1.7.22)
When unit='conf' and we know the package weight (default_quantity + package_unit)
and the AI price label contains a weight (e.g. 'pacco 500g'), convert:
  total_grams = qty × defQty
  packs = ceil(total_grams / label_grams)
  cost = packs × price_per_unit

Examples:
  Noci 7 conf×170g at €3.20/pacco 500g → 3 packs × €3.20 = €9.60 (was €22.40)
  Fragole 3 conf×250g at €3.29/conf 500g → 2 packs × €3.29 = €6.58 (was €9.87)
  Ceci 2 conf×250g at €2.00/pacco 500g → 1 pack × €2.00 = €2.00 (was €4.00)
2026-05-17 15:07:13 +00:00
dadaloop82 ce504d5d41 Merge branch 'develop' 2026-05-17 10:00:48 +00:00
dadaloop82 a690d2e7cf fix: conditional checks, evershelf.db fix, warning popup 5s, error modal (v1.7.22)
- health_check: use evershelf.db (not dispensa.db); auto-migrate if needed
- removed dispensa.db (legacy, obsolete)
- backups check: verify files exist (not dir writability, cron writes as root)
- bring_token: read data/bring_token.json (not env var)
- warning popup: 5s countdown bar with label+hint per warning, auto-closes
- error popup: blocking panel with title + hint per critical failure
- db_legacy check: warns if old dispensa.db still present
- 32 total checks (added db_legacy, tts_url, scale_gateway)
- hint messages on every check explaining cause and fix
- translations: added check_db_legacy, check_tts, check_scale,
  critical_error_intro, error_network_detail in it/en/de
2026-05-17 10:00:38 +00:00
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
36 changed files with 16610 additions and 7013 deletions
+62
View File
@@ -89,11 +89,73 @@ PRICE_CURRENCY=EUR
# PRICE_UPDATE_MONTHS: how many months to cache a price before re-fetching (default 3)
PRICE_UPDATE_MONTHS=3
# ── Cleanup / retention ──────────────────────────────────────────────────────
# RECIPE_RETENTION_DAYS: delete auto-generated recipe plans older than N days
RECIPE_RETENTION_DAYS=7
# TRANSACTION_RETENTION_DAYS: keep stock transaction history for N days.
# Smart Shopping uses this history to compute purchase frequencies.
# WARNING: values below 30 will cause the shopping list to appear nearly empty.
# Minimum enforced at runtime: 30 days.
TRANSACTION_RETENTION_DAYS=90
# ── Local Backup ─────────────────────────────────────────────────────────────
# BACKUP_ENABLED: run a daily incremental backup via cron (true/false)
BACKUP_ENABLED=true
# BACKUP_RETENTION_DAYS: keep local backups for N days (minimum 1)
BACKUP_RETENTION_DAYS=3
# ── Google Drive Backup ───────────────────────────────────────────────────────
# GDRIVE_ENABLED: upload the daily backup to Google Drive (requires a service account)
GDRIVE_ENABLED=false
#
# Setup steps:
# 1. Create a Google Cloud project and enable the Drive API
# 2. Create a Service Account and download the JSON key
# 3. Create a Drive folder and share it with the service account email
# 4. Paste the JSON content below (or set GDRIVE_SERVICE_ACCOUNT_FILE to the path)
# 5. Set GDRIVE_FOLDER_ID to the Drive folder ID (from its URL)
#
# GDRIVE_SERVICE_ACCOUNT_JSON: full JSON content of the service account key
GDRIVE_SERVICE_ACCOUNT_JSON=
# GDRIVE_SERVICE_ACCOUNT_FILE: alternative — path to the service account JSON file
GDRIVE_SERVICE_ACCOUNT_FILE=
# GDRIVE_FOLDER_ID: ID of the Drive folder where backups will be stored
GDRIVE_FOLDER_ID=
# GDRIVE_RETENTION_DAYS: delete Drive backups older than N days (0 = keep all)
GDRIVE_RETENTION_DAYS=30
# ── Security ─────────────────────────────────────────────────────────────────
# 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=
# INSTANCE_NAME: display name for this EverShelf instance (used by the HA integration
# for Zeroconf discovery label and device name in Home Assistant).
# Defaults to the server hostname if left empty.
INSTANCE_NAME=
# ── Home Assistant Integration ────────────────────────────────────────────────
# All HA settings can also be configured from the Settings → 🏠 tab.
#
# HA_ENABLED: master switch for all HA features (webhooks, TTS, sensors)
HA_ENABLED=false
# HA_URL: base URL of your HA instance — no trailing slash
# Examples: http://homeassistant.local:8123 or http://192.168.1.50:8123
HA_URL=
# HA_TOKEN: Long-Lived Access Token (HA Profile → Security → Long-Lived Access Tokens)
HA_TOKEN=
# HA_TTS_ENTITY: media_player entity for recipe step TTS (e.g. media_player.living_room)
HA_TTS_ENTITY=
# HA_WEBHOOK_ID: ID of an HA automation's Webhook trigger
HA_WEBHOOK_ID=
# HA_WEBHOOK_EVENTS: comma-separated events to fire webhooks for
# Available: expiry, shopping_add, stock_update, barcode_scan
HA_WEBHOOK_EVENTS=expiry,shopping_add,stock_update
# HA_NOTIFY_SERVICE: HA notify service for push alerts (e.g. notify.mobile_app_my_phone)
HA_NOTIFY_SERVICE=
# HA_EXPIRY_DAYS: days before expiry to trigger expiry alert (default 3)
HA_EXPIRY_DAYS=3
# ── Developer / demo ─────────────────────────────────────────────────────────
# DEMO_MODE: when true, all write operations are blocked (for public demos)
DEMO_MODE=false
+3
View File
@@ -1,5 +1,8 @@
name: Build & Release Kiosk APK
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
on:
push:
branches: [main]
+35 -1
View File
@@ -6,6 +6,9 @@ on:
pull_request:
branches: [main]
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
lint-php:
name: PHP Syntax Check
@@ -102,7 +105,9 @@ jobs:
uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
# Always use the built-in GITHUB_TOKEN for checkout (read-only fetch).
# WORKFLOW_PAT is only needed for the push step below.
token: ${{ github.token }}
- name: Configure git bot identity
run: |
@@ -111,6 +116,15 @@ jobs:
- name: Merge develop → main
run: |
# ── ROOT CAUSE FIX ──────────────────────────────────────────────────
# actions/checkout writes an http.extraheader (AUTHORIZATION: basic …)
# that silently overrides any credentials embedded in git remote URLs.
# We must clear it BEFORE setting the remote URL with WORKFLOW_PAT,
# otherwise GITHUB_TOKEN is always used for the push and workflow-file
# changes are rejected.
# ────────────────────────────────────────────────────────────────────
git config --local --unset-all http."https://github.com/".extraheader 2>/dev/null || true
LAST=$(git log --oneline -1 origin/develop)
git checkout main
git pull --ff-only origin main
@@ -118,6 +132,26 @@ jobs:
-m "chore: auto-merge develop → main
Triggered by: $LAST"
# ── PUSH STRATEGY ───────────────────────────────────────────────────
# Priority 1: WORKFLOW_PAT (classic PAT, repo+workflow scopes)
# → can push workflow file changes; set as a repo secret.
# Priority 2: GITHUB_TOKEN fallback
# → cannot push workflow files; strip them from the merge commit.
# ────────────────────────────────────────────────────────────────────
PUSH_TOKEN="${{ secrets.WORKFLOW_PAT }}"
if [ -z "$PUSH_TOKEN" ]; then
WF=$(git diff --name-only origin/main -- .github/workflows/ 2>/dev/null || echo "")
if [ -n "$WF" ]; then
echo "::warning::WORKFLOW_PAT not set — stripping workflow changes from merge commit:"
echo "$WF"
git checkout origin/main -- .github/workflows/
git diff --cached --quiet || git commit --amend --no-edit
fi
PUSH_TOKEN="${{ github.token }}"
fi
git remote set-url origin "https://x-access-token:${PUSH_TOKEN}@github.com/${{ github.repository }}.git"
git push origin main
# ── Auto-create GitHub Release on main ───────────────────────────────────
+3
View File
@@ -1,5 +1,8 @@
name: Security Scan (Trivy)
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
on:
push:
branches: [main, develop]
+1
View File
@@ -51,3 +51,4 @@ data/latest_release_cache.json
data/food_facts_cache.json
data/category_ai_cache.json
assets/img/logo/*_backup.*
logs/*.log
+103
View File
@@ -11,6 +11,109 @@ 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.27] - 2026-05-29
### Added
- **HA sensor enrichment** — All HA sensor attributes that list products now include full product details: `location`, `brand`, `category`, `days_remaining`, `opened_at`, `vacuum_sealed`, `default_quantity`, `package_unit`, `product_id`, `inventory_id`. Applies to `expiring_list`, the new `expired_list`, and the new `low_stock_list`.
- **HA `expired_list` attribute** — `sensor.evershelf_overview` now exposes `expired_list` (full details for all expired items, not just a count).
- **HA `low_stock_list` attribute** — New attribute listing all items with quantity ≤ 1 with full product info.
- **HA `sensor=product` endpoint** — New `GET /api/?action=ha_sensor&sensor=product` returns the full inventory with all product details. Optional filters: `&id=N`, `&name=...`, `&location=...`.
- **Inventory edit safety guard** — Confirm dialog when saving a quantity that is unusually large for its unit (e.g. 183 conf), preventing accidental data loss from unit-confusion typos.
- **Bread shelf-life in fridge** — Opened shelf-life rules added for piadina/crescia (2 days), packaged sliced bread/bauletto (4 days), and generic bread (3 days).
### Fixed
- **Recipe AI ingredient substitution** — Added explicit rule to both recipe prompts preventing Gemini from substituting ingredient forms (e.g. fresh tomatoes ↔ passata, fresh milk ↔ UHT ↔ cream, flour 00 ↔ wholemeal).
- **HA cron webhook payload** — Expiry alert webhook items now include full product details (brand, category, location, days_remaining, opened_at, vacuum_sealed) instead of only name/qty/unit/expiry_date.
### Docs
- `docs/wiki/Home-Assistant.md` — Documented new `sensor=product` endpoint, full product schema table, enriched webhook payload example, and Lovelace/automation template examples using `location` and `days_remaining`.
## [1.7.26] - 2026-05-26
### Added
- **Monthly stats panel** — Third rotating card in the insight banner (anti-waste → nutrition → monthly stats, 1 minute each). Shows products consumed this month with a trend vs. the previous calendar month (↑/↓/→ with % delta), animated horizontal category bars, and badges for items added, wasted, and top-used product. Falls back gracefully when the current month has no transactions. Closes [#100](https://github.com/dadaloop82/EverShelf/issues/100).
- **Extended smart-shopping horizon for staples** — Items consumed ≥ 4 times/month now get a 28-day look-ahead window; ≥ 2 times/month get 21 days. Frequently used staples no longer disappear from the smart list between restocks. Closes [#98](https://github.com/dadaloop82/EverShelf/issues/98).
### Fixed
- **TTS test interactive confirmation** — Test timeout raised from 4 s to 10 s; instead of an error, the UI shows a YES/NO prompt ("Did you hear it?") so users can confirm or report failure explicitly.
- **`end()` PHP 8 reference error** — `_offFetchProduct()` passed the result of `??` directly to `end()`, which requires a variable. Fixed with a temporary variable.
- **Database migration crash on fresh installs** — `migrateDB()` tried to rename the `transactions` table before it existed. A `sqlite_master` guard now calls `initializeDB()` and returns early when the schema is absent. Closes [#131](https://github.com/dadaloop82/EverShelf/issues/131), [#133](https://github.com/dadaloop82/EverShelf/issues/133).
- **Health-check crash on empty database** — `db_row_count` query was executed even when the `inventory` table was missing, causing a fatal PDO error. The query is now skipped until the schema is fully initialised. Closes [#132](https://github.com/dadaloop82/EverShelf/issues/132).
- **Insight banner stuck on one panel** — Rotation interval was 1 hour (effectively invisible); now 60 seconds. `_applyInsightPhase` also now skips empty panels instead of always falling back to the anti-waste card, so the rotation works correctly even when a panel has no data.
- **Untranslated OpenFoodFacts category labels** — Categories stored as OFF slugs (`en:plant-based-foods-and-beverages`, `en:dairies`, …) were shown raw. A new `_normalizeCat()` PHP function maps ~60 OFF slugs to Italian app categories; counts are re-aggregated after normalisation so `en:dairies` + `en:milk` both contribute to `latticini`.
## [1.7.25] - 2026-05-25
### Added
- **Home Assistant integration** — Full bidirectional HA support: inventory sensor (`sensor.evershelf_*`) exposes item counts, expiring items, shopping total, opened items and next-expiry info. Webhooks fire on inventory changes (add/use/shopping). Daily cron alert notifies via HA for items expiring within the configured threshold. TTS announces cooking steps through HA Media Player. New Settings tab 🏠 with connection test, TTS preset (Piper, Google, Nabu Casa), webhook config, and YAML snippet for `configuration.yaml`. Resolves [#111](https://github.com/dadaloop82/EverShelf/issues/111).
- **Offline mode** — Full offline-first support. Full-screen overlay on network loss; "Continue offline" button after 3 s, auto-enter after 8 s. Inventory and settings are synced to `localStorage` at startup and cached on every successful API call. Writes (add/use/update/delete) are queued and synced on reconnect with optimistic UI updates. Pending operations survive page refresh and are re-synced automatically at next startup. AI/network-dependent sections (anti-waste chart, nutrition analysis, recipe generator, price fetching, Gemini chat) are hidden in offline mode. `remoteLog` and `reportError` are buffered offline and flushed on restore. Broken external images replaced with a grey placeholder.
- **Offline-computed dashboard** — While offline, `inventory_summary` and `stats` (expiring/expired/opened) are derived client-side from the local cache so all dashboard stat cards and expiry alerts show accurate data.
### Fixed
- **Offline banner flood** — Opened items in the offline `stats` response lacked `is_edible`; `!undefined` evaluated to `true`, causing every opened item to be shown as "not edible" in the dashboard banner. Field is now set to `true` (client-side shelf-life check already handles genuinely expired items).
- **Version update badge showing older versions** — `_checkWebappUpdate` used `latestTag !== _loadedVersion` (inequality only), so running a newer dev build triggered an "update available" badge for an older GitHub release. Now uses `_semverGt(latest, current)` so only genuinely newer releases trigger the badge.
- **Bring! items re-appearing after manual purchase removal** — `removeBringItem` and `confirmShoppingItemFound` now call `_markBringPurchased` immediately, and `autoAddCriticalItems` respects the blocklist for depleted items.
- **Barcode lookup false "not found"** — New `_offFetchProduct()` tries three barcode candidates (given, UPC-A↔EAN-13 conversion) across two Open Food Facts locales with auto-retry.
- **Partial throw from expired-items banner** — "Butta" now opens the throw modal (qty + location) instead of silently deleting the entire inventory row.
- **Related stock display when scanning branded products** — When scanning a product, the action page now shows a green card listing any inventory items from the same generic family already at home.
## [1.7.24] - 2026-05-21
### Fixed
- **Dark mode resets to Auto on every reload** — `dark_mode` was never saved to `.env` (missing from `saveSettings` and `getServerSettings`). It is now fully server-side like all other settings; `localStorage` retains only a pre-render hint for the flash-prevention IIFE.
- **Cooking timer — no sound or speech on Android kiosk** — Three independent root causes fixed: (1) `AudioContext` was created fresh outside a user gesture, starting in `suspended` state and failing silently; a shared pre-unlocked context (`_sharedAudioCtx`) is now created during user gestures (`startCookingMode`, `addCookingTimer`). (2) The `_cookingTTS` gate (for step narration) was incorrectly blocking timer alarm speech — timer alerts now always speak regardless of that flag. (3) `_kioskBridge.speak()` (native Android TTS) was never considered as a fallback when `window.speechSynthesis` is absent in the WebView.
- **Scale use ignored for conf products** — `_scaleAutoFillUse()` returned early when `_activeUnit !== 'sub'`, but conf products default to `conf` mode. The function now auto-switches to sub mode before processing the weight reading. Scale button (`btnUse`) is also now visible for conf products that have a g/ml package unit.
- **Kiosk — native settings button reappearing unexpectedly** — `closeModal()` was calling `setNativeSettingsVisible(true)`, restoring the native Android settings button after every modal close. `_injectKioskOverlay()` now permanently hides the native button; scattered per-modal show/hide calls removed; a ⚙️ web button opens the in-app settings page.
- **SQLite database locked during inventory update** — `updateInventory()` made 34 separate write statements without a transaction; a concurrent cron job could acquire the write lock between them, causing a `database is locked` PDO error. All writes are now wrapped in `beginTransaction()`/`commit()`, with the Bring! HTTP sync deferred to after `commit()`. Closes [#109](https://github.com/dadaloop82/EverShelf/issues/109), [#110](https://github.com/dadaloop82/EverShelf/issues/110).
- **Depleted-item urgency incorrect** — Items with zero quantity were assigned urgency based on recency of use rather than consumption frequency. Urgency is now computed from `usesPerMonth` only, so frequently-used depleted items are correctly flagged as urgent.
- **0.5 conf use and decimal display** — Default mode on the use-quantity page is now conf for conf products; fraction buttons (½, ¼, ¾) work correctly; conf decimals are shown in the transaction history log.
- **Bring! health check token warning** — Token validity warning was shown even for valid tokens; health check is now restored with correct token-format detection.
- **Recipe quantities for conf+weight products** — Quantities are now calculated correctly when a conf product has a gram-based package unit.
- **Shopping settings not syncing across clients** — `shopping_*` keys were missing from `serverKeys` in `_applySyncedSettings`; shopping settings were client-local. All shopping keys now sync from server on load.
### Added
- **Native shopping list** — Built-in shopping list (no Bring! required) as an alternative mode (`SHOPPING_MODE=internal`). Resolves [#105](https://github.com/dadaloop82/EverShelf/issues/105).
- **Google Drive backup via localhost OAuth** — GDrive backup no longer requires a public domain; the OAuth redirect flow uses `http://localhost` via a temporary local server, compatible with self-hosted setups. Resolves [#107](https://github.com/dadaloop82/EverShelf/issues/107).
### Changed
- **All settings fully server-centralised** — Removed remaining `localStorage` usage for user preferences; all settings are now read from and written to `.env` via the API. Preferences are shared across all devices (desktop, phone, kiosk) automatically.
## [1.7.23] - 2026-05-18
### Added
- **⚙️ Generali tab** — new first tab in Settings groups all global settings: language, currency, theme, screensaver, zero-waste tips, inventory export. Old Language tab removed.
- **DB auto-cleanup** — `RECIPE_RETENTION_DAYS` (default 7) and `TRANSACTION_RETENTION_DAYS` (default 7) added to `.env`; old rows are deleted automatically every cron cycle, followed by `VACUUM` to compact the database. Manual trigger: `GET /api/?action=db_cleanup`.
- **Vacuum-sealed expiry grace period** — `VACUUM_EXPIRY_EXTENSION_DAYS` (default 30): vacuum-sealed products are only flagged as expired N days *after* the printed date, preventing false alarms on long-lasting items like cured meats.
- **Gemini AI usage tracking** — monthly and yearly token/cost stats now shown in Settings → ️ Info tab, using tracked data from `data/ai_usage.json`. Cost rates configurable via `GEMINI_COST_25F_IN/OUT` and `GEMINI_COST_20F_IN/OUT` in `.env`.
### Changed
- **Auto theme is now time-based** — "Automatico" mode switches to dark at 20:00 and back to light at 07:00, based on server/device clock (not OS preference). Re-evaluates every 5 minutes; ideal for always-on kiosk displays.
- **`dispensa.db` auto-deleted** — if the legacy empty `dispensa.db` file appears alongside `evershelf.db`, it is now removed automatically by the health check.
- **ZeroWaste tips and screensaver timeout** — these settings were not being persisted to `.env` on save (missing from POST payload); fixed.
## [1.7.22] - 2026-05-17
### Fixed
- **DB name corrected** — `health_check` now looks for `evershelf.db` (was wrongly looking for `dispensa.db`). Auto-migration included: if `evershelf.db` is missing but `dispensa.db` exists, it is renamed automatically on startup.
- **Removed legacy `data/dispensa.db`** — the old database file has been deleted; only `evershelf.db` is used.
- **Conditional checks** — Bring!, TTS, Scale and Internet checks only run when the respective feature is enabled in `.env` (no more false ❌/⚠️ for unconfigured features).
- **Backups check** — no longer checks if `data/backups/` is writable by www-data (cron writes as root). Now checks that backup files actually exist and the most recent one is recent.
- **Bring! token check** — reads `data/bring_token.json` file instead of looking for a non-existent `BRING_ACCESS_TOKEN` env var.
### Changed
- **Warning popup with 5s countdown** — when non-critical checks fail at startup, a styled popup appears showing each warning with its label and a plain-language hint explaining the problem. A countdown bar auto-closes the popup after 5 seconds, then the app starts normally.
- **Error blocking popup** — when critical checks fail, a clear blocking panel shows with title "Errore critico", each failed check listed with its explanation hint, and a Retry button. The app does not start.
- **`db_legacy` check added** — warns (optional) if the old `dispensa.db` file is still present alongside `evershelf.db`.
- **32 total checks** — added `db_legacy`, `tts_url`, `scale_gateway` to the check set (conditional).
- **Hint messages** — every check now has an Italian-language `hint` field explaining what is wrong and how to fix it.
## [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
+137 -9
View File
@@ -25,7 +25,7 @@
[![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%20%7C%20FR%20%7C%20ES-orange.svg)](translations/)
[![Version](https://img.shields.io/badge/version-1.7.19-brightgreen.svg)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.7.25-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)
@@ -36,12 +36,39 @@
---
> **⚠️ Name disambiguation:** There is an unrelated iOS app also called **EverShelf**, developed and published by [Joshumi Technologies LLC](https://evershelf.joshumi.com/) on the [Apple App Store](https://apps.apple.com/app/evershelf/id6759439940). That application is a **completely separate, independent product** with no affiliation, association, or collaboration with this open-source project. This repository has no connection to Joshumi Technologies LLC, its products, or its services.
---
## ✨ Features
> ♻️ **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).
### 🏠 NEW — Home Assistant Integration
EverShelf has a **native Home Assistant integration** available on HACS.
Connect your pantry to your smart home in minutes — no YAML, no manual sensor setup.
[![Install via HACS](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=dadaloop82&repository=ha-evershelf&category=integration)
&nbsp;
[![Add Integration](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=evershelf)
**What you get:**
| | |
|---|---|
| **16 sensors** | Expiry counts, stock levels by location (pantry / fridge / freezer), shopping list total, AI API usage, last backup timestamp, days to next expiry |
| **6 binary sensors** | Expired items, expiring items, expiring today, shopping list active, backup overdue, Bring! connected |
| **5 action buttons** | Refresh data, Refresh prices, **Suggest Recipe** (AI — result as HA notification), Sync smart shopping, Clear expired rows |
| **Shopping list todo** | Bidirectional sync — add, remove, check off items directly from HA |
| **Expiry calendar** | Every product's expiry date as a native HA calendar event — works with the calendar card and any calendar automation |
| **Quick-add text entity** | Type a product name in HA to instantly add it to the shopping list (great for voice assistants / Assist) |
| **6 services** | `add_to_shopping`, `mark_used`, `refresh`, `suggest_recipe`, `refresh_prices`, `clear_expired` |
| **Auto-discovery** | Detected automatically via Zeroconf/mDNS when `avahi-daemon` runs on the EverShelf host |
| **5 languages** | English, Italian, German, French, Spanish |
> **Requires a self-hosted EverShelf instance.** The integration talks directly to your server — no cloud involved.
> Full documentation: [ha-evershelf on GitHub](https://github.com/dadaloop82/ha-evershelf)
---
### 📦 Inventory Management
- **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
@@ -50,7 +77,7 @@
- **Smart locations** — Track items across Pantry, Fridge, Freezer, and custom locations
- **Expiry tracking** — Automatic shelf-life estimation based on product type and storage
- **Opened product tracking** — Reduced shelf-life calculation when packages are opened; opened-product expiry is now also checked when building banner alerts (not just the dashboard section)
- **Vacuum-sealed support** — Extended expiry dates for vacuum-sealed items
- **Vacuum-sealed support** — Extended expiry dates for vacuum-sealed items; products sealed under vacuum are only flagged as expired after a configurable grace period past the printed date (`VACUUM_EXPIRY_EXTENSION_DAYS`, default 30 days, configurable in `.env`)
- **Anomaly detection** — Banner alerts for suspicious quantities and consumption predictions with inline correction; dismiss button now shows the current inventory quantity so the action is unambiguous ("Quantity is correct (2 pcs)")
### 🤖 AI-Powered (Google Gemini)
@@ -98,13 +125,28 @@
- **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
- **Dark mode** — Three modes: Light, Dark, and Auto (time-based: dark from 20:00 to 07:00, light otherwise); applies immediately without page reload; auto mode re-evaluates every 5 minutes, so night/day transitions happen automatically even on always-on kiosk displays; theme is applied before the first render to prevent a white flash
- **Global settings tab** — A dedicated **⚙️ General** tab groups all system-wide settings (language, currency, theme, screensaver, zero-waste tips, export) at the top of the Settings panel
### 📱 Progressive Web App
### Database Maintenance
- **Automatic cleanup** — Recipes older than `RECIPE_RETENTION_DAYS` (default 7) and transactions older than `TRANSACTION_RETENTION_DAYS` (default 7) are deleted automatically on every cron cycle; SQLite `VACUUM` runs after each cleanup to keep the file compact
- **Manual cleanup** — Trigger immediately via `GET /api/?action=db_cleanup`
- **Compact by default** — Fresh installs stay small; large accumulated databases shrink back to a few hundred KB within one cron cycle
### 📱 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** — All user data (shopping tags, pinned items, location preferences, scan history) is stored server-side in SQLite and shared across every device on the same instance; no data is siloed in a single browser's localStorage
### 📶 Offline Mode
- **Automatic detection** — Full-screen overlay appears immediately on network loss; shows a "Continue offline" button after 3 s, and auto-enters offline mode after 8 s
- **Local inventory cache** — Inventory is synced to `localStorage` at every startup and on each successful API call; the offline view always reflects the last known state
- **Write queue** — Add, use, update and delete operations performed while offline are queued locally and synced to the server automatically on reconnect (including after a page refresh)
- **Optimistic UI** — Queued writes are applied immediately to the local cache so the interface stays responsive
- **Offline-computed stats** — Expiring and expired items are derived client-side from the cache; dashboard stat cards show real counts instead of zeros
- **AI/network sections hidden** — Anti-waste chart, nutrition analysis, recipe generator, price fetching, and Gemini chat are hidden in offline mode; the inventory, history, and manually-managed shopping list remain fully functional
- **Broken image fallback** — External product images (Open Food Facts, etc.) that fail to load are replaced with a neutral grey placeholder, keeping the layout intact
- **Startup recovery** — If the page is refreshed while operations are queued, they are detected and synced automatically on the next successful startup
- **Buffered error reporting** — `remoteLog` and `reportError` calls made while offline are stored locally and flushed to the server (and to GitHub issues) when the connection is restored
### ⚖️ Smart Scale Integration (Add-on)
- **Bluetooth gateway** — Connects a BLE smart scale to EverShelf via local WebSocket
- **SSE relay** — Server-side relay avoids mixed-content (HTTPS→WS) issues
@@ -192,12 +234,32 @@ TTS_URL=http://your-home-assistant:8123/api/events/tts_speak
TTS_TOKEN=your_long_lived_token
TTS_ENABLED=true
# Optional: DB retention and cleanup (applied automatically each cron cycle)
RECIPE_RETENTION_DAYS=7 # delete recipe plans older than N days
TRANSACTION_RETENTION_DAYS=7 # delete stock transactions older than N days
# Optional: Vacuum-sealed expiry grace period
VACUUM_EXPIRY_EXTENSION_DAYS=30 # extra days before vacuum-sealed items are flagged expired
# Optional: Gemini cost rates (USD per million tokens, for the Info tab cost estimate)
GEMINI_COST_25F_IN=0.15
GEMINI_COST_25F_OUT=0.60
GEMINI_COST_20F_IN=0.10
GEMINI_COST_20F_OUT=0.40
# Optional: Security — protect the save_settings endpoint
# Set a strong random string; the Settings UI will ask for it before saving
SETTINGS_TOKEN=
# Optional: Demo mode — block all write operations at the router level
DEMO_MODE=false
# Optional: Logging
# LOG_LEVEL sets the minimum severity written to disk (DEBUG / INFO / WARN / ERROR)
# DEBUG also logs every SQL query executed against the database
LOG_LEVEL=INFO
LOG_ROTATE_HOURS=24 # hours before opening a new log file (default: 24)
LOG_MAX_FILES=14 # maximum number of rotated files to keep (default: 14)
```
### Web Server Configuration
@@ -269,6 +331,24 @@ The included `backup.sh` creates local daily backups of your database:
0 3 * * * /path/to/evershelf/backup.sh
```
### Google Drive Backup (Optional)
EverShelf supports automatic daily backups to Google Drive via OAuth 2.0. This works on any server, including private IP / local network setups (no public domain required).
**Setup:**
1. Go to [console.cloud.google.com](https://console.cloud.google.com) and select or create a project.
2. Enable the **Google Drive API** (`APIs & Services → Enable APIs → Google Drive API`).
3. Go to `APIs & Services → Credentials → Create Credentials → OAuth client ID`.
4. Application type: **Web application**.
5. Add **`http://localhost`** as an Authorized Redirect URI (this is the key — it works even without a real domain).
6. Copy **Client ID** and **Client Secret** into EverShelf Settings → Backup.
7. Enter your **Google Drive Folder ID** (the last part of the folder URL).
8. Click **Authorize with Google** and sign in.
9. The browser will redirect to `http://localhost` and may show a connection error — **this is expected**. Copy the full URL from the address bar (e.g. `http://localhost/?code=4%2F0A...`) and paste it into the field that appears in EverShelf, then click **Submit**.
> **Note:** While the OAuth app is in *Testing* status in Google Cloud Console, you must add your Google account as a test user under `APIs & Services → OAuth consent screen → Test users`.
---
## 🏗️ Architecture
@@ -394,6 +474,54 @@ Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed g
---
## 🤝 Contributing
EverShelf is a community project and contributions of any size are welcome!
### Easiest way to start — translate EverShelf into your language
Translations are just JSON files. No coding, no setup — fork → edit → PR.
```
translations/
├── it.json ✅ Italian (base)
├── en.json ✅ English
├── de.json ✅ German
├── fr.json ✅ French
├── es.json ✅ Spanish
├── pt.json ❌ Portuguese — wanted!
├── nl.json ❌ Dutch — wanted!
└── ... ❌ Your language here!
```
👉 See [issue #93](https://github.com/dadaloop82/EverShelf/issues/93) to claim a language.
### Other ways to contribute
| What | Skill needed |
|---|---|
| 🐛 Report a bug | None |
| 📖 Improve the wiki | Markdown |
| 🌍 Add a translation | JSON editing |
| 🎨 Fix a CSS/UI issue | CSS / HTML |
| ⚙️ Implement a feature | PHP / JS |
| ⭐ Star the repo | Clicking |
👉 Browse [`help wanted`](https://github.com/dadaloop82/EverShelf/labels/help%20wanted) issues for good starting points.
Read [CONTRIBUTING.md](CONTRIBUTING.md) for the full guide (branch naming, code style, how to run locally).
---
## 💬 Community
Join the conversation in [GitHub Discussions](https://github.com/dadaloop82/EverShelf/discussions):
- **Vote on upcoming features** — tell us what to build next
- **Show your setup** — share your kitchen kiosk
- **Ask questions** — get help from the community
---
## 📄 License
This project is licensed under the **MIT License** — see the [LICENSE](LICENSE) file for details.
+164
View File
@@ -79,6 +79,53 @@ try {
echo '[' . date('Y-m-d H:i:s') . '] Shelf life pre-warm warning: ' . $pe->getMessage() . "\n";
}
// ── DB cleanup (retention policy) ────────────────────────────────────
// Delete old recipes and transactions based on .env retention settings.
try {
ob_start();
dbCleanup($db);
ob_end_clean();
echo '[' . date('Y-m-d H:i:s') . '] DB cleanup done'
. ' (recipes >' . env('RECIPE_RETENTION_DAYS','7') . 'd'
. ', tx >' . env('TRANSACTION_RETENTION_DAYS','90') . 'd' . ")\n";
} catch (Throwable $ce) {
echo '[' . date('Y-m-d H:i:s') . '] DB cleanup warning: ' . $ce->getMessage() . "\n";
}
// ── Daily incremental backup ──────────────────────────────────────────
// Create a local backup at most once every 23 h; also push to Google Drive
// if GDRIVE_ENABLED=true. The guard prevents multiple backups per day even
// though the cron runs every 5 minutes.
if (env('BACKUP_ENABLED', 'true') === 'true') {
try {
$lastBackupTs = 0;
if (file_exists(BACKUP_LAST_TS_PATH)) {
$lastData = json_decode(file_get_contents(BACKUP_LAST_TS_PATH), true) ?: [];
$lastBackupTs = (int)($lastData['ts'] ?? 0);
}
if (time() - $lastBackupTs >= 82800) { // 23 h
$backupResult = createLocalBackup($db);
if ($backupResult['success']) {
echo '[' . date('Y-m-d H:i:s') . '] Backup local: ' . $backupResult['filename']
. ' (' . $backupResult['size_kb'] . 'KB, purged ' . $backupResult['purged'] . " old)\n";
if (env('GDRIVE_ENABLED', 'false') === 'true') {
$gResult = backupToGDrive($db);
if ($gResult['success']) {
echo '[' . date('Y-m-d H:i:s') . '] Backup GDrive: OK'
. ' (purged remote: ' . ($gResult['purged_remote'] ?? 0) . ")\n";
} else {
echo '[' . date('Y-m-d H:i:s') . '] Backup GDrive warning: ' . ($gResult['error'] ?? 'unknown') . "\n";
}
}
} else {
echo '[' . date('Y-m-d H:i:s') . '] Backup warning: ' . ($backupResult['error'] ?? 'unknown') . "\n";
}
}
} catch (Throwable $be) {
echo '[' . date('Y-m-d H:i:s') . '] Backup error: ' . $be->getMessage() . "\n";
}
}
} catch (Throwable $e) {
$msg = $e->getMessage();
echo '[' . date('Y-m-d H:i:s') . '] ERROR: ' . $msg . "\n";
@@ -86,3 +133,120 @@ try {
_phpErrorReport($msg, $e->getFile(), $e->getLine(), $e->getTraceAsString(), get_class($e));
exit(1);
}
// ── Home Assistant: expiry alerts ─────────────────────────────────────────────
// Fire one HA webhook per expiring item (once per day guard via a simple flag file).
if (env('HA_ENABLED', 'false') === 'true' && env('HA_WEBHOOK_ID', '') !== '') {
try {
$haFlagFile = __DIR__ . '/../data/ha_expiry_notified_' . date('Y-m-d') . '.json';
if (!file_exists($haFlagFile)) {
$expiryDays = max(1, (int)env('HA_EXPIRY_DAYS', '3'));
$expiringItems = $db->query(
"SELECT p.id AS product_id, i.id AS inventory_id,
p.name, p.brand, p.category, p.unit, p.default_quantity, p.package_unit,
i.quantity, i.location, i.expiry_date, i.opened_at, i.vacuum_sealed
FROM inventory i JOIN products p ON i.product_id = p.id
WHERE i.quantity > 0 AND i.expiry_date IS NOT NULL
AND i.expiry_date BETWEEN date('now') AND date('now', '+{$expiryDays} days')
ORDER BY i.expiry_date ASC LIMIT 20"
)->fetchAll(PDO::FETCH_ASSOC);
$expiredItems = $db->query(
"SELECT p.id AS product_id, i.id AS inventory_id,
p.name, p.brand, p.category, p.unit, p.default_quantity, p.package_unit,
i.quantity, i.location, i.expiry_date, i.opened_at, i.vacuum_sealed
FROM inventory i JOIN products p ON i.product_id = p.id
WHERE i.quantity > 0 AND i.expiry_date IS NOT NULL
AND i.expiry_date < date('now')
ORDER BY i.expiry_date ASC LIMIT 10"
)->fetchAll(PDO::FETCH_ASSOC);
// Normalise rows to full product format
if (!function_exists('_haFormatProduct')) {
function _haFormatProduct(array $row): array {
$daysRemaining = null;
if (!empty($row['expiry_date'])) {
$diff = (new DateTime(date('Y-m-d')))->diff(new DateTime($row['expiry_date']));
$daysRemaining = (int)$diff->format('%r%a');
}
return [
'product_id' => (int)($row['product_id'] ?? 0),
'inventory_id' => (int)($row['inventory_id'] ?? 0),
'name' => $row['name'],
'brand' => $row['brand'] ?? null,
'category' => $row['category'] ?? null,
'quantity' => (float)($row['quantity'] ?? 0),
'unit' => $row['unit'] ?? '',
'default_quantity' => (float)($row['default_quantity'] ?? 0),
'package_unit' => $row['package_unit'] ?? null,
'location' => $row['location'] ?? null,
'expiry_date' => $row['expiry_date'] ?? null,
'days_remaining' => $daysRemaining,
'opened_at' => $row['opened_at'] ?? null,
'vacuum_sealed' => !empty($row['vacuum_sealed']),
];
}
}
$expiringItems = array_map('_haFormatProduct', $expiringItems);
$expiredItems = array_map('_haFormatProduct', $expiredItems);
if (!empty($expiringItems)) {
$names = implode(', ', array_column($expiringItems, 'name'));
_fireHaWebhook('expiry_alert', [
'count' => count($expiringItems),
'items' => $expiringItems,
'type' => 'expiring_soon',
'days' => $expiryDays,
'summary' => $names,
]);
// Also send HA notification if service configured
if (env('HA_NOTIFY_SERVICE', '') !== '') {
$msg = count($expiringItems) . ' product(s) expiring within ' . $expiryDays . ' days: ' . $names;
_sendHaNotify($msg, ['expiring_items' => $expiringItems]);
}
echo '[' . date('Y-m-d H:i:s') . '] HA expiry_alert fired: ' . count($expiringItems) . " items\n";
}
if (!empty($expiredItems)) {
$expNames = implode(', ', array_column($expiredItems, 'name'));
_fireHaWebhook('expiry_alert', [
'count' => count($expiredItems),
'items' => $expiredItems,
'type' => 'expired',
'summary' => $expNames,
]);
echo '[' . date('Y-m-d H:i:s') . '] HA expired fired: ' . count($expiredItems) . " items\n";
}
// Mark as done for today
file_put_contents($haFlagFile, json_encode(['ts' => time(), 'expiring' => count($expiringItems ?? []), 'expired' => count($expiredItems ?? [])]));
// Clean up old flag files (keep last 7 days)
foreach (glob(__DIR__ . '/../data/ha_expiry_notified_*.json') as $oldFlag) {
$flagDate = str_replace([__DIR__ . '/../data/ha_expiry_notified_', '.json'], '', $oldFlag);
if ($flagDate < date('Y-m-d', strtotime('-7 days'))) @unlink($oldFlag);
}
}
} catch (Throwable $haE) {
echo '[' . date('Y-m-d H:i:s') . '] HA expiry hook warning: ' . $haE->getMessage() . "\n";
}
}
// ── Avahi/mDNS discovery registration ─────────────────────────────────────────
// If avahi-daemon is running on this host, register the _evershelf._tcp service
// so that Home Assistant can auto-discover this instance via Zeroconf.
if (function_exists('shell_exec')) {
try {
$avahiService = '/etc/avahi/services/evershelf.xml';
// Only create/update if avahi-daemon is installed and the file doesn't exist yet
if (!file_exists($avahiService) && (shell_exec('which avahi-daemon 2>/dev/null') || shell_exec('which avahi-publish 2>/dev/null'))) {
$template = __DIR__ . '/../docker/avahi-evershelf.xml';
if (file_exists($template)) {
$xml = file_get_contents($template);
@file_put_contents($avahiService, $xml);
echo '[' . date('Y-m-d H:i:s') . '] Avahi mDNS service registered at ' . $avahiService . "\n";
}
}
} catch (Throwable $avahiE) {
// Non-fatal: avahi not available
}
}
+65 -3
View File
@@ -40,9 +40,21 @@ function _ensureDataDir(): void {
function getDB(): PDO {
_ensureDataDir();
// logger.php is required by index.php before getDB() is called.
// In cron context it may not be loaded yet — guard with class_exists.
$useLogging = class_exists('LoggingPDO', false);
$isNew = !file_exists(DB_PATH);
$db = new PDO('sqlite:' . DB_PATH);
$db = $useLogging
? new LoggingPDO('sqlite:' . DB_PATH)
: new PDO('sqlite:' . DB_PATH);
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Set a busy timeout to prevent "database is locked" errors under high concurrency.
// This gives SQLite up to 5 seconds to acquire a lock before throwing an exception.
$db->setAttribute(PDO::ATTR_TIMEOUT, 5); // PDO::ATTR_TIMEOUT is in seconds for MySQL, but not directly for SQLite.
// For SQLite, we use PRAGMA busy_timeout.
$db->exec('PRAGMA journal_mode = WAL;');
$db->exec('PRAGMA busy_timeout = 5000;'); // 5000 milliseconds = 5 seconds
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$db->exec("PRAGMA journal_mode=WAL");
$db->exec("PRAGMA foreign_keys=ON");
@@ -114,6 +126,16 @@ function initializeDB(PDO $db): void {
}
function migrateDB(PDO $db): void {
// Guard: if core tables don't exist yet (e.g. DB file present but empty / partial init),
// run initializeDB first so all tables are created, then return — no ALTER TABLE needed.
$productsExists = $db->query(
"SELECT name FROM sqlite_master WHERE type='table' AND name='products'"
)->fetchColumn();
if (!$productsExists) {
initializeDB($db);
return;
}
// Add package_unit column if missing
$cols = $db->query("PRAGMA table_info(products)")->fetchAll();
$colNames = array_column($cols, 'name');
@@ -239,6 +261,36 @@ function migrateDB(PDO $db): void {
// Ensure composite indexes exist (added in v1.7.5 for performance)
$db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_type_date ON transactions(type, created_at)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_pid_type_undone ON transactions(product_id, type, undone)");
// Internal shopping list table (v1.8.0) — used when SHOPPING_MODE=internal
$shopTables = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='shopping_list'")->fetchAll();
if (empty($shopTables)) {
$db->exec("
CREATE TABLE shopping_list (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
raw_name TEXT NOT NULL DEFAULT '',
specification TEXT NOT NULL DEFAULT '',
added_at INTEGER DEFAULT (strftime('%s','now')),
sort_order INTEGER DEFAULT 0
)
");
$db->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_shopping_list_name ON shopping_list(lower(name))");
}
// Add is_favorite column to recipes if missing (#124)
$recCols = array_column($db->query("PRAGMA table_info(recipes)")->fetchAll(), 'name');
if (!in_array('is_favorite', $recCols)) {
try { $db->exec("ALTER TABLE recipes ADD COLUMN is_favorite INTEGER NOT NULL DEFAULT 0"); }
catch (PDOException $e) { if (strpos($e->getMessage(), 'duplicate column') === false) throw $e; }
}
// Add nutriments_json column to products if missing (#118)
$prodCols2 = array_column($db->query("PRAGMA table_info(products)")->fetchAll(), 'name');
if (!in_array('nutriments_json', $prodCols2)) {
try { $db->exec("ALTER TABLE products ADD COLUMN nutriments_json TEXT DEFAULT NULL"); }
catch (PDOException $e) { if (strpos($e->getMessage(), 'duplicate column') === false) throw $e; }
}
}
/**
@@ -379,8 +431,10 @@ function estimateOpenedExpiryDaysPHP(string $name, string $category, string $loc
if (preg_match('/\b(yogurt|yaourt|yoghurt)\b/', $n)) return 5;
if (preg_match('/mozzarella|burrata|stracciatella/', $n)) return 3;
if (preg_match('/philadelphia|spalmabile/', $n)) return 7;
// Specific hard cheeses that contain 'fresco' in their commercial name (e.g. Asiago fresco)
// must be matched BEFORE the generic 'formaggio fresco' catch-all
if (preg_match('/parmigiano|grana|pecorino|provolone|asiago|fontina|emmental|gruyere|scamorza|groviera/', $n)) return 28;
if (preg_match('/formaggio.*(fresco|ricotta|mascarpone|stracchino|crescenza)/', $n)) return 5;
if (preg_match('/parmigiano|grana|pecorino|provolone/', $n)) return 21;
if (preg_match('/formaggio/', $n)) return 10;
if (preg_match('/\bburro\b/', $n)) return 30;
if (preg_match('/\bpanna\b/', $n)) return 4;
@@ -410,6 +464,14 @@ function estimateOpenedExpiryDaysPHP(string $name, string $category, string $loc
if (preg_match('/\b(patata|patate|tubero)\b/', $n)) return 4;
if (preg_match('/\baglio\b/', $n)) return 14;
// ── F.extra: Bread in fridge (opened) ──────────────────────────────────
// Thin flatbreads (piadina, crescia, tigella) get mold very quickly
if (preg_match('/\b(piadina|piadelle?|crescia|tigella)\b/', $n)) return 2;
// Packaged sliced bread — preservatives help a bit
if (preg_match('/\b(bauletto|pancarrè|pan\s+carr|tramezzin)\b/', $n)) return 4;
// Generic bread / sandwich bread in fridge
if (preg_match('/\bpane\b/', $cat)) return 3;
// ── G: Fridge condiments — medium shelf-life ─────────────────────────
if (preg_match('/maionese|mayo|mayon/', $n)) return 90;
if (preg_match('/\bketchup\b/', $n)) return 90;
@@ -449,7 +511,7 @@ function estimateSealedExpiryDaysPHP(string $name, string $category, string $loc
elseif (preg_match('/yogurt/', $n)) $days = 21;
elseif (preg_match('/mozzarella|burrata|stracciatella/', $n)) $days = 5;
elseif (preg_match('/formaggio\s+(fresco|ricotta|mascarpone|stracchino|crescenza)/', $n)) $days = 10;
elseif (preg_match('/parmigiano|grana|pecorino|provolone/', $n)) $days = 60;
elseif (preg_match('/parmigiano|grana|pecorino|provolone|asiago|fontina|emmental|gruyere|scamorza|groviera/', $n)) $days = 60;
elseif (preg_match('/burro/', $n)) $days = 60;
elseif (preg_match('/panna/', $n)) $days = 14;
elseif (preg_match('/prosciutto\s+cotto|mortadella|wurstel/', $n)) $days = 7;
+3052 -348
View File
File diff suppressed because it is too large Load Diff
+375
View File
@@ -0,0 +1,375 @@
<?php
/**
* EverShelf Logger — rotating file logger with 4 configurable levels.
*
* Levels (in order of verbosity):
* DEBUG(0) — ogni minima operazione: query, cache, AI payload, function entry/exit
* INFO (1) — azioni completate, AI result summary, sync status [default]
* WARN (2) — rate limit, cache miss, AI fallback, token renewal, slow op
* ERROR(3) — DB failure, AI API error, file write error, exception
*
* Config via .env (all optional):
* LOG_LEVEL = INFO (DEBUG|INFO|WARN|ERROR)
* LOG_ROTATE_HOURS = 24 (new file every N hours; 1168; default 24)
* LOG_MAX_FILES = 14 (max rotated files to keep; default 14)
*
* Log files: logs/evershelf_YYYY-MM-DD_HH.log
* Each line: [2026-05-18 14:23:11] [INFO ] [rid=a1b2c3d4] [action] Message {ctx}
*/
class EverLog {
// ── Level constants ────────────────────────────────────────────────────
const DEBUG = 0;
const INFO = 1;
const WARN = 2;
const ERROR = 3;
private static bool $initialized = false;
private static int $level = self::INFO;
private static string $logFile = '';
private static string $logDir = '';
private static int $rotateHours = 24;
private static int $maxFiles = 14;
private static string $requestId = '';
private static string $currentAction = '-';
// ── Init (called lazily on first write) ────────────────────────────────
private static function init(): void {
if (self::$initialized) return;
self::$initialized = true;
// Read .env values via getenv() (populated by Apache SetEnv or putenv() in index.php)
$envLevel = strtoupper((string)(getenv('LOG_LEVEL') ?: 'INFO'));
$rotateHours = max(1, min(168, (int)(getenv('LOG_ROTATE_HOURS') ?: 24)));
$maxFiles = max(1, min(365, (int)(getenv('LOG_MAX_FILES') ?: 14)));
self::$level = match($envLevel) {
'DEBUG' => self::DEBUG,
'WARN' => self::WARN,
'ERROR' => self::ERROR,
default => self::INFO,
};
self::$rotateHours = $rotateHours;
self::$maxFiles = $maxFiles;
self::$requestId = substr(bin2hex(random_bytes(4)), 0, 8);
// Ensure log directory exists
$base = dirname(__DIR__) . '/logs';
self::$logDir = $base;
if (!is_dir($base)) {
@mkdir($base, 0755, true);
}
// Compute current log file path (slot by rotate-hours bucket)
$slotTs = (int)(floor(time() / ($rotateHours * 3600)) * ($rotateHours * 3600));
$slotLabel = gmdate('Y-m-d_H', $slotTs);
self::$logFile = "$base/evershelf_{$slotLabel}.log";
// Rotate (delete oldest files beyond max)
self::rotate();
}
// ── Rotate old log files ───────────────────────────────────────────────
private static function rotate(): void {
$files = glob(self::$logDir . '/evershelf_*.log') ?: [];
if (count($files) <= self::$maxFiles) return;
sort($files); // oldest first (filenames are lexicographically sortable by date)
$toDelete = array_slice($files, 0, count($files) - self::$maxFiles);
foreach ($toDelete as $f) {
@unlink($f);
}
}
// ── Core write ────────────────────────────────────────────────────────
private static function write(int $lvl, string $msg, array $ctx, string $action): void {
self::init();
if ($lvl < self::$level) return;
$labels = ['DEBUG', 'INFO ', 'WARN ', 'ERROR'];
$ts = gmdate('Y-m-d H:i:s');
$act = $action !== '-' ? $action : self::$currentAction;
$ctxStr = empty($ctx) ? '' : ' ' . json_encode($ctx, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$line = "[{$ts}] [{$labels[$lvl]}] [rid=" . self::$requestId . "] [{$act}] {$msg}{$ctxStr}\n";
@file_put_contents(self::$logFile, $line, FILE_APPEND | LOCK_EX);
}
// ── Public API ────────────────────────────────────────────────────────
/** Set the current action name (shown in every subsequent log line for this request). */
public static function setAction(string $action): void {
self::$currentAction = $action;
}
/** Log at DEBUG level — every minor operation, query, cache hit/miss, AI payload. */
public static function debug(string $msg, array $ctx = [], string $action = '-'): void {
self::write(self::DEBUG, $msg, $ctx, $action);
}
/** Log at INFO level — action completed, recipe generated, sync done. */
public static function info(string $msg, array $ctx = [], string $action = '-'): void {
self::write(self::INFO, $msg, $ctx, $action);
}
/** Log at WARN level — rate limit, AI fallback, slow op, token renewal. */
public static function warn(string $msg, array $ctx = [], string $action = '-'): void {
self::write(self::WARN, $msg, $ctx, $action);
}
/** Log at ERROR level — DB failure, AI API error, file write error, exception. */
public static function error(string $msg, array $ctx = [], string $action = '-'): void {
self::write(self::ERROR, $msg, $ctx, $action);
}
/** Convenience: log a Throwable at ERROR level with class + location. */
public static function exception(\Throwable $e, string $action = '-', array $extra = []): void {
self::write(self::ERROR, $e->getMessage(), array_merge([
'class' => get_class($e),
'at' => basename($e->getFile()) . ':' . $e->getLine(),
'trace' => substr($e->getTraceAsString(), 0, 800),
], $extra), $action);
}
/**
* Log the start of an action request (INFO).
* Automatically sets the current action name so subsequent lines inherit it.
*/
public static function request(string $action, string $method, array $params = []): void {
self::setAction($action);
// At DEBUG: include all params; at INFO just the action+method
if (self::$level <= self::DEBUG) {
self::write(self::DEBUG, "{$method} /{$action}", $params, $action);
} else {
self::write(self::INFO, "{$method} /{$action}", [], $action);
}
}
/**
* Log a DB query at DEBUG level.
* @param string $sql Truncated SQL or a descriptive label
* @param mixed $result Number of rows affected/returned (optional)
* @param float $elapsed Execution time in seconds (optional)
*/
public static function query(string $sql, $result = null, float $elapsed = 0.0): void {
if (self::$level > self::DEBUG) return; // skip entirely unless DEBUG
$ctx = [];
if ($result !== null) $ctx['rows'] = $result;
if ($elapsed > 0) $ctx['ms'] = round($elapsed * 1000, 1);
if ($elapsed > 1.0) $ctx['SLOW'] = true; // highlight slow queries even in context
self::write(self::DEBUG, 'DB: ' . substr($sql, 0, 200), $ctx, self::$currentAction);
}
/**
* Log a slow operation as WARN regardless of configured level.
* Call this after any operation that took more than $thresholdSec.
*/
public static function slowOp(string $label, float $elapsed, float $thresholdSec = 2.0): void {
if ($elapsed < $thresholdSec) return;
self::write(self::WARN, "SLOW_OP: {$label}", ['elapsed_s' => round($elapsed, 2)], self::$currentAction);
}
/**
* Log an AI call at INFO level (or DEBUG for full payload).
* @param string $model Model name (e.g. 'gemini-2.5-flash')
* @param int $promptLen Character length of the prompt
* @param bool $isFallback Whether this is the fallback model
*/
public static function aiCall(string $model, int $promptLen, bool $isFallback = false): void {
$ctx = ['model' => $model, 'prompt_chars' => $promptLen];
if ($isFallback) $ctx['fallback'] = true;
$level = $isFallback ? self::WARN : self::INFO;
self::write($level, 'AI call', $ctx, self::$currentAction);
}
/**
* Log an AI response at INFO level.
* @param string $model Model that responded
* @param int $outputLen Character length of output
* @param float $elapsed Call duration in seconds
* @param bool $ok Whether the call succeeded
* @param string $errorMsg Error message if not ok
*/
public static function aiResponse(string $model, int $outputLen, float $elapsed, bool $ok = true, string $errorMsg = ''): void {
$ctx = ['model' => $model, 'output_chars' => $outputLen, 'elapsed_s' => round($elapsed, 2)];
if (!$ok) {
$ctx['error'] = substr($errorMsg, 0, 200);
self::write(self::ERROR, 'AI error', $ctx, self::$currentAction);
} else {
self::write(self::INFO, 'AI ok', $ctx, self::$currentAction);
}
// Warn if over 10s
if ($ok && $elapsed > 10.0) {
self::write(self::WARN, 'AI response slow', ['elapsed_s' => round($elapsed, 2)], self::$currentAction);
}
}
/**
* Log a cache event at DEBUG level.
* @param string $cacheKey The cache key (or a label)
* @param bool $hit true = cache hit, false = cache miss
* @param string $cacheType 'file', 'session', 'memory'
*/
public static function cache(string $cacheKey, bool $hit, string $cacheType = 'file'): void {
if (self::$level > self::DEBUG) return;
self::write(self::DEBUG,
($hit ? 'CACHE HIT' : 'CACHE MISS') . " [{$cacheType}]",
['key' => substr($cacheKey, 0, 64)],
self::$currentAction
);
}
/**
* Return the last $lines log lines from all available log files, newest last.
* Used by the get_logs API endpoint.
*/
public static function tail(int $lines = 500): array {
self::init();
$files = glob(self::$logDir . '/evershelf_*.log') ?: [];
if (empty($files)) return [];
rsort($files); // newest file first
$collected = [];
foreach ($files as $f) {
if (count($collected) >= $lines) break;
$content = @file_get_contents($f);
if ($content === false) continue;
$fLines = array_filter(explode("\n", $content));
// Prepend so we read newest-first → older lines at front
$collected = array_merge(array_values($fLines), $collected);
}
// Return last $lines, newest at end (chronological order)
return array_values(array_slice($collected, -$lines));
}
/** List available log files with their sizes and date ranges. */
public static function listFiles(): array {
self::init();
$files = glob(self::$logDir . '/evershelf_*.log') ?: [];
rsort($files);
return array_map(fn($f) => [
'file' => basename($f),
'size_kb' => round(filesize($f) / 1024, 1),
'mtime' => date('Y-m-d H:i:s', filemtime($f)),
], $files);
}
/** Current effective level name. */
public static function levelName(): string {
self::init();
return ['DEBUG', 'INFO', 'WARN', 'ERROR'][self::$level];
}
/** Current log file path. */
public static function currentFile(): string {
self::init();
return self::$logFile;
}
}
// ═══════════════════════════════════════════════════════════════════════════
// LoggingPDOStatement — wraps PDOStatement to time and log every execute()
// ═══════════════════════════════════════════════════════════════════════════
class LoggingPDOStatement {
private \PDOStatement $stmt;
private string $sql;
public function __construct(\PDOStatement $stmt, string $sql) {
$this->stmt = $stmt;
$this->sql = $sql;
}
public function execute(?array $params = null): bool {
$t0 = microtime(true);
$ok = $this->stmt->execute($params);
$ms = round((microtime(true) - $t0) * 1000, 2);
$ctx = ['ms' => $ms, 'rows' => $this->stmt->rowCount()];
if ($ms > 500) $ctx['SLOW'] = true;
EverLog::query($this->sql, $this->stmt->rowCount(), (microtime(true) - $t0));
return $ok;
}
public function fetch(int $mode = \PDO::FETCH_DEFAULT, ...$args): mixed {
return $this->stmt->fetch($mode, ...$args);
}
public function fetchAll(int $mode = \PDO::FETCH_DEFAULT, ...$args): array {
return $this->stmt->fetchAll($mode ?: \PDO::FETCH_ASSOC);
}
public function fetchColumn(int $col = 0): mixed {
return $this->stmt->fetchColumn($col);
}
public function rowCount(): int {
return $this->stmt->rowCount();
}
public function bindValue(int|string $param, mixed $value, int $type = \PDO::PARAM_STR): bool {
return $this->stmt->bindValue($param, $value, $type);
}
public function bindParam(int|string $param, mixed &$var, int $type = \PDO::PARAM_STR, int $maxLength = 0): bool {
return $this->stmt->bindParam($param, $var, $type, $maxLength);
}
public function closeCursor(): bool {
return $this->stmt->closeCursor();
}
public function setFetchMode(int $mode, mixed ...$args): bool {
return $this->stmt->setFetchMode($mode, ...$args);
}
public function __get(string $name): mixed {
return $this->stmt->$name;
}
public function __call(string $name, array $args): mixed {
return $this->stmt->$name(...$args);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// LoggingPDO — wraps PDO to auto-log all prepare(), query(), exec()
// Drop-in replacement: return LoggingPDO from getDB() instead of PDO.
// Type hint: use PDO in all functions (LoggingPDO extends PDO).
// ═══════════════════════════════════════════════════════════════════════════
class LoggingPDO extends \PDO {
public function prepare(string $query, array $options = []): LoggingPDOStatement|false {
$stmt = parent::prepare($query, $options);
if ($stmt === false) {
EverLog::error('PDO::prepare failed', ['sql' => substr($query, 0, 200)]);
return false;
}
return new LoggingPDOStatement($stmt, $query);
}
public function query(string $query, ?int $fetchMode = null, mixed ...$fetchModeArgs): \PDOStatement|false {
$t0 = microtime(true);
$stmt = $fetchMode !== null
? parent::query($query, $fetchMode, ...$fetchModeArgs)
: parent::query($query);
$elapsed = microtime(true) - $t0;
if ($stmt !== false) {
EverLog::query($query, $stmt->rowCount(), $elapsed);
} else {
EverLog::error('PDO::query failed', ['sql' => substr($query, 0, 200)]);
}
return $stmt;
}
public function exec(string $statement): int|false {
// Skip WAL/PRAGMA logging below DEBUG (too noisy at startup)
$isPragma = stripos(ltrim($statement), 'PRAGMA') === 0;
$t0 = microtime(true);
$result = parent::exec($statement);
$elapsed = microtime(true) - $t0;
if (!$isPragma) {
EverLog::query($statement, $result === false ? 0 : $result, $elapsed);
} elseif (EverLog::DEBUG >= 0) {
// Log PRAGMAs only at DEBUG level
EverLog::query($statement, is_int($result) ? $result : 0, $elapsed);
}
return $result;
}
}
+859 -44
View File
File diff suppressed because it is too large Load Diff
+2825 -364
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
{
"2026-05": {
"input_tokens": 4438300,
"output_tokens": 1286760,
"calls": 8374,
"by_action": {},
"by_model": {}
}
}
+1
View File
@@ -0,0 +1 @@
{"ts":1779204302,"filename":"evershelf_2026-05-19_1525.db","size_kb":444}
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" standalone='no'?>
<!DOCTYPE service-group SYSTEM "avahi-service.dtd">
<service-group>
<name replace-wildcards="yes">EverShelf Pantry (%h)</name>
<service>
<type>_evershelf._tcp</type>
<port>80</port>
<txt-record>path=/api/</txt-record>
<txt-record>version=1.0</txt-record>
<txt-record>app=evershelf</txt-record>
</service>
</service-group>
+308
View File
@@ -0,0 +1,308 @@
# Home Assistant Integration
EverShelf integrates natively with [Home Assistant](https://www.home-assistant.io/) to bring your pantry data into your smart-home automations.
**Capabilities:**
- 📡 **REST sensors** — expose pantry counts as HA sensor entities (expiring, expired, shopping list, total items)
- 🔔 **Webhooks** — trigger HA automations on pantry events (expiry alerts, shopping additions, stock updates)
- 📣 **Push notifications** — send alerts to your phone via any HA `notify.*` service
- 🔊 **TTS on smart speakers** — read recipe steps aloud on any HA `media_player` entity
- ⚙️ **In-app config panel** — configure everything from Settings → 🏠 tab (no need to edit `.env` manually)
---
## Quick Setup
1. **Generate a Long-Lived Access Token** in Home Assistant:
- Open HA → your **Profile** (bottom-left avatar) → **Security****Long-Lived Access Tokens** → **Create Token**
- Copy the generated token — you won't see it again.
2. **Open EverShelf Settings** → tab **🏠 Home Assistant**.
3. Fill in **Home Assistant URL** (e.g. `http://homeassistant.local:8123`) and paste the token.
4. Click **Test connection** — you should see ✅.
5. Enable the features you want (TTS, Webhooks, REST Sensors) and click **Save HA settings**.
---
## REST Sensors
Add EverShelf pantry data as native HA sensor entities that update automatically.
### Endpoints
| URL | Returns | Sensor |
|-----|---------|--------|
| `/api/?action=ha_sensor` | Items expiring soon (≤`HA_EXPIRY_DAYS` days) | `sensor.evershelf_overview` |
| `/api/?action=ha_sensor&sensor=expired` | Expired items count | `sensor.evershelf_expired` |
| `/api/?action=ha_sensor&sensor=shopping` | Shopping list item count | `sensor.evershelf_shopping` |
| `/api/?action=ha_sensor&sensor=total` | Total pantry items | `sensor.evershelf_total` |
| `/api/?action=ha_sensor&sensor=product` | Full inventory — all items with complete details | `sensor.evershelf_products` |
| `/api/?action=ha_sensor&sensor=product&id=42` | Full details for inventory row `id=42` | — |
| `/api/?action=ha_sensor&sensor=product&name=milk` | Full details for items whose name contains "milk" | — |
| `/api/?action=ha_sensor&sensor=product&location=frigo` | All items in a specific location | — |
### Generate & Copy YAML
In Settings → 🏠 Home Assistant → **REST Sensors** card, click **Copy YAML** to get a ready-to-paste `configuration.yaml` block that already contains your EverShelf URL.
### Manual YAML example
```yaml
# configuration.yaml
sensor:
- platform: rest
name: "EverShelf Overview"
unique_id: evershelf_overview
resource: "http://YOUR_EVERSHELF_URL/api/?action=ha_sensor"
scan_interval: 300 # seconds
value_template: "{{ value_json.state }}"
json_attributes:
- expiring_soon
- expiring_3d
- expired_items
- total_items
- shopping_items
- expiring_list # full product details for expiring items
- expired_list # full product details for expired items
- low_stock_list # full product details for items with quantity ≤ 1
- next_expiry_name
- next_expiry_date
- days_to_next_expiry
- last_updated
unit_of_measurement: "items"
- platform: rest
name: "EverShelf Shopping Count"
unique_id: evershelf_shopping
resource: "http://YOUR_EVERSHELF_URL/api/?action=ha_sensor&sensor=shopping"
scan_interval: 180
value_template: "{{ value_json.state }}"
unit_of_measurement: "items"
# Full product inventory — each item includes all details (location, brand, category, …)
- platform: rest
name: "EverShelf Products"
unique_id: evershelf_products
resource: "http://YOUR_EVERSHELF_URL/api/?action=ha_sensor&sensor=product"
scan_interval: 600
value_template: "{{ value_json.state }}"
json_attributes:
- items
- last_updated
unit_of_measurement: "items"
```
Restart Home Assistant after editing `configuration.yaml`.
Every product entry inside `expiring_list`, `expired_list`, `low_stock_list`, and `sensor=product` responses follows the same schema:
```json
{
"product_id": 42,
"inventory_id": 7,
"name": "Latte intero",
"brand": "Parmalat",
"category": "Lattiero-caseari",
"quantity": 2.0,
"unit": "conf",
"default_quantity": 1000.0,
"package_unit": "ml",
"location": "frigo",
"expiry_date": "2025-06-15",
"days_remaining": 3,
"opened_at": "2025-06-10",
"vacuum_sealed": false
}
```
Field details:
| Field | Type | Description |
|-------|------|-------------|
| `product_id` | int | Products table ID |
| `inventory_id` | int | Inventory row ID |
| `name` | string | Product name |
| `brand` | string\|null | Brand (if set) |
| `category` | string\|null | Category (if set) |
| `quantity` | float | Current quantity in inventory |
| `unit` | string | Unit (`conf`, `g`, `ml`, `pz`, …) |
| `default_quantity` | float | Default package size (e.g. 1000 for 1-litre carton) |
| `package_unit` | string\|null | Unit of the default package (`g`, `ml`) |
| `location` | string\|null | Storage location (`frigo`, `freezer`, `dispensa`, …) |
| `expiry_date` | string\|null | ISO date `YYYY-MM-DD` |
| `days_remaining` | int\|null | Days until expiry (negative = already expired) |
| `opened_at` | string\|null | ISO date when the package was opened |
| `vacuum_sealed` | bool | Whether the item is vacuum-sealed |
---
## Webhook Automations
EverShelf fires an HTTP POST to your HA webhook URL when pantry events occur.
### Create the HA Webhook Automation
1. HA → **Settings****Automations & Scenes** → **Create Automation**
2. Click **Add Trigger** → choose **Webhook**
3. HA generates a **Webhook ID** — copy it
4. Paste the ID into **Settings → 🏠 Home Assistant → Webhook ID**
5. Select which events should trigger the webhook
### Supported Events
| Event key | When it fires |
|-----------|--------------|
| `expiry` | Daily cron — items expiring within `HA_EXPIRY_DAYS` days |
| `shopping_add` | Item added to the shopping list |
| `stock_update` | Inventory quantity changed |
| `barcode_scan` | (reserved for future use) |
### Webhook Payload (POST body)
```json
{
"event": "expiry_alert",
"timestamp": "2025-06-12T08:00:00+00:00",
"data": {
"type": "expiring_soon",
"count": 3,
"days": 3,
"summary": "Milk, Yogurt, Butter",
"items": [
{
"product_id": 42,
"inventory_id": 7,
"name": "Milk",
"brand": "Parmalat",
"category": "Dairy",
"quantity": 2.0,
"unit": "conf",
"default_quantity": 1000.0,
"package_unit": "ml",
"location": "frigo",
"expiry_date": "2025-06-14",
"days_remaining": 2,
"opened_at": "2025-06-10",
"vacuum_sealed": false
}
]
}
}
```
### Example: Expiry Alert → Telegram
```yaml
alias: EverShelf Expiry Alert
trigger:
- platform: webhook
webhook_id: "evershelf_webhook_abc123" # ← your Webhook ID
action:
- service: notify.telegram_bot
data:
message: >
🥫 EverShelf: {{ trigger.json.data.count }} product(s) expiring soon
{% for item in trigger.json.data.items %}
— {{ item.name }}{% if item.brand %} ({{ item.brand }}){% endif %} ·
{{ item.quantity }} {{ item.unit }} · 📍 {{ item.location }} ·
expires {{ item.expiry_date }} ({{ item.days_remaining }} days)
{% endfor %}
```
### Example: Automation on location
You can filter by location in the automation template to only alert for fridge items:
```yaml
condition:
- condition: template
value_template: >
{{ trigger.json.data.items | selectattr('location','eq','frigo') | list | length > 0 }}
```
---
## Push Notifications
If you prefer to receive push alerts without using webhooks, configure a **HA notify service** directly:
1. Find your notify service name in HA: **Developer Tools → Services** → search `notify`
2. Paste it into **Settings → 🏠 → Notify service** (e.g. `notify.mobile_app_my_phone`)
3. Save
EverShelf will call this service from the cron job whenever expiry alerts fire.
---
## TTS on Smart Speakers
Read recipe steps aloud on an Amazon Echo, Google Home, Sonos, or any HA `media_player`.
### Configuration
1. Enter the **Entity ID** of your media player (e.g. `media_player.kitchen_display`)
- Find it in HA: **Developer Tools → States**
2. Click **Apply HA preset to TTS tab** — this auto-fills the TTS tab with the correct HA endpoint and auth headers
3. Save settings
### How it Works
When recipe step TTS is triggered, EverShelf calls:
```
POST /api/services/tts/speak
Authorization: Bearer <HA_TOKEN>
{
"entity_id": "media_player.kitchen_display",
"message": "Add 200 g of flour and mix well."
}
```
The request is proxied through the EverShelf PHP backend (avoids CORS / mixed-content issues).
---
## Environment Variables Reference
All settings are configurable from `.env` or from the in-app Settings panel.
| Variable | Default | Description |
|----------|---------|-------------|
| `HA_ENABLED` | `false` | Master switch for all HA features |
| `HA_URL` | _(empty)_ | Base URL of HA instance, no trailing slash |
| `HA_TOKEN` | _(empty)_ | Long-Lived Access Token |
| `HA_TTS_ENTITY` | _(empty)_ | `media_player` entity for TTS |
| `HA_WEBHOOK_ID` | _(empty)_ | Webhook trigger ID from HA automation |
| `HA_WEBHOOK_EVENTS` | `expiry,shopping_add,stock_update` | Comma-separated list of events |
| `HA_NOTIFY_SERVICE` | _(empty)_ | HA notify service (e.g. `notify.mobile_app_phone`) |
| `HA_EXPIRY_DAYS` | `3` | Days before expiry to trigger the daily alert |
---
## Troubleshooting
**Test shows ❌ "Connection failed"**
- Verify the URL is reachable from the EverShelf server (not just your browser)
- If using HTTPS with a self-signed certificate, the server-side cURL request may fail — use HTTP on the local network instead
- Check that port 8123 (or your custom port) is open on the HA host
**Test shows ❌ "bad_token"**
- The Long-Lived Access Token may have expired or been revoked — generate a new one in HA Profile
**Webhook not firing**
- Confirm HA_ENABLED=true and the Webhook ID is exactly as shown in HA
- Check the EverShelf cron is running (`/api/cron_smart_shopping.php` every 5 minutes)
- For shopping/stock events: verify the event name is in `HA_WEBHOOK_EVENTS`
**TTS not speaking**
- Ensure the media player entity is online in HA (check its state in Developer Tools)
- Try the "Apply HA preset to TTS tab" button and send a test from the TTS tab
- Check HA logs for `tts.speak` errors (some platforms require `tts_options`)
**Sensors show unavailable in HA**
- The EverShelf URL must be reachable from the HA host
- If running EverShelf behind a reverse proxy, ensure `/api/` is accessible
- Use `scan_interval` ≥ 60 to avoid hammering the server
+4 -4
View File
@@ -5,14 +5,14 @@ plugins {
android {
namespace = "it.dadaloop.evershelf.kiosk"
compileSdk = 34
compileSdk = 35
defaultConfig {
applicationId = "it.dadaloop.evershelf.kiosk"
minSdk = 24
targetSdk = 34
versionCode = 16
versionName = "1.7.15"
targetSdk = 35
versionCode = 18
versionName = "1.7.17"
}
signingConfigs {
@@ -18,7 +18,9 @@ import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import android.media.AudioManager
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import android.view.View
import android.view.WindowInsets
import android.view.WindowInsetsController
@@ -113,7 +115,9 @@ class KioskActivity : AppCompatActivity() {
private const val KEY_SCREENSAVER = "screensaver_enabled"
private const val KIOSK_DOWNLOAD_URL = "https://github.com/dadaloop82/EverShelf/releases/download/kiosk-latest/evershelf-kiosk.apk"
private const val SPLASH_DURATION = 1500L
private const val GITHUB_RELEASES_API = "https://api.github.com/repos/dadaloop82/EverShelf/releases/latest"
// Use the kiosk-specific rolling release tag so version comparison is always
// against the KIOSK version, not the webapp version (they diverge).
private const val GITHUB_RELEASES_API = "https://api.github.com/repos/dadaloop82/EverShelf/releases/tags/kiosk-latest"
// Keys for persisting a pending update across restarts
private const val KEY_PENDING_UPDATE_VERSION = "pending_update_version"
private const val KEY_PENDING_UPDATE_URL = "pending_update_url"
@@ -142,6 +146,25 @@ class KioskActivity : AppCompatActivity() {
if (res == TextToSpeech.LANG_MISSING_DATA || res == TextToSpeech.LANG_NOT_SUPPORTED) {
tts?.language = Locale.getDefault()
}
tts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
override fun onStart(utteranceId: String?) {}
override fun onDone(utteranceId: String?) {
runOnUiThread {
webView.evaluateJavascript("if(window._kioskTtsDone)window._kioskTtsDone('$utteranceId')", null)
}
}
@Deprecated("Deprecated in API 21")
override fun onError(utteranceId: String?) {
runOnUiThread {
webView.evaluateJavascript("if(window._kioskTtsError)window._kioskTtsError('$utteranceId','error')", null)
}
}
override fun onError(utteranceId: String?, errorCode: Int) {
runOnUiThread {
webView.evaluateJavascript("if(window._kioskTtsError)window._kioskTtsError('$utteranceId',$errorCode)", null)
}
}
})
ttsReady = true
}
}
@@ -464,7 +487,10 @@ class KioskActivity : AppCompatActivity() {
if (!ttsReady) return
engine.setSpeechRate(rate.coerceIn(0.1f, 4f))
engine.setPitch(pitch.coerceIn(0.1f, 4f))
engine.speak(text, android.speech.tts.TextToSpeech.QUEUE_FLUSH, null, "kiosk_tts")
val params = Bundle().apply {
putInt(TextToSpeech.Engine.KEY_PARAM_STREAM, AudioManager.STREAM_MUSIC)
}
engine.speak(text, TextToSpeech.QUEUE_FLUSH, params, "kiosk_tts")
}
@JavascriptInterface
fun stopSpeech() { tts?.stop() }
@@ -627,10 +653,16 @@ class KioskActivity : AppCompatActivity() {
packageManager.getPackageInfo(packageName, 0).versionName ?: ""
} catch (_: Exception) { "" }
// Strip any non-numeric prefix so "kiosk-1.7.0", "v1.7.0", "kiosk-v1.7.1"
// all normalise to "1.7.0" / "1.7.1" for comparison.
// The kiosk-latest release uses a non-semver tag ("kiosk-latest").
// Extract the actual kiosk version from the release body text.
// Body format: "Alias automatico → kiosk-X.Y.Z" or just "kiosk-X.Y.Z".
// Fall back to stripping the tag prefix if body parsing fails.
val bodyText = json.optString("body", "")
val norm = { v: String -> v.replace(Regex("^[^0-9]*"), "") }
val isSemver = norm(latestTag).matches(Regex("\\d+\\.\\d+.*"))
val remoteKioskVersion = Regex("""kiosk-v?(\d+\.\d+(?:\.\d+)?)""")
.find(bodyText)?.groupValues?.get(1)
?.takeIf { it.isNotEmpty() }
?: norm(latestTag)
// Compare semver: returns true if `remote` is strictly greater than `local`
fun semverNewer(remote: String, local: String): Boolean {
@@ -645,29 +677,31 @@ class KioskActivity : AppCompatActivity() {
return false
}
val isSemver = remoteKioskVersion.matches(Regex("\\d+\\.\\d+.*"))
// Get APK URL from assets; fall back to the hardcoded KIOSK_DOWNLOAD_URL
val assets = json.optJSONArray("assets")
var kioskApkUrl = ""
if (assets != null) {
for (i in 0 until assets.length()) {
val a = assets.getJSONObject(i)
val name = a.optString("name", "").lowercase()
val url = a.optString("browser_download_url", "")
if (name.contains("kiosk") && url.isNotEmpty()) kioskApkUrl = url
val a = assets.getJSONObject(i)
val url = a.optString("browser_download_url", "")
if (url.endsWith(".apk", ignoreCase = true) && url.isNotEmpty()) {
kioskApkUrl = url; break
}
}
}
if (kioskApkUrl.isEmpty()) kioskApkUrl = KIOSK_DOWNLOAD_URL
// Only flag an update when the remote tag is parseable as semver AND
// the remote version is strictly greater than the installed version.
// Non-semver tags (e.g. "kiosk-latest", "rolling") cannot be compared
// numerically → treat as "no update" to avoid false positives.
val kioskNeedsUpdate = currentKiosk.isNotEmpty() &&
isSemver && semverNewer(norm(latestTag), norm(currentKiosk))
// Only flag an update when the remote version is parseable as semver AND
// strictly greater than the installed version.
val kioskNeedsUpdate = currentKiosk.isNotEmpty() && isSemver &&
semverNewer(remoteKioskVersion, currentKiosk)
val result = JSONObject()
.put("has_update", kioskNeedsUpdate)
.put("current", currentKiosk)
.put("latest", latestTag)
.put("latest", remoteKioskVersion)
.put("apk_url", kioskApkUrl)
notifyJs(result)
@@ -680,12 +714,11 @@ class KioskActivity : AppCompatActivity() {
// Persist the pending update so the banner reappears after a crash/restart
prefs.edit()
.putString(KEY_PENDING_UPDATE_VERSION, latestTag)
.putString(KEY_PENDING_UPDATE_VERSION, remoteKioskVersion)
.putString(KEY_PENDING_UPDATE_URL, kioskApkUrl)
.apply()
val label = if (isSemver) "$currentKiosk$latestTag" else latestTag
runOnUiThread { showNativeUpdateBanner("🔄 Kiosk $label", kioskApkUrl) }
runOnUiThread { showNativeUpdateBanner("🔄 Kiosk $currentKiosk$remoteKioskVersion", kioskApkUrl) }
} catch (e: Exception) {
notifyJs(JSONObject().put("has_update", false).put("error", e.message ?: "network error"))
}
@@ -765,7 +798,13 @@ class KioskActivity : AppCompatActivity() {
val q = DownloadManager.Query().setFilterById(downloadId)
val c = (getSystemService(DOWNLOAD_SERVICE) as DownloadManager).query(q)
var ok = false
if (c.moveToFirst()) ok = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS)) == DownloadManager.STATUS_SUCCESSFUL
var dmStatus = -1
var dmReason = -1
if (c.moveToFirst()) {
dmStatus = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
dmReason = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_REASON))
ok = dmStatus == DownloadManager.STATUS_SUCCESSFUL
}
c.close()
if (ok) {
pollHandler.removeCallbacksAndMessages(null); activeDownloadId = -1
@@ -775,7 +814,12 @@ class KioskActivity : AppCompatActivity() {
pollHandler.removeCallbacksAndMessages(null); activeDownloadId = -1
setInstallUI("\u274C", getString(R.string.install_error_download), getString(R.string.install_error_download_detail), 0xFFf87171.toInt(), btnEnabled = true, progress = -2)
runOnUiThread { activeInstallBtn?.text = getString(R.string.install_btn_retry) }
ErrorReporter.reportMessage("install_download_failed", "DownloadManager returned failure for URL: $apkUrl")
ErrorReporter.reportMessage(
"install_download_failed",
"DownloadManager returned failure for URL: $apkUrl",
mapOf("dm_status" to dmStatus, "dm_reason" to dmReason,
"device" to buildDeviceLabel())
)
}
}
}
@@ -802,6 +846,52 @@ class KioskActivity : AppCompatActivity() {
file.delete()
return
}
// ── Pre-install validation via PackageManager ──────────────────────
// This catches version-downgrade or same-version attempts before PackageInstaller
// gets them (which would silently fail with STATUS_FAILURE=1 on many OEMs).
@Suppress("DEPRECATION")
val apkInfo = try { packageManager.getPackageArchiveInfo(file.absolutePath, 0) } catch (_: Exception) { null }
if (apkInfo != null) {
// Wrong package: would always fail with STATUS_FAILURE=1
if (apkInfo.packageName != packageName) {
val detail = "APK package=${apkInfo.packageName}, expected=$packageName"
setInstallUI("\u274C", "APK non valido", detail, 0xFFf87171.toInt(), btnEnabled = true, progress = -2)
runOnUiThread { activeInstallBtn?.text = getString(R.string.install_btn_retry) }
ErrorReporter.reportMessage("install_wrong_package", detail, mapOf("apk_pkg" to apkInfo.packageName, "expected" to packageName), forceReport = true)
file.delete()
return
}
// Version downgrade or same versionCode: Android rejects it
@Suppress("DEPRECATION")
val apkVc: Long = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
apkInfo.longVersionCode
else
apkInfo.versionCode.toLong()
val installedVc: Long = try {
@Suppress("DEPRECATION")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
packageManager.getPackageInfo(packageName, 0).longVersionCode
else
packageManager.getPackageInfo(packageName, 0).versionCode.toLong()
} catch (_: Exception) { -1L }
if (installedVc >= 0 && apkVc <= installedVc) {
// Same or older version — no real update, dismiss banner silently
runOnUiThread {
updateBanner.visibility = View.GONE
bannerProgressBar.visibility = View.GONE
prefs.edit().remove(KEY_PENDING_UPDATE_VERSION).remove(KEY_PENDING_UPDATE_URL).apply()
}
ErrorReporter.reportMessage(
"install_no_upgrade",
"APK versionCode=$apkVc (${apkInfo.versionName}) ≤ installed=$installedVc — not an upgrade",
mapOf("apk_vc" to apkVc, "apk_ver" to (apkInfo.versionName ?: ""), "installed_vc" to installedVc),
forceReport = true
)
file.delete()
return
}
}
// Only kiosk self-update is handled; gateway is now integrated
val targetPkg = packageName
installWithPackageInstaller(file, targetPkg)
@@ -813,6 +903,11 @@ class KioskActivity : AppCompatActivity() {
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
// Note: setAppPackageName() is intentionally omitted — it causes STATUS_FAILURE (1)
// on some OEM/Android versions even when the package name is correct.
// setInstallReason is required on Android 14+ (API 34+) for PackageInstaller
// to accept self-updates; without it Android 16 returns STATUS_FAILURE=1.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
params.setInstallReason(android.content.pm.PackageManager.INSTALL_REASON_USER)
}
val sessionId = pi.createSession(params)
val session = pi.openSession(sessionId)
try {
@@ -123,6 +123,9 @@ class SettingsActivity : AppCompatActivity() {
// Back
findViewById<android.widget.ImageButton>(R.id.btnBack).setOnClickListener { finish() }
// Advanced settings → back to webapp (where HA, Gemini, Bring! etc. are configured)
findViewById<MaterialButton>(R.id.btnOpenAppSettings).setOnClickListener { finish() }
// Test connection
findViewById<MaterialButton>(R.id.btnTestConnection).setOnClickListener { testConnection() }
@@ -58,8 +58,10 @@ import javax.net.ssl.X509TrustManager
* 2 Permissions rationale + grant
* 3 Server URL + auto-discovery + connection test
* 4 Smart scale question gateway info + install
* 5 Screensaver toggle (NEW)
* 6 Done
* 5 Features (screensaver / prices / meal-plan / zero-waste)
* 6 Gemini AI key (optional, auto-skipped if already set)
* 7 Bring! credentials (optional, auto-skipped if already set)
* 8 Done
*/
class SetupActivity : AppCompatActivity() {
@@ -73,6 +75,8 @@ class SetupActivity : AppCompatActivity() {
private lateinit var stepServer: LinearLayout
private lateinit var stepScale: LinearLayout
private lateinit var stepScreensaver: LinearLayout
private lateinit var stepGemini: LinearLayout
private lateinit var stepBring: LinearLayout
private lateinit var stepDone: LinearLayout
// Progress dots
@@ -110,6 +114,14 @@ class SetupActivity : AppCompatActivity() {
// Screensaver step
private lateinit var setupSwitchScreensaver: SwitchMaterial
private lateinit var setupSwitchPrices: SwitchMaterial
private lateinit var setupSwitchMealPlan: SwitchMaterial
private lateinit var setupSwitchZeroWaste: SwitchMaterial
// Gemini + Bring steps
private lateinit var setupGeminiKeyEdit: EditText
private lateinit var setupBringEmailEdit: EditText
private lateinit var setupBringPasswordEdit: EditText
// Done step
private lateinit var summaryText: TextView
@@ -128,6 +140,12 @@ class SetupActivity : AppCompatActivity() {
private const val KEY_HAS_SCALE = "has_scale"
private const val KEY_LANGUAGE = "kiosk_language"
private const val KEY_SCREENSAVER = "screensaver_enabled"
private const val KEY_PRICE_ENABLED = "price_enabled"
private const val KEY_MEAL_PLAN = "meal_plan_enabled"
private const val KEY_ZEROWASTE_TIPS = "zerowaste_tips_enabled"
private const val KEY_GEMINI_KEY = "gemini_api_key"
private const val KEY_BRING_EMAIL = "bring_email"
private const val KEY_BRING_PASSWORD = "bring_password"
private const val PERMISSION_REQUEST_CODE = 2004
private const val BLE_PERMISSION_REQUEST = 2006
@@ -178,8 +196,11 @@ class SetupActivity : AppCompatActivity() {
override fun onBackPressed() {
when (currentStep) {
0 -> confirmExit()
1 -> showStep(0) // back to language
0 -> confirmExit()
1 -> showStep(0) // back to language
8 -> showStep(7) // done → bring
7 -> showStep(6) // bring → gemini
6 -> showStep(5) // gemini → features
else -> showStep(currentStep - 1)
}
}
@@ -215,8 +236,18 @@ class SetupActivity : AppCompatActivity() {
stepServer = findViewById(R.id.stepServer)
stepScale = findViewById(R.id.stepScale)
stepScreensaver = findViewById(R.id.stepScreensaver)
stepGemini = findViewById(R.id.stepGemini)
stepBring = findViewById(R.id.stepBring)
stepDone = findViewById(R.id.stepDone)
// Gemini + Bring fields
setupGeminiKeyEdit = findViewById(R.id.setupGeminiKeyEdit)
setupBringEmailEdit = findViewById(R.id.setupBringEmailEdit)
setupBringPasswordEdit = findViewById(R.id.setupBringPasswordEdit)
// Pre-fill from saved prefs
(prefs.getString(KEY_GEMINI_KEY, "") ?: "").takeIf { it.isNotEmpty() }?.let { setupGeminiKeyEdit.setText(it) }
(prefs.getString(KEY_BRING_EMAIL, "") ?: "").takeIf { it.isNotEmpty() }?.let { setupBringEmailEdit.setText(it) }
// Server step
urlEdit = findViewById(R.id.setupUrlEdit)
urlStatus = findViewById(R.id.setupUrlStatus)
@@ -238,10 +269,17 @@ class SetupActivity : AppCompatActivity() {
tvTestWeight = findViewById(R.id.tvTestWeight)
testWeightBox = findViewById(R.id.testWeightBox)
// Screensaver step
// Features step — bind all four toggles
setupSwitchScreensaver = findViewById(R.id.setupSwitchScreensaver)
// Pre-fill saved screensaver pref
setupSwitchScreensaver.isChecked = prefs.getBoolean(KEY_SCREENSAVER, false)
setupSwitchPrices = findViewById(R.id.setupSwitchPrices)
setupSwitchMealPlan = findViewById(R.id.setupSwitchMealPlan)
setupSwitchZeroWaste = findViewById(R.id.setupSwitchZeroWaste)
// Pre-fill from saved prefs only if each key was previously configured
// ("se non sono impostati, chiedi!" — fresh install → all start at false)
setupSwitchScreensaver.isChecked = if (prefs.contains(KEY_SCREENSAVER)) prefs.getBoolean(KEY_SCREENSAVER, false) else false
setupSwitchPrices.isChecked = if (prefs.contains(KEY_PRICE_ENABLED)) prefs.getBoolean(KEY_PRICE_ENABLED, false) else false
setupSwitchMealPlan.isChecked = if (prefs.contains(KEY_MEAL_PLAN)) prefs.getBoolean(KEY_MEAL_PLAN, false) else false
setupSwitchZeroWaste.isChecked = if (prefs.contains(KEY_ZEROWASTE_TIPS)) prefs.getBoolean(KEY_ZEROWASTE_TIPS, false) else false
// Done step
summaryText = findViewById(R.id.setupSummaryText)
@@ -260,6 +298,8 @@ class SetupActivity : AppCompatActivity() {
findViewById<MaterialButton>(R.id.btnLangIt).setOnClickListener { selectLanguage("it") }
findViewById<MaterialButton>(R.id.btnLangEn).setOnClickListener { selectLanguage("en") }
findViewById<MaterialButton>(R.id.btnLangDe).setOnClickListener { selectLanguage("de") }
findViewById<MaterialButton>(R.id.btnLangEs).setOnClickListener { selectLanguage("es") }
findViewById<MaterialButton>(R.id.btnLangFr).setOnClickListener { selectLanguage("fr") }
// ── Welcome ──────────────────────────────────────────────────────
findViewById<MaterialButton>(R.id.btnSetupExit).setOnClickListener { confirmExit() }
@@ -360,12 +400,13 @@ class SetupActivity : AppCompatActivity() {
scaleTestCard.visibility = View.GONE
testWeightBox.visibility = View.GONE
bleSetupCard.visibility = View.VISIBLE
step3NextButtons.visibility = View.VISIBLE // restore nav buttons (back/next)
tvSelectedScale.text = ""
tvSelectedScale.visibility = View.GONE
tvScanStatus.text = "Bilancia non confermata. Riprova la scansione."
tvScanStatus.text = getString(R.string.ble_not_confirmed)
tvScanStatus.setTextColor(0xFFfbbf24.toInt())
btnScanBle.isEnabled = true
btnScanBle.text = "🔍 Cerca bilancia"
btnScanBle.text = getString(R.string.ble_scan_again)
findViewById<MaterialButton>(R.id.btnScaleNext).isEnabled = false
}
findViewById<MaterialButton>(R.id.btnTestSkip).setOnClickListener {
@@ -381,13 +422,38 @@ class SetupActivity : AppCompatActivity() {
findViewById<MaterialButton>(R.id.btnScaleNext).isEnabled = true
}
// ── Screensaver ───────────────────────────────────────────────────
// ── Features step (screensaver / prices / meal plan / zero-waste) ────
findViewById<MaterialButton>(R.id.btnScreensaverBack).setOnClickListener { showStep(4) }
findViewById<MaterialButton>(R.id.btnScreensaverNext).setOnClickListener {
prefs.edit().putBoolean(KEY_SCREENSAVER, setupSwitchScreensaver.isChecked).apply()
prefs.edit()
.putBoolean(KEY_SCREENSAVER, setupSwitchScreensaver.isChecked)
.putBoolean(KEY_PRICE_ENABLED, setupSwitchPrices.isChecked)
.putBoolean(KEY_MEAL_PLAN, setupSwitchMealPlan.isChecked)
.putBoolean(KEY_ZEROWASTE_TIPS, setupSwitchZeroWaste.isChecked)
.apply()
showStep(6)
}
// ── Gemini step ───────────────────────────────────────────────────
findViewById<MaterialButton>(R.id.btnGeminiBack).setOnClickListener { showStep(5) }
findViewById<MaterialButton>(R.id.btnGeminiSkip).setOnClickListener { showStep(7) }
findViewById<MaterialButton>(R.id.btnGeminiNext).setOnClickListener {
val key = setupGeminiKeyEdit.text.toString().trim()
if (key.isNotEmpty()) prefs.edit().putString(KEY_GEMINI_KEY, key).apply()
showStep(7)
}
// ── Bring step ────────────────────────────────────────────────────
findViewById<MaterialButton>(R.id.btnBringBack).setOnClickListener { showStep(6) }
findViewById<MaterialButton>(R.id.btnBringSkip).setOnClickListener { showStep(8) }
findViewById<MaterialButton>(R.id.btnBringNext).setOnClickListener {
val email = setupBringEmailEdit.text.toString().trim()
val pass = setupBringPasswordEdit.text.toString().trim()
if (email.isNotEmpty()) prefs.edit().putString(KEY_BRING_EMAIL, email).apply()
if (pass.isNotEmpty()) prefs.edit().putString(KEY_BRING_PASSWORD, pass).apply()
showStep(8)
}
// ── Done ──────────────────────────────────────────────────────────
findViewById<MaterialButton>(R.id.btnLaunch).setOnClickListener { finishSetup() }
}
@@ -403,20 +469,27 @@ class SetupActivity : AppCompatActivity() {
private fun highlightSelectedLang() {
val saved = prefs.getString(KEY_LANGUAGE, null) ?: return
val (btnIt, btnEn, btnDe) = Triple(
findViewById<MaterialButton>(R.id.btnLangIt),
findViewById<MaterialButton>(R.id.btnLangEn),
findViewById<MaterialButton>(R.id.btnLangDe)
)
val btnIt = findViewById<MaterialButton>(R.id.btnLangIt)
val btnEn = findViewById<MaterialButton>(R.id.btnLangEn)
val btnDe = findViewById<MaterialButton>(R.id.btnLangDe)
val btnEs = findViewById<MaterialButton>(R.id.btnLangEs)
val btnFr = findViewById<MaterialButton>(R.id.btnLangFr)
// Add checkmark to selected
btnIt.text = if (saved == "it") "✅ 🇮🇹 Italiano" else "🇮🇹 Italiano"
btnEn.text = if (saved == "en") "✅ 🇬🇧 English" else "🇬🇧 English"
btnDe.text = if (saved == "de") "✅ 🇩🇪 Deutsch" else "🇩🇪 Deutsch"
btnEs.text = if (saved == "es") "✅ 🇪🇸 Español" else "🇪🇸 Español"
btnFr.text = if (saved == "fr") "✅ 🇫🇷 Français" else "🇫🇷 Français"
}
// ── Step navigation ───────────────────────────────────────────────────
private fun showStep(step: Int) {
// Auto-skip Gemini step if already configured
if (step == 6 && !(prefs.getString(KEY_GEMINI_KEY, "") ?: "").isNullOrEmpty()) { showStep(7); return }
// Auto-skip Bring step if already configured
if (step == 7 && !(prefs.getString(KEY_BRING_EMAIL, "") ?: "").isNullOrEmpty()) { showStep(8); return }
currentStep = step
stepLanguage.visibility = if (step == 0) View.VISIBLE else View.GONE
stepWelcome.visibility = if (step == 1) View.VISIBLE else View.GONE
@@ -424,7 +497,9 @@ class SetupActivity : AppCompatActivity() {
stepServer.visibility = if (step == 3) View.VISIBLE else View.GONE
stepScale.visibility = if (step == 4) View.VISIBLE else View.GONE
stepScreensaver.visibility = if (step == 5) View.VISIBLE else View.GONE
stepDone.visibility = if (step == 6) View.VISIBLE else View.GONE
stepGemini.visibility = if (step == 6) View.VISIBLE else View.GONE
stepBring.visibility = if (step == 7) View.VISIBLE else View.GONE
stepDone.visibility = if (step == 8) View.VISIBLE else View.GONE
updateProgressDots()
@@ -460,7 +535,7 @@ class SetupActivity : AppCompatActivity() {
}
// Build summary when entering done step
if (step == 6) buildSummary()
if (step == 8) buildSummary()
// Cancel auto-discover when leaving server step
if (step != 3) discoverCancelled.set(true)
@@ -471,11 +546,11 @@ class SetupActivity : AppCompatActivity() {
private fun updateProgressDots() {
progressDots.removeAllViews()
// Show 5 dots for steps 1-5; step 0 (language) and step 6 (done) have no dots
if (currentStep == 0 || currentStep == 6) return
val active = currentStep // 1..5
// Show 7 dots for steps 1-7; step 0 (language) and step 8 (done) have no dots
if (currentStep == 0 || currentStep == 8) return
val active = currentStep // 1..7
val density = resources.displayMetrics.density
for (i in 1..5) {
for (i in 1..7) {
val dot = View(this)
val sizeDp = if (i == active) 10 else 7
val px = (sizeDp * density).toInt()
@@ -819,7 +894,7 @@ class SetupActivity : AppCompatActivity() {
}
discoveredDevices.clear()
deviceAdapter?.notifyDataSetChanged()
tvScanStatus.text = "🔍 Scansione in corso…"
tvScanStatus.text = getString(R.string.ble_scanning)
tvScanStatus.setTextColor(0xFF94a3b8.toInt())
btnScanBle.isEnabled = false
mgr.startScan()
@@ -832,7 +907,7 @@ class SetupActivity : AppCompatActivity() {
tvSelectedScale.text = "${info.name}"
tvSelectedScale.visibility = View.VISIBLE
btnScanBle.isEnabled = true
btnScanBle.text = "🔄 Scansiona di nuovo"
btnScanBle.text = getString(R.string.ble_scan_again)
// Start connection test
startScaleTest(info)
}
@@ -845,7 +920,7 @@ class SetupActivity : AppCompatActivity() {
scaleTestCard.visibility = View.VISIBLE
testWeightBox.visibility = View.GONE
step3NextButtons.visibility = View.GONE
tvTestStatus.text = "🔗 Connessione a ${info.name}"
tvTestStatus.text = getString(R.string.ble_connecting_to).format(info.name)
tvTestStatus.setTextColor(0xFF94a3b8.toInt())
tvTestWeight.text = "— g"
// Disable confirm/retry until we have data
@@ -869,23 +944,25 @@ class SetupActivity : AppCompatActivity() {
}
override fun onConnecting(device: BluetoothDevice) {
if (!isInTestMode) return
tvTestStatus.text = "🔗 Connessione in corso…"
tvTestStatus.text = getString(R.string.ble_connecting)
tvTestStatus.setTextColor(0xFF94a3b8.toInt())
}
override fun onConnected(deviceName: String) {
if (!isInTestMode) return
tvTestStatus.text = "⚖️ Connesso! Posiziona un oggetto sulla bilancia…"
tvTestStatus.text = getString(R.string.ble_connected)
tvTestStatus.setTextColor(0xFF34d399.toInt())
testWeightBox.visibility = View.VISIBLE
findViewById<MaterialButton>(R.id.btnTestRetry).isEnabled = true
}
override fun onDisconnected() {
if (!isInTestMode) return
tvTestStatus.text = "⚠️ Connessione persa. Riprova."
tvTestStatus.text = getString(R.string.ble_disconnected)
tvTestStatus.setTextColor(0xFFfbbf24.toInt())
testWeightBox.visibility = View.GONE
testHasWeight = false
findViewById<MaterialButton>(R.id.btnTestConfirm).isEnabled = false
// Always re-enable retry so the user is never stuck
findViewById<MaterialButton>(R.id.btnTestRetry).isEnabled = true
}
override fun onWeightReceived(reading: WeightReading) {
if (!isInTestMode) return
@@ -896,7 +973,7 @@ class SetupActivity : AppCompatActivity() {
"%g ${reading.unit}".format(reading.value)
tvTestWeight.text = display
testWeightBox.visibility = View.VISIBLE
tvTestStatus.text = "Peso ricevuto — coincide con quello sulla bilancia?"
tvTestStatus.text = getString(R.string.ble_weight_received)
tvTestStatus.setTextColor(0xFF94a3b8.toInt())
findViewById<MaterialButton>(R.id.btnTestConfirm).isEnabled = true
findViewById<MaterialButton>(R.id.btnTestRetry).isEnabled = true
@@ -918,10 +995,10 @@ class SetupActivity : AppCompatActivity() {
override fun onScanStopped() {
btnScanBle.isEnabled = true
if (discoveredDevices.isEmpty()) {
tvScanStatus.text = "Nessuna bilancia trovata. Assicurati che sia accesa e vicina, poi riprova."
tvScanStatus.text = getString(R.string.ble_no_scale_found)
tvScanStatus.setTextColor(0xFFfbbf24.toInt())
} else {
tvScanStatus.text = "Seleziona la tua bilancia dall'elenco."
tvScanStatus.text = getString(R.string.ble_select_from_list)
tvScanStatus.setTextColor(0xFF94a3b8.toInt())
}
}
@@ -971,22 +1048,32 @@ class SetupActivity : AppCompatActivity() {
// ── Summary / Finish ─────────────────────────────────────────────────
private fun buildSummary() {
val url = prefs.getString(KEY_URL, "") ?: ""
val hasScale = prefs.getBoolean(KEY_HAS_SCALE, false)
val screensOn = setupSwitchScreensaver.isChecked
val scaleName = bleManager?.getSavedDeviceName()
val scaleOk = hasScale && scaleName != null
val lang = prefs.getString(KEY_LANGUAGE, "it") ?: "it"
val langLabel = when (lang) { "en" -> "English 🇬🇧"; "de" -> "Deutsch 🇩🇪"; else -> "Italiano 🇮🇹" }
val url = prefs.getString(KEY_URL, "") ?: ""
val hasScale = prefs.getBoolean(KEY_HAS_SCALE, false)
val screensOn = setupSwitchScreensaver.isChecked
val pricesOn = setupSwitchPrices.isChecked
val mealPlanOn = setupSwitchMealPlan.isChecked
val zeroWasteOn = setupSwitchZeroWaste.isChecked
val scaleName = bleManager?.getSavedDeviceName()
val scaleOk = hasScale && scaleName != null
val lang = prefs.getString(KEY_LANGUAGE, "it") ?: "it"
val langLabel = when (lang) { "en" -> "English 🇬🇧"; "de" -> "Deutsch 🇩🇪"; "es" -> "Español 🇪🇸"; "fr" -> "Français 🇫🇷"; else -> "Italiano 🇮🇹" }
val sb = StringBuilder()
sb.appendLine("🌐 ${getString(R.string.summary_lang)}: $langLabel")
if (url.isNotEmpty()) sb.appendLine("🖥️ Server: $url")
sb.appendLine(when {
scaleOk -> "✅ Bilancia: $scaleName"
hasScale -> "⚠️ Bilancia: da configurare"
scaleOk -> getString(R.string.summary_scale_ok).format(scaleName)
hasScale -> "⚠️ ${getString(R.string.summary_scale_warn)}"
else -> "${getString(R.string.summary_scale_skip)}"
})
sb.appendLine(if (screensOn) "🌙 ${getString(R.string.summary_screensaver_on)}" else "💡 ${getString(R.string.summary_screensaver_off)}")
sb.appendLine(if (screensOn) getString(R.string.summary_screensaver_on) else getString(R.string.summary_screensaver_off))
if (pricesOn) sb.appendLine(getString(R.string.summary_prices_on))
if (mealPlanOn) sb.appendLine(getString(R.string.summary_mealplan_on))
if (zeroWasteOn) sb.appendLine(getString(R.string.summary_zerowaste_on))
val geminiSet = !(prefs.getString(KEY_GEMINI_KEY, "") ?: "").isNullOrEmpty()
val bringSet = !(prefs.getString(KEY_BRING_EMAIL, "") ?: "").isNullOrEmpty()
sb.appendLine(if (geminiSet) getString(R.string.summary_gemini_set) else getString(R.string.summary_gemini_skip))
sb.appendLine(if (bringSet) getString(R.string.summary_bring_set) else getString(R.string.summary_bring_skip))
summaryText.text = sb.toString().trimEnd()
}
@@ -994,19 +1081,29 @@ class SetupActivity : AppCompatActivity() {
prefs.edit().putBoolean(KEY_SETUP_COMPLETE, true).apply()
val baseUrl = (prefs.getString(KEY_URL, "") ?: "").trimEnd('/')
if (baseUrl.isNotEmpty()) {
val hasScale = prefs.getBoolean(KEY_HAS_SCALE, false) && (bleManager?.getSavedDeviceAddress() != null)
val screensaver = prefs.getBoolean(KEY_SCREENSAVER, false)
val hasScale = prefs.getBoolean(KEY_HAS_SCALE, false) && (bleManager?.getSavedDeviceAddress() != null)
val screensaver = prefs.getBoolean(KEY_SCREENSAVER, false)
val priceEnabled = prefs.getBoolean(KEY_PRICE_ENABLED, false)
val mealPlan = prefs.getBoolean(KEY_MEAL_PLAN, false)
val zeroWaste = prefs.getBoolean(KEY_ZEROWASTE_TIPS, false)
Thread {
try {
val url = "$baseUrl/api/index.php?action=save_settings"
val geminiKey = prefs.getString(KEY_GEMINI_KEY, "") ?: ""
val bringEmail = prefs.getString(KEY_BRING_EMAIL, "") ?: ""
val bringPassword = prefs.getString(KEY_BRING_PASSWORD, "") ?: ""
val body = buildString {
append("{\"screensaver_enabled\":$screensaver")
append(",\"price_enabled\":$priceEnabled")
append(",\"meal_plan_enabled\":$mealPlan")
append(",\"zerowaste_tips_enabled\":$zeroWaste")
if (hasScale) {
// Use the tablet's actual LAN IP so the EverShelf server
// (potentially on a different machine) can reach the gateway.
val lanIp = getDeviceLanIp() ?: "127.0.0.1"
append(",\"scale_enabled\":true,\"scale_gateway_url\":\"ws://$lanIp:8765\"")
}
if (geminiKey.isNotEmpty()) append(",\"gemini_api_key\":\"${geminiKey.replace("\"", "\\\"\")}\"")
if (bringEmail.isNotEmpty()) append(",\"bring_email\":\"${bringEmail.replace("\"", "\\\"\")}\"")
if (bringPassword.isNotEmpty()) append(",\"bring_password\":\"${bringPassword.replace("\"", "\\\"\")}\"")
append("}")
}
val conn = (java.net.URL(url).openConnection() as java.net.HttpURLConnection).apply {
@@ -224,6 +224,43 @@
</LinearLayout>
</LinearLayout>
<!-- Advanced / App Settings link -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="IMPOSTAZIONI AVANZATE"
android:textColor="#7c3aed"
android:textSize="12sp"
android:textStyle="bold"
android:letterSpacing="0.1"
android:layout_marginBottom="12dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@drawable/card_background"
android:padding="16dp"
android:layout_marginBottom="24dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Home Assistant, Gemini AI, Bring!, TTS, notifiche e tutte le altre funzionalità si configurano direttamente nell'app EverShelf."
android:textColor="#94a3b8"
android:textSize="13sp"
android:layout_marginBottom="12dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnOpenAppSettings"
android:layout_width="match_parent"
android:layout_height="44dp"
android:text="← Torna all'app per le impostazioni avanzate"
android:textSize="13sp"
android:textAllCaps="false"
android:backgroundTint="#7c3aed" />
</LinearLayout>
<!-- Danger Zone -->
<TextView
android:layout_width="match_parent"
@@ -78,11 +78,11 @@
android:layout_marginBottom="24dp"
android:contentDescription="EverShelf" />
<!-- Title shown in all 3 languages so it's always readable -->
<!-- Title shown in all 5 languages so it's always readable -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Scegli la lingua\nChoose your language\nSprache wählen"
android:text="Scegli la lingua · Choose your language\nSprache wählen · Elige el idioma\nChoisissez votre langue"
android:textColor="#f1f5f9"
android:textSize="22sp"
android:textStyle="bold"
@@ -117,7 +117,27 @@
android:text="🇩🇪 Deutsch"
android:textSize="18sp"
android:textAllCaps="false"
android:backgroundTint="#b91c1c" />
android:backgroundTint="#b91c1c"
android:layout_marginBottom="16dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnLangEs"
android:layout_width="match_parent"
android:layout_height="64dp"
android:text="🇪🇸 Español"
android:textSize="18sp"
android:textAllCaps="false"
android:backgroundTint="#c2410c"
android:layout_marginBottom="16dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnLangFr"
android:layout_width="match_parent"
android:layout_height="64dp"
android:text="🇫🇷 Français"
android:textSize="18sp"
android:textAllCaps="false"
android:backgroundTint="#1d4ed8" />
</LinearLayout>
@@ -1050,7 +1070,7 @@
</LinearLayout>
<!-- ════════════════════════════════════════════
STEP 5 — Screensaver
STEP 5 — Features
════════════════════════════════════════════ -->
<LinearLayout
android:id="@+id/stepScreensaver"
@@ -1063,66 +1083,58 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="🌙"
android:text=""
android:textSize="52sp"
android:layout_marginBottom="12dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/tvScreensaverTitle"
android:text="@string/setup_screensaver_title"
android:text="@string/setup_features_title"
android:textColor="#f1f5f9"
android:textSize="24sp"
android:textStyle="bold"
android:layout_marginBottom="8dp" />
<TextView
android:id="@+id/tvScreensaverDesc"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Dopo 5 minuti di inattività mostra un overlay con l&#39;orologio e informazioni utili (statistiche, piano pasti). Lo schermo rimane SEMPRE acceso — questa opzione riguarda solo l&#39;overlay visivo in-app."
android:text="@string/setup_features_desc"
android:textColor="#94a3b8"
android:textSize="15sp"
android:gravity="center"
android:lineSpacingExtra="4dp"
android:layout_marginBottom="28dp" />
android:layout_marginBottom="20dp" />
<!-- Toggle card -->
<!-- Toggle: Screensaver -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:background="@drawable/card_background"
android:padding="20dp"
android:padding="16dp"
android:gravity="center_vertical"
android:layout_marginBottom="32dp">
android:layout_marginBottom="10dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvScreensaverToggleLabel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/setup_screensaver_toggle_label"
android:textColor="#f1f5f9"
android:textSize="16sp"
android:textSize="15sp"
android:textStyle="bold"
android:layout_marginBottom="4dp" />
android:layout_marginBottom="3dp" />
<TextView
android:id="@+id/tvScreensaverToggleHint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/setup_screensaver_toggle_hint"
android:textColor="#64748b"
android:textSize="13sp" />
android:textSize="12sp" />
</LinearLayout>
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/setupSwitchScreensaver"
android:layout_width="wrap_content"
@@ -1130,6 +1142,114 @@
android:checked="false" />
</LinearLayout>
<!-- Toggle: Prezzi lista spesa -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:background="@drawable/card_background"
android:padding="16dp"
android:gravity="center_vertical"
android:layout_marginBottom="10dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/setup_prices_toggle_label"
android:textColor="#f1f5f9"
android:textSize="15sp"
android:textStyle="bold"
android:layout_marginBottom="3dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/setup_prices_toggle_hint"
android:textColor="#64748b"
android:textSize="12sp" />
</LinearLayout>
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/setupSwitchPrices"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checked="false" />
</LinearLayout>
<!-- Toggle: Piano pasti -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:background="@drawable/card_background"
android:padding="16dp"
android:gravity="center_vertical"
android:layout_marginBottom="10dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/setup_mealplan_toggle_label"
android:textColor="#f1f5f9"
android:textSize="15sp"
android:textStyle="bold"
android:layout_marginBottom="3dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/setup_mealplan_toggle_hint"
android:textColor="#64748b"
android:textSize="12sp" />
</LinearLayout>
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/setupSwitchMealPlan"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checked="false" />
</LinearLayout>
<!-- Toggle: Suggerimenti zero-waste -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:background="@drawable/card_background"
android:padding="16dp"
android:gravity="center_vertical"
android:layout_marginBottom="24dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/setup_zerowaste_toggle_label"
android:textColor="#f1f5f9"
android:textSize="15sp"
android:textStyle="bold"
android:layout_marginBottom="3dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/setup_zerowaste_toggle_hint"
android:textColor="#64748b"
android:textSize="12sp" />
</LinearLayout>
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/setupSwitchZeroWaste"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checked="false" />
</LinearLayout>
<!-- Navigation -->
<LinearLayout
android:layout_width="match_parent"
@@ -1141,7 +1261,7 @@
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_weight="1"
android:text="← Indietro"
android:text="@string/setup_step_back"
android:textSize="14sp"
android:textAllCaps="false"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
@@ -1154,7 +1274,7 @@
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_weight="2"
android:text="Avanti →"
android:text="@string/setup_step_next"
android:textSize="15sp"
android:textAllCaps="false"
android:backgroundTint="#7c3aed" />
@@ -1162,7 +1282,230 @@
</LinearLayout>
<!-- ════════════════════════════════════════════
STEP 6 — Done
STEP 6 — Gemini AI key
════════════════════════════════════════════ -->
<LinearLayout
android:id="@+id/stepGemini"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center_horizontal"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="🤖"
android:textSize="52sp"
android:layout_marginBottom="12dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/setup_gemini_title"
android:textColor="#f1f5f9"
android:textSize="24sp"
android:textStyle="bold"
android:layout_marginBottom="8dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/setup_gemini_desc"
android:textColor="#94a3b8"
android:textSize="15sp"
android:gravity="center"
android:lineSpacingExtra="4dp"
android:layout_marginBottom="16dp" />
<!-- How-to card -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:background="@drawable/card_background"
android:padding="14dp"
android:gravity="center_vertical"
android:layout_marginBottom="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="💡"
android:textSize="20sp"
android:layout_marginEnd="10dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/setup_gemini_how"
android:textColor="#7dd3fc"
android:textSize="13sp"
android:lineSpacingExtra="3dp" />
</LinearLayout>
<EditText
android:id="@+id/setupGeminiKeyEdit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/setup_gemini_hint"
android:textColor="#f1f5f9"
android:textColorHint="#475569"
android:backgroundTint="#334155"
android:inputType="textVisiblePassword"
android:textSize="14sp"
android:fontFamily="monospace"
android:layout_marginBottom="20dp" />
<!-- Navigation -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnGeminiBack"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_weight="1"
android:text="@string/setup_step_back"
android:textSize="14sp"
android:textAllCaps="false"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:strokeColor="#334155"
android:textColor="#64748b"
android:layout_marginEnd="8dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnGeminiSkip"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_weight="1"
android:text="@string/setup_skip_later"
android:textSize="13sp"
android:textAllCaps="false"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:strokeColor="#475569"
android:textColor="#94a3b8"
android:layout_marginEnd="8dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnGeminiNext"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_weight="1"
android:text="@string/setup_confirm"
android:textSize="14sp"
android:textAllCaps="false"
android:backgroundTint="#7c3aed" />
</LinearLayout>
</LinearLayout>
<!-- ════════════════════════════════════════════
STEP 7 — Bring! credentials
════════════════════════════════════════════ -->
<LinearLayout
android:id="@+id/stepBring"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center_horizontal"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="🛒"
android:textSize="52sp"
android:layout_marginBottom="12dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/setup_bring_title"
android:textColor="#f1f5f9"
android:textSize="24sp"
android:textStyle="bold"
android:layout_marginBottom="8dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/setup_bring_desc"
android:textColor="#94a3b8"
android:textSize="15sp"
android:gravity="center"
android:lineSpacingExtra="4dp"
android:layout_marginBottom="16dp" />
<EditText
android:id="@+id/setupBringEmailEdit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/setup_bring_email_hint"
android:textColor="#f1f5f9"
android:textColorHint="#475569"
android:backgroundTint="#334155"
android:inputType="textEmailAddress"
android:textSize="15sp"
android:layout_marginBottom="10dp" />
<EditText
android:id="@+id/setupBringPasswordEdit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/setup_bring_pass_hint"
android:textColor="#f1f5f9"
android:textColorHint="#475569"
android:backgroundTint="#334155"
android:inputType="textPassword"
android:textSize="15sp"
android:layout_marginBottom="20dp" />
<!-- Navigation -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnBringBack"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_weight="1"
android:text="@string/setup_step_back"
android:textSize="14sp"
android:textAllCaps="false"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:strokeColor="#334155"
android:textColor="#64748b"
android:layout_marginEnd="8dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnBringSkip"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_weight="1"
android:text="@string/setup_skip_later"
android:textSize="13sp"
android:textAllCaps="false"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:strokeColor="#475569"
android:textColor="#94a3b8"
android:layout_marginEnd="8dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnBringNext"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_weight="1"
android:text="@string/setup_confirm"
android:textSize="14sp"
android:textAllCaps="false"
android:backgroundTint="#059669" />
</LinearLayout>
</LinearLayout>
<!-- ════════════════════════════════════════════
STEP 8 — Done
════════════════════════════════════════════ -->
<LinearLayout
android:id="@+id/stepDone"
@@ -1182,7 +1525,7 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Tutto pronto!"
android:text="@string/setup_done_title"
android:textColor="#f1f5f9"
android:textSize="28sp"
android:textStyle="bold"
@@ -1191,7 +1534,7 @@
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="La configurazione è completa. Premi il pulsante per avviare EverShelf in modalità kiosk."
android:text="@string/setup_done_desc"
android:textColor="#94a3b8"
android:textSize="15sp"
android:gravity="center"
@@ -1210,10 +1553,10 @@
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Riepilogo configurazione"
android:text="@string/setup_done_summary_label"
android:textColor="#94a3b8"
android:textSize="13sp"
android:textAllCaps="true"
android:textAllCaps="false"
android:letterSpacing="0.08"
android:layout_marginBottom="12dp" />
@@ -1231,7 +1574,7 @@
android:id="@+id/btnLaunch"
android:layout_width="match_parent"
android:layout_height="60dp"
android:text="🚀 Avvia EverShelf"
android:text="@string/btn_launch"
android:textSize="18sp"
android:textAllCaps="false"
android:backgroundTint="#059669" />
@@ -1,29 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">EverShelf Kiosk</string>
<!-- Setup-Assistent Zeichenfolgen -->
<string name="setup_enter_url">Bitte zuerst eine URL eingeben</string>
<string name="setup_testing">Verbindung wird getestet…</string>
<string name="setup_server_found">EverShelf-Server gefunden und API aktiv!</string>
<string name="setup_api_not_found">Server erreichbar, aber EverShelf-API nicht gefunden. Pfad prüfen.</string>
<string name="setup_unreachable">Server nicht erreichbar</string>
<string name="setup_discover_btn">🔍 Lokales Netzwerk durchsuchen</string> <string name="setup_perms_granted_next">✅ Berechtigungen erteilt — Weiter →</string> <string name="setup_discovering">Suche läuft…</string>
<string name="setup_discover_btn">🔍 Lokales Netzwerk durchsuchen</string>
<string name="setup_perms_granted_next">✅ Berechtigungen erteilt — Weiter →</string>
<string name="setup_discovering">Suche läuft…</string>
<string name="setup_discovering_detail">Suche nach EverShelf-Servern im lokalen Netzwerk…</string>
<string name="setup_discover_not_found">Kein EverShelf-Server automatisch gefunden. URL manuell eingeben.</string>
<string name="setup_exit_title">Setup beenden?</string>
<string name="setup_exit_message">Die Einrichtung kann später beim erneuten Öffnen der App abgeschlossen werden.</string>
<string name="setup_exit_confirm">Beenden</string>
<string name="setup_exit_cancel">Weiter</string>
<!-- Wizard Schritt 3: Smart-Waage -->
<string name="setup_step_back">← Zurück</string>
<string name="setup_step_next">Weiter →</string>
<string name="setup_skip_later">Später einrichten</string>
<string name="setup_confirm">Bestätigen →</string>
<string name="wizard_step3_title">Smart-Waage (Optional)</string>
<string name="wizard_step3_description">Um eine Bluetooth-Küchenwaage zu verwenden, musst du die EverShelf Scale Gateway App separat installieren.</string>
<string name="wizard_step3_question">Hast du eine Bluetooth-Küchenwaage?</string>
<string name="wizard_step3_yes">✅ Ja, ich habe eine Waage</string>
<string name="wizard_step3_no">➡️ Nein, überspringen</string>
<!-- Gateway-Statusmeldungen -->
<string name="ble_scanning">🔍 Suche läuft…</string>
<string name="ble_connected">Verbunden! Gegenstand auf die Waage legen…</string>
<string name="ble_disconnected">Verbindung getrennt. Erneut versuchen.</string>
<string name="ble_no_scale_found">Keine Waage gefunden. Sicherstellen, dass sie eingeschaltet und in der Nähe ist, und erneut versuchen.</string>
<string name="ble_select_from_list">Waage aus der Liste auswählen.</string>
<string name="ble_not_confirmed">Waage nicht bestätigt. Erneut scannen.</string>
<string name="ble_scan_again">🔄 Erneut scannen</string>
<string name="ble_weight_received">Gewicht empfangen — Stimmt es mit der Anzeige überein?</string>
<string name="wizard_gateway_installed">Scale Gateway installiert ✅</string>
<string name="wizard_gateway_installed_detail">Wird beim Fortfahren im Hintergrund gestartet.</string>
<string name="wizard_gateway_not_installed">Scale Gateway nicht installiert</string>
@@ -32,8 +40,6 @@
<string name="wizard_gateway_up_to_date">Scale Gateway ist aktuell.</string>
<string name="wizard_gateway_update_available">Update für Scale Gateway verfügbar</string>
<string name="wizard_gateway_update_detail">Tippe auf den Button, um jetzt zu aktualisieren.</string>
<!-- Download- / Installationsfortschritt -->
<string name="install_downloading">Download läuft…</string>
<string name="install_downloading_detail">Bitte warten, die Datei wird heruntergeladen.</string>
<string name="install_installing">Installation läuft…</string>
@@ -43,31 +49,56 @@
<string name="install_error_download">Download fehlgeschlagen</string>
<string name="install_error_download_detail">Verbindung prüfen und erneut versuchen.</string>
<string name="install_error_install">Installation fehlgeschlagen</string>
<string name="install_perm_detail">Aktiviere \'Unbekannte Apps installieren\' in den Einstellungen, dann komm zurück.</string>
<string name="install_perm_detail">Aktiviere 'Unbekannte Apps installieren' in den Einstellungen, dann komm zurück.</string>
<string name="install_btn_retry">↩ Nochmal versuchen</string>
<!-- Schaltflächen -->
<string name="btn_back">Zurück</string>
<string name="btn_launch">🚀 EverShelf starten</string>
<string name="btn_launch_no_scale">🚀 Ohne Waage starten</string>
<string name="btn_download_gateway">📥 Scale Gateway installieren</string>
<string name="btn_update_gateway">📥 Scale Gateway aktualisieren</string>
<!-- Server-Erreichbarkeit prüfen (Wizard Schritt 3) -->
<string name="wizard_server_checking">Server-Verbindung wird geprüft…</string>
<string name="wizard_server_ok">Server erreichbar ✅</string>
<string name="wizard_server_ok_detail">Fehlerberichterstattung aktiv — Installationsfehler werden automatisch an GitHub Issues gesendet.</string>
<string name="wizard_server_error">Server nicht erreichbar ⚠️</string>
<string name="wizard_server_error_detail">Fehler werden GitHub Issues nicht erreichen. URL in Schritt 2 prüfen.</string>
<!-- Bildschirmschoner-Schritt -->
<string name="setup_screensaver_title">Bildschirmschoner</string>
<string name="setup_screensaver_desc">Zeigt nach 5 Minuten Inaktivität eine Uhr mit nützlichen Fakten. Standardmäßig deaktiviert (Bildschirm bleibt immer an).</string>
<string name="setup_screensaver_toggle_label">Bildschirmschoner aktivieren</string>
<string name="setup_screensaver_toggle_hint">Wenn deaktiviert, bleibt der Bildschirm immer an.</string>
<string name="setup_features_title">Funktionen</string>
<string name="setup_features_desc">Aktiviere die gewünschten Funktionen. Du kannst sie später jederzeit in den Servereinstellungen ändern.</string>
<string name="setup_screensaver_toggle_label">Uhr-Bildschirmschoner</string>
<string name="setup_screensaver_toggle_hint">Zeigt eine Uhranzeige nach 5 Min. Inaktivität.</string>
<string name="setup_prices_toggle_label">Einkaufslisten-Preise</string>
<string name="setup_prices_toggle_hint">KI-gestützte automatische Kostensätzung für jeden Artikel.</string>
<string name="setup_mealplan_toggle_label">Mahlzeitenplan</string>
<string name="setup_mealplan_toggle_hint">Plane die Wöchentliche Mahlzeiten mit Rezepten aus deiner Vorratskammer.</string>
<string name="setup_zerowaste_toggle_label">Zero-Waste-Tipps</string>
<string name="setup_zerowaste_toggle_hint">Beim Kochen Tipps zur Wiederverwendung von Resten anzeigen (Schalen, Kochwasser usw.).</string>
<string name="setup_gemini_title">Google Gemini AI</string>
<string name="setup_gemini_desc">EverShelf nutzt Google Gemini AI für Rezeptvorschläge, smarte Einkaufsschätzungen und mehr.
<!-- Zusammenfassung -->
Zum Aktivieren den kostenlosen Gemini API-Schlüssel eingeben.</string>
<string name="setup_gemini_how">Kostenlosen Schlüssel unter: aistudio.google.com → "API-Schlüssel erhalten"</string>
<string name="setup_gemini_hint">API-Schlüssel einfügen (beginnt mit AIza…)</string>
<string name="setup_bring_title">Bring! Einkaufsliste</string>
<string name="setup_bring_desc">EverShelf kann die Einkaufsliste mit der Bring!-App synchronisieren.
Bring!-Zugangsdaten eingeben, um die Integration zu aktivieren.</string>
<string name="setup_bring_email_hint">Bring!-E-Mail-Adresse</string>
<string name="setup_bring_pass_hint">Bring!-Passwort</string>
<string name="setup_done_title">Alles bereit!</string>
<string name="setup_done_desc">Die Einrichtung ist abgeschlossen. Auf den Button tippen, um EverShelf im Kiosk-Modus zu starten.</string>
<string name="setup_done_summary_label">KONFIGURATIONSSÜBERSICHT</string>
<string name="summary_lang">Sprache</string>
<string name="summary_scale_skip">Waage: nicht konfiguriert</string>
<string name="summary_screensaver_on">Bildschirmschoner: aktiv</string>
<string name="summary_screensaver_off">Bildschirm immer an (Bildschirmschoner deaktiviert)</string>
<string name="summary_prices_on">Einkaufslisten-Preise: aktiviert</string>
<string name="summary_mealplan_on">Mahlzeitenplan: aktiviert</string>
<string name="summary_zerowaste_on">Zero-Waste-Tipps: aktiviert</string>
<string name="summary_gemini_set">Gemini AI: aktiviert</string>
<string name="summary_gemini_skip">Gemini AI: nicht konfiguriert</string>
<string name="summary_bring_set">Bring!: verbunden</string>
<string name="summary_bring_skip">Bring!: nicht konfiguriert</string>
<string name="ble_connecting_to">🔗 Verbinde mit %s…</string>
<string name="ble_connecting">🔗 Verbindung wird hergestellt…</string>
<string name="summary_scale_ok">Waage: %s</string>
<string name="summary_scale_warn">Waage: nicht bestätigt</string>
</resources>
@@ -0,0 +1,104 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">EverShelf Kiosk</string>
<string name="setup_enter_url">Introduce primero una URL</string>
<string name="setup_testing">Probando conexión…</string>
<string name="setup_server_found">¡Servidor EverShelf encontrado y API activa!</string>
<string name="setup_api_not_found">Servidor accesible pero API EverShelf no encontrada. Comprueba la ruta.</string>
<string name="setup_unreachable">No se puede alcanzar el servidor</string>
<string name="setup_discover_btn">🔍 Buscar en la red local</string>
<string name="setup_perms_granted_next">✅ Permisos concedidos — Continuar →</string>
<string name="setup_discovering">Escaneando…</string>
<string name="setup_discovering_detail">Buscando servidores EverShelf en la red local…</string>
<string name="setup_discover_not_found">Ningún servidor EverShelf encontrado automáticamente. Introduce la URL manualmente.</string>
<string name="setup_exit_title">¿Salir de la configuración?</string>
<string name="setup_exit_message">Puedes completar la configuración más tarde cuando vuelvas a abrir la app.</string>
<string name="setup_exit_confirm">Salir</string>
<string name="setup_exit_cancel">Continuar</string>
<string name="setup_step_back">← Atrás</string>
<string name="setup_step_next">Siguiente →</string>
<string name="setup_skip_later">Configurar después</string>
<string name="setup_confirm">Confirmar →</string>
<string name="wizard_step3_title">Báscula inteligente</string>
<string name="wizard_step3_description">EverShelf Kiosk incluye una pasarela Bluetooth integrada — no necesitas ninguna app externa. Selecciona tu báscula abajo.</string>
<string name="wizard_step3_question">¿Tienes una báscula inteligente Bluetooth?</string>
<string name="wizard_step3_yes">✅ Sí, tengo una báscula</string>
<string name="wizard_step3_no">➡️ No, saltar este paso</string>
<string name="ble_scanning">🔍 Escaneando…</string>
<string name="ble_connected">¡Conectado! Coloca un objeto en la báscula…</string>
<string name="ble_disconnected">Conexión perdida. Reintentar.</string>
<string name="ble_no_scale_found">No se encontró ninguna báscula. Asegúrate de que esté encendida y cerca, e inténtalo de nuevo.</string>
<string name="ble_select_from_list">Selecciona tu báscula de la lista.</string>
<string name="ble_not_confirmed">Báscula no confirmada. Vuelve a escanear.</string>
<string name="ble_scan_again">🔄 Volver a escanear</string>
<string name="ble_weight_received">Peso recibido — ¿coincide con el mostrado en la báscula?</string>
<string name="wizard_gateway_installed">Báscula guardada ✅</string>
<string name="wizard_gateway_installed_detail">La pasarela BLE integrada se conectará automáticamente al inicio.</string>
<string name="wizard_gateway_not_installed">Ninguna báscula seleccionada</string>
<string name="wizard_gateway_not_installed_detail">Escanea las básculas BLE cercanas y toca una para seleccionarla.</string>
<string name="wizard_gateway_checking">Escaneando básculas BLE…</string>
<string name="wizard_gateway_up_to_date">Servicio BLE de báscula listo.</string>
<string name="wizard_gateway_update_available">Báscula BLE encontrada</string>
<string name="wizard_gateway_update_detail">Toca la báscula en la lista para conectarte.</string>
<string name="install_downloading">Descargando…</string>
<string name="install_downloading_detail">Por favor, espera mientras se descarga el archivo.</string>
<string name="install_installing">Instalando…</string>
<string name="install_confirm_detail">Confirma la instalación en el diálogo que se ha abierto.</string>
<string name="install_success">¡Instalado correctamente!</string>
<string name="install_success_detail">La app ha sido actualizada.</string>
<string name="install_error_download">Descarga fallida</string>
<string name="install_error_download_detail">Comprueba la conexión e inténtalo de nuevo.</string>
<string name="install_error_install">Instalación fallida</string>
<string name="install_perm_detail">Habilita 'Instalar apps desconocidas' en los ajustes y vuelve aquí.</string>
<string name="install_btn_retry">↩ Reintentar</string>
<string name="btn_back">Atrás</string>
<string name="btn_launch">🚀 Iniciar EverShelf</string>
<string name="btn_launch_no_scale">🚀 Iniciar sin báscula</string>
<string name="btn_download_gateway">📥 Instalar Scale Gateway</string>
<string name="btn_update_gateway">📥 Actualizar Scale Gateway</string>
<string name="wizard_server_checking">Comprobando conexión al servidor…</string>
<string name="wizard_server_ok">Servidor accesible ✅</string>
<string name="wizard_server_ok_detail">Informe de errores activo — los fallos de instalación se enviarán automáticamente a GitHub Issues.</string>
<string name="wizard_server_error">Servidor no accesible ⚠️</string>
<string name="wizard_server_error_detail">Los errores no llegarán a GitHub Issues. Comprueba la URL introducida en el paso 2.</string>
<string name="setup_features_title">Funcionalidades</string>
<string name="setup_features_desc">Activa las funciones que quieras usar. Puedes cambiarlas en cualquier momento desde los ajustes del servidor.</string>
<string name="setup_screensaver_toggle_label">Salvapantallas reloj</string>
<string name="setup_screensaver_toggle_hint">Muestra un reloj después de 5 min de inactividad.</string>
<string name="setup_prices_toggle_label">Precios lista de la compra</string>
<string name="setup_prices_toggle_hint">Estimación automática del coste de cada artículo mediante IA.</string>
<string name="setup_mealplan_toggle_label">Plan de comidas</string>
<string name="setup_mealplan_toggle_hint">Planifica las comidas de la semana con recetas basadas en tu despensa.</string>
<string name="setup_zerowaste_toggle_label">Consejos zero-waste</string>
<string name="setup_zerowaste_toggle_hint">Muestra consejos para reutilizar restos (cáscaras, agua de cocción, etc.) al cocinar.</string>
<string name="setup_gemini_title">Google Gemini AI</string>
<string name="setup_gemini_desc">EverShelf usa Google Gemini AI para sugerencias de recetas, estimaciones inteligentes de la compra y más.
Para activarla, introduce tu clave API de Gemini gratuita.</string>
<string name="setup_gemini_how">Obtén tu clave gratuita en: aistudio.google.com → "Obtener clave API"</string>
<string name="setup_gemini_hint">Pega la clave API aquí (empieza por AIza…)</string>
<string name="setup_bring_title">Bring! Lista de la compra</string>
<string name="setup_bring_desc">EverShelf puede sincronizar tu lista de la compra con la app Bring!.
Introduce tus credenciales de Bring! para activar la integración.</string>
<string name="setup_bring_email_hint">Correo electrónico de Bring!</string>
<string name="setup_bring_pass_hint">Contraseña de Bring!</string>
<string name="setup_done_title">¡Todo listo!</string>
<string name="setup_done_desc">La configuración está completa. Pulsa el botón para iniciar EverShelf en modo quiosco.</string>
<string name="setup_done_summary_label">RESUMEN DE CONFIGURACIÓN</string>
<string name="summary_lang">Idioma</string>
<string name="summary_scale_skip">Báscula: no configurada</string>
<string name="summary_screensaver_on">Salvapantallas: activo</string>
<string name="summary_screensaver_off">Pantalla siempre encendida (salvapantallas desactivado)</string>
<string name="summary_prices_on">Precios lista de la compra: activados</string>
<string name="summary_mealplan_on">Plan de comidas: activado</string>
<string name="summary_zerowaste_on">Consejos zero-waste: activados</string>
<string name="summary_gemini_set">Gemini AI: activada</string>
<string name="summary_gemini_skip">Gemini AI: no configurada</string>
<string name="summary_bring_set">Bring!: conectada</string>
<string name="summary_bring_skip">Bring!: no configurada</string>
<string name="ble_connecting_to">🔗 Conectando con %s…</string>
<string name="ble_connecting">🔗 Estableciendo conexión…</string>
<string name="summary_scale_ok">Báscula: %s</string>
<string name="summary_scale_warn">Báscula: no confirmada</string>
</resources>
@@ -0,0 +1,104 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">EverShelf Kiosk</string>
<string name="setup_enter_url">Veuillez d'abord saisir une URL</string>
<string name="setup_testing">Test de connexion…</string>
<string name="setup_server_found">Serveur EverShelf trouvé et API active !</string>
<string name="setup_api_not_found">Serveur accessible mais API EverShelf introuvable. Vérifiez le chemin.</string>
<string name="setup_unreachable">Impossible d'atteindre le serveur</string>
<string name="setup_discover_btn">🔍 Rechercher sur le réseau local</string>
<string name="setup_perms_granted_next">✅ Permissions accordées — Continuer →</string>
<string name="setup_discovering">Analyse en cours…</string>
<string name="setup_discovering_detail">Recherche de serveurs EverShelf sur le réseau local…</string>
<string name="setup_discover_not_found">Aucun serveur EverShelf trouvé automatiquement. Entrez l'URL manuellement.</string>
<string name="setup_exit_title">Quitter la configuration ?</string>
<string name="setup_exit_message">Vous pouvez terminer la configuration plus tard en rouvrant l'app.</string>
<string name="setup_exit_confirm">Quitter</string>
<string name="setup_exit_cancel">Continuer</string>
<string name="setup_step_back">← Retour</string>
<string name="setup_step_next">Suivant →</string>
<string name="setup_skip_later">Configurer plus tard</string>
<string name="setup_confirm">Confirmer →</string>
<string name="wizard_step3_title">Balance intelligente</string>
<string name="wizard_step3_description">EverShelf Kiosk inclut une passerelle Bluetooth intégrée — aucune app externe nécessaire. Sélectionnez votre balance ci-dessous.</string>
<string name="wizard_step3_question">Avez-vous une balance intelligente Bluetooth ?</string>
<string name="wizard_step3_yes">✅ Oui, j'ai une balance</string>
<string name="wizard_step3_no">➡️ Non, ignorer cette étape</string>
<string name="ble_scanning">🔍 Scan en cours…</string>
<string name="ble_connected">Connecté ! Posez un objet sur la balance…</string>
<string name="ble_disconnected">Connexion perdue. Réessayer.</string>
<string name="ble_no_scale_found">Aucune balance trouvée. Vérifiez qu'elle est allumée et à proximité, puis réessayez.</string>
<string name="ble_select_from_list">Sélectionnez votre balance dans la liste.</string>
<string name="ble_not_confirmed">Balance non confirmée. Relancer le scan.</string>
<string name="ble_scan_again">🔄 Scanner à nouveau</string>
<string name="ble_weight_received">Poids reçu — correspond-il à l'affichage de la balance ?</string>
<string name="wizard_gateway_installed">Balance enregistrée ✅</string>
<string name="wizard_gateway_installed_detail">La passerelle BLE intégrée se connectera automatiquement au démarrage.</string>
<string name="wizard_gateway_not_installed">Aucune balance sélectionnée</string>
<string name="wizard_gateway_not_installed_detail">Scannez les balances BLE à proximité et appuyez sur l'une d'elles pour la sélectionner.</string>
<string name="wizard_gateway_checking">Scan des balances BLE en cours…</string>
<string name="wizard_gateway_up_to_date">Service BLE de la balance prêt.</string>
<string name="wizard_gateway_update_available">Balance BLE trouvée</string>
<string name="wizard_gateway_update_detail">Appuyez sur la balance dans la liste pour vous connecter.</string>
<string name="install_downloading">Téléchargement en cours…</string>
<string name="install_downloading_detail">Veuillez patienter, le fichier est en cours de téléchargement.</string>
<string name="install_installing">Installation en cours…</string>
<string name="install_confirm_detail">Confirmez l'installation dans la boîte de dialogue ouverte.</string>
<string name="install_success">Installé avec succès !</string>
<string name="install_success_detail">L'app a été mise à jour.</string>
<string name="install_error_download">Téléchargement échoué</string>
<string name="install_error_download_detail">Vérifiez la connexion et réessayez.</string>
<string name="install_error_install">Installation échouée</string>
<string name="install_perm_detail">Activez 'Installer des apps inconnues' dans les paramètres, puis revenez ici.</string>
<string name="install_btn_retry">↩ Réessayer</string>
<string name="btn_back">Retour</string>
<string name="btn_launch">🚀 Lancer EverShelf</string>
<string name="btn_launch_no_scale">🚀 Lancer sans balance</string>
<string name="btn_download_gateway">📥 Installer Scale Gateway</string>
<string name="btn_update_gateway">📥 Mettre à jour Scale Gateway</string>
<string name="wizard_server_checking">Vérification de la connexion au serveur…</string>
<string name="wizard_server_ok">Serveur accessible ✅</string>
<string name="wizard_server_ok_detail">Rapport d'erreurs actif — les échecs d'installation seront envoyés automatiquement aux GitHub Issues.</string>
<string name="wizard_server_error">Serveur inaccessible ⚠️</string>
<string name="wizard_server_error_detail">Les erreurs n'atteindront pas GitHub Issues. Vérifiez l'URL saisie à l'étape 2.</string>
<string name="setup_features_title">Fonctionnalités</string>
<string name="setup_features_desc">Activez les fonctions que vous souhaitez utiliser. Vous pourrez les modifier plus tard dans les paramètres du serveur.</string>
<string name="setup_screensaver_toggle_label">Horloge écran de veille</string>
<string name="setup_screensaver_toggle_hint">Affiche une horloge après 5 min d'inactivité.</string>
<string name="setup_prices_toggle_label">Prix liste de courses</string>
<string name="setup_prices_toggle_hint">Estimation automatique du coût de chaque article via IA.</string>
<string name="setup_mealplan_toggle_label">Plan de repas</string>
<string name="setup_mealplan_toggle_hint">Planifiez les repas de la semaine avec des recettes basées sur votre garde-manger.</string>
<string name="setup_zerowaste_toggle_label">Conseils zéro déchet</string>
<string name="setup_zerowaste_toggle_hint">Affiche des conseils pour réutiliser les restes (peaux, eau de cuisson, etc.) pendant la cuisson.</string>
<string name="setup_gemini_title">Google Gemini AI</string>
<string name="setup_gemini_desc">EverShelf utilise Google Gemini AI pour les suggestions de recettes, les estimations intelligentes des courses et plus encore.
Pour l'activer, entrez votre clé API Gemini gratuite.</string>
<string name="setup_gemini_how">Obtenez votre clé gratuite sur : aistudio.google.com → "Obtenir une clé API"</string>
<string name="setup_gemini_hint">Collez la clé API ici (commence par AIza…)</string>
<string name="setup_bring_title">Bring! Liste de courses</string>
<string name="setup_bring_desc">EverShelf peut synchroniser votre liste de courses avec l'app Bring!.
Entrez vos identifiants Bring! pour activer l'intégration.</string>
<string name="setup_bring_email_hint">Adresse e-mail Bring!</string>
<string name="setup_bring_pass_hint">Mot de passe Bring!</string>
<string name="setup_done_title">Tout est prêt !</string>
<string name="setup_done_desc">La configuration est terminée. Appuyez sur le bouton pour lancer EverShelf en mode kiosque.</string>
<string name="setup_done_summary_label">RÉSUMÉ DE CONFIGURATION</string>
<string name="summary_lang">Langue</string>
<string name="summary_scale_skip">Balance : non configurée</string>
<string name="summary_screensaver_on">Écran de veille : actif</string>
<string name="summary_screensaver_off">Écran toujours allumé (écran de veille désactivé)</string>
<string name="summary_prices_on">Prix liste de courses : activés</string>
<string name="summary_mealplan_on">Plan de repas : activé</string>
<string name="summary_zerowaste_on">Conseils zéro déchet : activés</string>
<string name="summary_gemini_set">Gemini AI : activée</string>
<string name="summary_gemini_skip">Gemini AI : non configurée</string>
<string name="summary_bring_set">Bring! : connectée</string>
<string name="summary_bring_skip">Bring! : non configurée</string>
<string name="ble_connecting_to">🔗 Connexion à %s…</string>
<string name="ble_connecting">🔗 Connexion en cours…</string>
<string name="summary_scale_ok">Balance : %s</string>
<string name="summary_scale_warn">Balance : non confirmée</string>
</resources>
@@ -1,73 +1,104 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">EverShelf Kiosk</string>
<!-- Stringhe setup wizard -->
<string name="setup_enter_url">Inserisci prima un URL</string>
<string name="setup_testing">Verifica connessione…</string>
<string name="setup_server_found">Server EverShelf trovato e API attiva!</string>
<string name="setup_api_not_found">Server raggiungibile ma API EverShelf non trovata. Verifica il percorso.</string>
<string name="setup_unreachable">Impossibile raggiungere il server</string>
<string name="setup_discover_btn">🔍 Cerca nella rete locale</string> <string name="setup_perms_granted_next">✅ Permessi concessi — Continua →</string> <string name="setup_discovering">Scansione in corso…</string>
<string name="setup_discover_btn">🔍 Cerca nella rete locale</string>
<string name="setup_perms_granted_next">✅ Permessi concessi — Continua →</string>
<string name="setup_discovering">Scansione in corso…</string>
<string name="setup_discovering_detail">Ricerca server EverShelf nella rete locale…</string>
<string name="setup_discover_not_found">Nessun server EverShelf trovato automaticamente. Inserisci l\'URL manualmente.</string>
<string name="setup_discover_not_found">Nessun server EverShelf trovato automaticamente. Inserisci l'URL manualmente.</string>
<string name="setup_exit_title">Uscire dalla configurazione?</string>
<string name="setup_exit_message">Puoi completare la configurazione più tardi riaprendo l\'app.</string>
<string name="setup_exit_message">Puoi completare la configurazione più tardi riaprendo l'app.</string>
<string name="setup_exit_confirm">Esci</string>
<string name="setup_exit_cancel">Continua</string>
<!-- Wizard Step 3: Bilancia smart -->
<string name="setup_step_back">← Indietro</string>
<string name="setup_step_next">Avanti →</string>
<string name="setup_skip_later">Lo faccio dopo</string>
<string name="setup_confirm">Conferma →</string>
<string name="wizard_step3_title">Bilancia Smart</string>
<string name="wizard_step3_description">EverShelf Kiosk include un gateway Bluetooth integrato — nessuna app esterna necessaria. Seleziona la tua bilancia qui sotto.</string>
<string name="wizard_step3_question">Hai una bilancia smart Bluetooth?</string>
<string name="wizard_step3_yes">✅ Sì, ho una bilancia</string>
<string name="wizard_step3_no">➡️ No, salta questo passaggio</string>
<!-- Messaggi stato gateway -->
<string name="ble_scanning">🔍 Scansione in corso…</string>
<string name="ble_connected">Connesso! Posiziona un oggetto sulla bilancia…</string>
<string name="ble_disconnected">Connessione persa. Riprova.</string>
<string name="ble_no_scale_found">Nessuna bilancia trovata. Assicurati che sia accesa e vicina, poi riprova.</string>
<string name="ble_select_from_list">Seleziona la tua bilancia dall'elenco.</string>
<string name="ble_not_confirmed">Bilancia non confermata. Riprova la scansione.</string>
<string name="ble_scan_again">🔄 Scansiona di nuovo</string>
<string name="ble_weight_received">Peso ricevuto — coincide con quello sulla bilancia?</string>
<string name="wizard_gateway_installed">Bilancia salvata ✅</string>
<string name="wizard_gateway_installed_detail">Il gateway BLE integrato si collegherà automaticamente all\'avvio.</string>
<string name="wizard_gateway_installed_detail">Il gateway BLE integrato si collegher automaticamente all'avvio.</string>
<string name="wizard_gateway_not_installed">Nessuna bilancia selezionata</string>
<string name="wizard_gateway_not_installed_detail">Scansiona le bilance BLE nelle vicinanze e tocca una per selezionarla.</string>
<string name="wizard_gateway_checking">Scansione bilance BLE in corso…</string>
<string name="wizard_gateway_up_to_date">Servizio BLE bilancia pronto.</string>
<string name="wizard_gateway_update_available">Bilancia BLE trovata</string>
<string name="wizard_gateway_update_detail">Tocca la bilancia nell\'elenco per connettersi.</string>
<!-- Stati scaricamento / installazione -->
<string name="wizard_gateway_update_detail">Tocca la bilancia nell'elenco per connettersi.</string>
<string name="install_downloading">Scaricamento in corso…</string>
<string name="install_downloading_detail">Attendi, il file viene scaricato.</string>
<string name="install_installing">Installazione in corso…</string>
<string name="install_confirm_detail">Conferma l\'installazione nel dialog che si è aperto.</string>
<string name="install_confirm_detail">Conferma l'installazione nel dialog che si è aperto.</string>
<string name="install_success">Installato con successo!</string>
<string name="install_success_detail">L\'app è stata aggiornata.</string>
<string name="install_success_detail">L'app è stata aggiornata.</string>
<string name="install_error_download">Download fallito</string>
<string name="install_error_download_detail">Controlla la connessione e riprova.</string>
<string name="install_error_install">Installazione fallita</string>
<string name="install_perm_detail">Abilita \'Installa app sconosciute\' nelle impostazioni, poi torna qui.</string>
<string name="install_perm_detail">Abilita 'Installa app sconosciute' nelle impostazioni, poi torna qui.</string>
<string name="install_btn_retry">↩ Riprova</string>
<!-- Pulsanti -->
<string name="btn_back">Indietro</string>
<string name="btn_launch">🚀 Avvia EverShelf</string>
<string name="btn_launch_no_scale">🚀 Avvia senza bilancia</string>
<string name="btn_download_gateway">📥 Installa Scale Gateway</string>
<string name="btn_update_gateway">📥 Aggiorna Scale Gateway</string>
<!-- Verifica raggiungibilità server (step 3 wizard) -->
<string name="wizard_server_checking">Verifica connessione server…</string>
<string name="wizard_server_ok">Server raggiungibile ✅</string>
<string name="wizard_server_ok_detail">Segnalazione errori attiva — i problemi di installazione vengono inviati automaticamente alle GitHub Issues.</string>
<string name="wizard_server_error">Server non raggiungibile ⚠️</string>
<string name="wizard_server_error_detail">Gli errori non raggiungeranno GitHub Issues. Verifica l\'URL inserito al passaggio 2.</string>
<!-- Passo salvaschermo -->
<string name="setup_screensaver_title">Salvaschermo</string>
<string name="setup_screensaver_desc">Mostra un orologio con fatti utili dopo 5 minuti di inattività. Di default è disattivato (lo schermo resta sempre acceso).</string>
<string name="setup_screensaver_toggle_label">Attiva salvaschermo</string>
<string name="setup_screensaver_toggle_hint">Se disattivo, lo schermo resta sempre acceso.</string>
<string name="wizard_server_error_detail">Gli errori non raggiungeranno GitHub Issues. Verifica l'URL inserito al passaggio 2.</string>
<string name="setup_features_title">Funzionalità</string>
<string name="setup_features_desc">Attiva le funzioni che vuoi usare. Puoi sempre cambiarle in seguito dalle impostazioni del server.</string>
<string name="setup_screensaver_toggle_label">Salvaschermo orologio</string>
<string name="setup_screensaver_toggle_hint">Mostra l'overlay orologio dopo 5 min di inattività.</string>
<string name="setup_prices_toggle_label">Prezzi lista spesa</string>
<string name="setup_prices_toggle_hint">Stima automatica del costo di ogni articolo in lista tramite AI.</string>
<string name="setup_mealplan_toggle_label">Piano pasti</string>
<string name="setup_mealplan_toggle_hint">Pianifica i pasti della settimana suggerendo ricette basate sulla dispensa.</string>
<string name="setup_zerowaste_toggle_label">Suggerimenti zero-waste</string>
<string name="setup_zerowaste_toggle_hint">Durante la cottura mostra consigli per riutilizzare scarti (bucce, acqua di cottura, ecc.).</string>
<string name="setup_gemini_title">Google Gemini AI</string>
<string name="setup_gemini_desc">EverShelf usa Google Gemini AI per suggerimenti di ricette, stime intelligenti della spesa e altro ancora.
<!-- Riepilogo -->
Per abilitarla, inserisci la tua chiave API Gemini gratuita.</string>
<string name="setup_gemini_how">Ottieni la chiave gratuita su: aistudio.google.com → "Ottieni chiave API"</string>
<string name="setup_gemini_hint">Incolla la chiave API (inizia con AIza…)</string>
<string name="setup_bring_title">Bring! Lista della spesa</string>
<string name="setup_bring_desc">EverShelf può sincronizzare la lista della spesa con l'app Bring!.
Inserisci le credenziali del tuo account Bring! per abilitare l'integrazione.</string>
<string name="setup_bring_email_hint">Email Bring!</string>
<string name="setup_bring_pass_hint">Password Bring!</string>
<string name="setup_done_title">Tutto pronto!</string>
<string name="setup_done_desc">La configurazione è completa. Premi il pulsante per avviare EverShelf in modalità kiosk.</string>
<string name="setup_done_summary_label">RIEPILOGO CONFIGURAZIONE</string>
<string name="summary_lang">Lingua</string>
<string name="summary_scale_skip">Bilancia: non configurata</string>
<string name="summary_screensaver_on">Salvaschermo: attivo</string>
<string name="summary_screensaver_off">Schermo sempre acceso (salvaschermo disattivato)</string>
<string name="summary_prices_on">Prezzi lista spesa: abilitati</string>
<string name="summary_mealplan_on">Piano pasti: abilitato</string>
<string name="summary_zerowaste_on">Suggerimenti zero-waste: abilitati</string>
<string name="summary_gemini_set">Gemini AI: abilitata</string>
<string name="summary_gemini_skip">Gemini AI: non configurata</string>
<string name="summary_bring_set">Bring!: connessa</string>
<string name="summary_bring_skip">Bring!: non configurata</string>
<string name="ble_connecting_to">🔗 Connessione a %s…</string>
<string name="ble_connecting">🔗 Connessione in corso…</string>
<string name="summary_scale_ok">Bilancia: %s</string>
<string name="summary_scale_warn">Bilancia: da configurare</string>
</resources>
@@ -1,28 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">EverShelf Kiosk</string>
<!-- Setup wizard strings -->
<!-- ── Setup wizard ─────────────────────────────────────────────────── -->
<string name="setup_enter_url">Please enter a URL first</string>
<string name="setup_testing">Testing connection…</string>
<string name="setup_server_found">EverShelf server found and API active!</string>
<string name="setup_api_not_found">Server reachable but EverShelf API not found. Check the path.</string>
<string name="setup_unreachable">Cannot reach server</string>
<string name="setup_discover_btn">🔍 Search local network</string> <string name="setup_perms_granted_next">✅ Permissions granted — Continue →</string> <string name="setup_discovering">Scanning…</string>
<string name="setup_discover_btn">🔍 Search local network</string>
<string name="setup_perms_granted_next">✅ Permissions granted — Continue →</string>
<string name="setup_discovering">Scanning…</string>
<string name="setup_discovering_detail">Searching for EverShelf servers on the local network…</string>
<string name="setup_discover_not_found">No EverShelf server found automatically. Enter the URL manually.</string>
<string name="setup_exit_title">Exit setup?</string>
<string name="setup_exit_message">You can complete setup later when you reopen the app.</string>
<string name="setup_exit_confirm">Exit</string>
<string name="setup_exit_cancel">Continue</string>
<string name="setup_step_back">← Back</string>
<string name="setup_step_next">Next →</string>
<string name="setup_skip_later">Set up later</string>
<string name="setup_confirm">Confirm →</string>
<!-- Wizard Step 3: Smart scale -->
<!-- ── Wizard Step 4: Smart scale ───────────────────────────────────── -->
<string name="wizard_step3_title">Smart Scale</string>
<string name="wizard_step3_description">EverShelf Kiosk includes a built-in Bluetooth gateway — no external app needed. Select your scale below.</string>
<string name="wizard_step3_question">Do you have a Bluetooth smart scale?</string>
<string name="wizard_step3_yes">✅ Yes, I have a scale</string>
<string name="wizard_step3_no">➡️ No, skip this step</string>
<!-- Gateway status messages -->
<!-- BLE scan / test feedback (previously hardcoded) -->
<string name="ble_scanning">🔍 Scanning…</string>
<string name="ble_connected">Connected! Place an object on the scale…</string>
<string name="ble_disconnected">Connection lost. Retry.</string>
<string name="ble_no_scale_found">No scale found. Make sure it is on and nearby, then retry.</string>
<string name="ble_select_from_list">Select your scale from the list.</string>
<string name="ble_not_confirmed">Scale not confirmed. Retry scan.</string>
<string name="ble_scan_again">🔄 Scan again</string>
<string name="ble_weight_received">Weight received — does it match the display?</string>
<!-- ── Gateway status messages ──────────────────────────────────────── -->
<string name="wizard_gateway_installed">Scale device saved ✅</string>
<string name="wizard_gateway_installed_detail">The integrated BLE gateway will connect automatically on startup.</string>
<string name="wizard_gateway_not_installed">No scale selected</string>
@@ -32,41 +49,76 @@
<string name="wizard_gateway_update_available">BLE scale found</string>
<string name="wizard_gateway_update_detail">Tap the scale in the list to connect.</string>
<!-- Install / download progress states -->
<string name="install_downloading">Scaricamento in corso</string>
<string name="install_downloading_detail">Attendi, il file viene scaricato.</string>
<string name="install_installing">Installazione in corso</string>
<string name="install_confirm_detail">Conferma l\'installazione nel dialog che si è aperto.</string>
<string name="install_success">Installato con successo!</string>
<string name="install_success_detail">L\'app è stata aggiornata.</string>
<string name="install_error_download">Download fallito</string>
<string name="install_error_download_detail">Controlla la connessione e riprova.</string>
<string name="install_error_install">Installazione fallita</string>
<string name="install_perm_detail">Abilita \'Installa app sconosciute\' nelle impostazioni, poi torna qui.</string>
<string name="install_btn_retry">↩ Riprova</string>
<!-- ── Install / download progress states ───────────────────────────── -->
<string name="install_downloading">Downloading</string>
<string name="install_downloading_detail">Please wait, the file is being downloaded.</string>
<string name="install_installing">Installing</string>
<string name="install_confirm_detail">Confirm the installation in the dialog that has opened.</string>
<string name="install_success">Installed successfully!</string>
<string name="install_success_detail">The app has been updated.</string>
<string name="install_error_download">Download failed</string>
<string name="install_error_download_detail">Check your connection and try again.</string>
<string name="install_error_install">Installation failed</string>
<string name="install_perm_detail">Enable \'Install unknown apps\' in settings, then come back here.</string>
<string name="install_btn_retry">↩ Retry</string>
<!-- Buttons -->
<!-- ── Buttons ───────────────────────────────────────────────────────── -->
<string name="btn_back">Back</string>
<string name="btn_launch">🚀 Launch EverShelf</string>
<string name="btn_launch_no_scale">🚀 Launch without scale</string>
<string name="btn_download_gateway">📥 Install Scale Gateway</string>
<string name="btn_update_gateway">📥 Update Scale Gateway</string>
<!-- Server reachability check (wizard step 3) -->
<!-- ── Server reachability check ────────────────────────────────────── -->
<string name="wizard_server_checking">Checking server connection…</string>
<string name="wizard_server_ok">Server reachable ✅</string>
<string name="wizard_server_ok_detail">Error reporting is active — install failures will be sent to GitHub Issues automatically.</string>
<string name="wizard_server_error">Server not reachable ⚠️</string>
<string name="wizard_server_error_detail">Install errors won\'t reach GitHub Issues. Check the URL entered in step 2.</string>
<!-- Screensaver step -->
<string name="setup_screensaver_title">Salvaschermo in-app</string>
<string name="setup_screensaver_desc">Shows a clock with useful facts after 5 minutes of inactivity. Off by default (screen stays always on).</string>
<string name="setup_screensaver_toggle_label">Abilita salvaschermo orologio</string>
<string name="setup_screensaver_toggle_hint">Mostra l\'overlay orologio dopo 5 min. Lo schermo resta sempre acceso.</string>
<!-- Summary -->
<!-- ── Step 5 — Features ─────────────────────────────────────────────── -->
<string name="setup_features_title">Features</string>
<string name="setup_features_desc">Enable the features you want to use. You can always change them later in the server settings.</string>
<string name="setup_screensaver_toggle_label">Clock screensaver</string>
<string name="setup_screensaver_toggle_hint">Shows a clock overlay after 5 min of inactivity.</string>
<string name="setup_prices_toggle_label">Shopping list prices</string>
<string name="setup_prices_toggle_hint">AI-powered automatic cost estimate for each item in the list.</string>
<string name="setup_mealplan_toggle_label">Meal plan</string>
<string name="setup_mealplan_toggle_hint">Plan the week\'s meals with recipes based on your pantry.</string>
<string name="setup_zerowaste_toggle_label">Zero-waste tips</string>
<string name="setup_zerowaste_toggle_hint">Show tips for reusing scraps (peels, cooking water, etc.) while cooking.</string>
<!-- ── Step 6 — Gemini AI key ─────────────────────────────────────────── -->
<string name="setup_gemini_title">Google Gemini AI</string>
<string name="setup_gemini_desc">EverShelf uses Google Gemini AI for recipe suggestions, smart shopping estimates and more.\n\nTo enable it, enter your free Gemini API key below.</string>
<string name="setup_gemini_how">Get your free key at: aistudio.google.com → \"Get API key\"</string>
<string name="setup_gemini_hint">Paste your API key here (starts with AIza…)</string>
<!-- ── Step 7 — Bring! credentials ──────────────────────────────────── -->
<string name="setup_bring_title">Bring! Shopping List</string>
<string name="setup_bring_desc">EverShelf can sync your shopping list with the Bring! app.\n\nEnter your Bring! account credentials to enable this integration.</string>
<string name="setup_bring_email_hint">Bring! email address</string>
<string name="setup_bring_pass_hint">Bring! password</string>
<!-- ── Step 8 — Done ─────────────────────────────────────────────────── -->
<string name="setup_done_title">All set!</string>
<string name="setup_done_desc">Setup is complete. Press the button below to launch EverShelf in kiosk mode.</string>
<string name="setup_done_summary_label">CONFIGURATION SUMMARY</string>
<!-- ── Summary lines ─────────────────────────────────────────────────── -->
<string name="summary_lang">Language</string>
<string name="summary_scale_skip">Scale: not configured</string>
<string name="summary_screensaver_on">Screensaver: enabled</string>
<string name="summary_screensaver_off">Screen always on (screensaver disabled)</string>
<string name="summary_prices_on">Shopping list prices: enabled</string>
<string name="summary_mealplan_on">Meal plan: enabled</string>
<string name="summary_zerowaste_on">Zero-waste tips: enabled</string>
<string name="summary_gemini_set">Gemini AI: enabled</string>
<string name="summary_gemini_skip">Gemini AI: not configured</string>
<string name="summary_bring_set">Bring!: connected</string>
<string name="summary_bring_skip">Bring!: not configured</string>
<string name="ble_connecting_to">🔗 Connecting to %s…</string>
<string name="ble_connecting">🔗 Connecting…</string>
<string name="summary_scale_ok">Scale: %s</string>
<string name="summary_scale_warn">Scale: not confirmed</string>
</resources>
+418 -84
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=20260520a">
<link rel="stylesheet" href="assets/css/style.css?v=20260517a">
<!-- 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 -->
@@ -55,10 +55,16 @@
<div class="app-preloader-inner">
<img src="assets/img/logo/logo.png" alt="EverShelf" class="app-preloader-logo" />
<div class="app-preloader-spinner" id="preloader-spinner"></div>
<div id="preloader-checks" class="preloader-checks" style="display:none"></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="check-ticker" class="check-ticker"></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.20</span>
<span class="app-preloader-version" id="preloader-version">v1.7.25</span>
</div>
</div>
@@ -71,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.20</span>
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.25</span>
</h1>
<!-- Update badge — shown alongside title, never replaces it -->
<span class="header-update-badge" id="header-update-badge" style="display:none"></span>
@@ -163,10 +169,12 @@
<div id="expired-list"></div>
</div>
<!-- Anti-Waste Report Card + Nutrition Analysis (alternating, content rendered by JS) -->
<!-- Anti-Waste Report Card + Nutrition Analysis + Monthly Stats (alternating, content rendered by JS) -->
<div id="dashboard-insight-wrap" style="position:relative">
<div id="waste-chart-section" style="display:none"></div>
<div id="nutrition-section" style="display:none"></div>
<div id="monthly-stats-section" style="display:none"></div>
<div id="macros-section" style="display:none"></div>
</div>
<!-- Alert for soonest expiring items -->
@@ -243,6 +251,11 @@
</div>
<!-- Live partial code preview -->
<div class="scan-live-code" id="scan-live-code" style="display:none"></div>
<!-- Scan status bar -->
<div class="scan-status-bar" id="scan-status-bar">
<span id="scan-status-method" class="scan-status-method"></span>
<span id="scan-status-msg" class="scan-status-msg" data-i18n="scan.status_ready"></span>
</div>
<!-- Success flash overlay -->
<div class="scan-confirm-overlay" id="scan-confirm-overlay" style="display:none">
<div class="scan-confirm-check"></div>
@@ -325,8 +338,9 @@
</div>
<!-- Banner: shopping list scan context -->
<div id="shopping-scan-target-banner" class="shopping-scan-target-banner" style="display:none"></div>
<div class="product-preview product-preview-large" id="action-product-preview"></div>
<div class="product-preview product-preview-small" id="action-product-preview"></div>
<div class="inventory-status-bar" id="action-inventory-status" style="display:none"></div>
<div id="action-related-stock" style="display:none"></div>
<div class="action-buttons" id="action-buttons-container">
<button class="btn btn-huge btn-success" onclick="showAddForm()">
<span class="btn-icon">📥</span>
@@ -665,7 +679,7 @@
<h2 data-i18n="recipes.title">🍳 Ricette</h2>
</div>
<div class="recipe-page-container">
<button class="btn btn-large btn-success full-width" onclick="openRecipeDialog()" data-i18n="recipes.generate">
<button class="btn btn-large btn-success full-width recipe-generate-btn" onclick="openRecipeDialog()" data-i18n="recipes.generate">
✨ Genera nuova ricetta
</button>
<div id="recipe-archive" class="recipe-archive"></div>
@@ -825,20 +839,119 @@
<h2 data-i18n="settings.title">⚙️ Configurazione</h2>
</div>
<div class="settings-tabs">
<button class="settings-tab active" onclick="switchSettingsTab(this, 'tab-api')" data-tab="tab-api" title="API Keys">🔑</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-bring')" data-tab="tab-bring" title="Bring!">🛒</button>
<button class="settings-tab active" onclick="switchSettingsTab(this, 'tab-general')" data-tab="tab-general" data-i18n-title="settings.tab_general" title="Generali">⚙️</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-api')" data-tab="tab-api" title="API Keys">🔑</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-bring')" data-tab="tab-bring" data-i18n-title="settings.shopping.tab" title="Lista spesa">🛒</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-recipe')" data-tab="tab-recipe" title="Ricette">🍳</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-mealplan')" data-tab="tab-mealplan" title="Piano Settimanale">📅</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-appliances')" data-tab="tab-appliances" title="Elettrodomestici">🔌</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-camera')" data-tab="tab-camera" title="Fotocamera">📷</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-security')" data-tab="tab-security" title="Sicurezza">🔒</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-tts')" data-tab="tab-tts" title="Voce (TTS)" data-i18n-title="settings.tab_tts">🔊</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-language')" data-tab="tab-language" title="Lingua" data-i18n-title="settings.tab_language">🌐</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-ha'); _loadHaTab();" data-tab="tab-ha" title="Home Assistant" data-i18n-title="settings.ha.tab">🏠</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-scale')" data-tab="tab-scale" title="Bilancia Smart" data-i18n-title="settings.scale.tab">⚖️</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-backup'); _loadBackupTab();" data-tab="tab-backup" data-i18n-title="settings.backup.tab" title="Backup">💾</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-info'); _loadInfoTab();" data-tab="tab-info" data-i18n-title="settings.info.tab" title="Info"></button>
</div>
<div class="settings-panels">
<!-- Generali Tab -->
<div class="settings-panel active" id="tab-general">
<div class="settings-card">
<h4 data-i18n="settings.language.title">🌐 Lingua / Language</h4>
<p class="settings-hint" data-i18n="settings.language.hint">Seleziona la lingua dell'interfaccia. Select the interface language.</p>
<div class="form-group">
<select id="setting-language" class="form-input" onchange="changeLanguage(this.value)">
</select>
<p class="settings-hint mt-2" data-i18n="settings.language.restart_notice">La pagina verrà ricaricata per applicare la nuova lingua.</p>
</div>
</div>
<div class="settings-card">
<h4 data-i18n="settings.info.currency_title">💱 Valuta</h4>
<p class="settings-hint" data-i18n="settings.info.currency_hint">La valuta usata per tutti i costi e i prezzi nell'app.</p>
<div class="form-group" style="margin-top:8px">
<select id="setting-price-currency" class="form-input">
<option value="EUR">€ Euro (EUR)</option>
<option value="USD">$ Dollaro USA (USD)</option>
<option value="GBP">£ Sterlina (GBP)</option>
<option value="CHF">CHF Franco Svizzero</option>
<option value="CAD">CA$ Dollaro Canadese</option>
<option value="AUD">A$ Dollaro Australiano</option>
<option value="BRL">R$ Real Brasiliano</option>
<option value="JPY">¥ Yen Giapponese</option>
<option value="SEK">kr Corona Svedese</option>
<option value="NOK">kr Corona Norvegese</option>
<option value="DKK">kr Corona Danese</option>
<option value="PLN">zł Zloty Polacco</option>
</select>
</div>
<div class="form-group" style="margin-top:10px">
<button class="btn btn-primary" onclick="saveSettings()" data-i18n="btn.save">Salva</button>
</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">
<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 (orario)</option>
<option value="on" data-i18n="settings.theme.on">🌙 Scuro</option>
</select>
</div>
</div>
<div class="settings-card">
<h4 data-i18n="settings.screensaver.card_title">🌙 Salvaschermo</h4>
<p class="settings-hint" data-i18n="settings.screensaver.card_hint">Mostra un orologio con fatti utili dopo un periodo di inattività. Di default è disattivato.</p>
<div class="form-group">
<label class="toggle-row">
<span data-i18n="settings.screensaver.label">Attiva salvaschermo</span>
<span class="toggle-switch">
<input type="checkbox" id="setting-screensaver-enabled">
<span class="toggle-slider"></span>
</span>
</label>
</div>
<div class="form-group" id="screensaver-timeout-row" style="margin-top:10px">
<label for="setting-screensaver-timeout" style="font-size:0.85rem;font-weight:600;color:var(--text-secondary)" data-i18n="settings.screensaver.start_after">⏱️ Avvia dopo</label>
<select id="setting-screensaver-timeout" class="form-input" style="margin-top:6px;max-width:200px">
<option value="1" data-i18n="settings.screensaver.timeout_1">1 minuto</option>
<option value="2" data-i18n="settings.screensaver.timeout_2">2 minuti</option>
<option value="5" selected data-i18n="settings.screensaver.timeout_5">5 minuti</option>
<option value="10" data-i18n="settings.screensaver.timeout_10">10 minuti</option>
<option value="15" data-i18n="settings.screensaver.timeout_15">15 minuti</option>
<option value="30" data-i18n="settings.screensaver.timeout_30">30 minuti</option>
<option value="60" data-i18n="settings.screensaver.timeout_60">1 ora</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>
<!-- API Keys Tab -->
<div class="settings-panel active" id="tab-api">
<div class="settings-panel" id="tab-api">
<div class="settings-card">
<h4 data-i18n="settings.gemini.title">🤖 Google Gemini AI</h4>
<p class="settings-hint" data-i18n="settings.gemini.hint">Chiave API per identificazione prodotti, scadenze e ricette.</p>
@@ -851,9 +964,36 @@
</div>
<!-- Bring! Tab -->
<div class="settings-panel" id="tab-bring">
<!-- Shopping enable + provider -->
<div class="settings-card">
<h4 data-i18n="settings.bring.title">🛒 Bring! Shopping List</h4>
<p class="settings-hint" data-i18n="settings.bring.hint">Credenziali per l'integrazione con la lista della spesa Bring!</p>
<h4 data-i18n="settings.shopping.title">🛒 Lista della spesa</h4>
<p class="settings-hint" data-i18n="settings.shopping.hint">Configura la lista della spesa integrata o collega Bring!.</p>
<div class="form-group">
<label class="toggle-row">
<span data-i18n="settings.shopping.enable_label">Abilita lista della spesa</span>
<span class="toggle-switch">
<input type="checkbox" id="setting-shopping-enabled" onchange="onShoppingEnabledChange()">
<span class="toggle-slider"></span>
</span>
</label>
</div>
<div class="form-group" id="shopping-mode-group">
<label data-i18n="settings.shopping.mode_label">Provider</label>
<div class="radio-group" style="margin-top:6px">
<label class="radio-option">
<input type="radio" name="shopping-mode" value="internal" onchange="onShoppingModeChange(this.value)">
<span data-i18n="settings.shopping.mode_internal">Interno (senza Bring!)</span>
</label>
<label class="radio-option" style="margin-left:16px">
<input type="radio" name="shopping-mode" value="bring" onchange="onShoppingModeChange(this.value)">
<span data-i18n="settings.shopping.mode_bring">Bring! (app esterna)</span>
</label>
</div>
</div>
</div>
<!-- Bring! sub-section (shown only when mode = bring) -->
<div class="settings-card" id="bring-subsection" style="display:none;margin-top:12px">
<h4 data-i18n="settings.shopping.bring_section_title">Configurazione Bring!</h4>
<div class="form-group">
<label data-i18n="settings.bring.email_label">📧 Email Bring!</label>
<input type="email" id="setting-bring-email" class="form-input" placeholder="email@esempio.com">
@@ -864,6 +1004,37 @@
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-bring-password')" data-i18n="btn.toggle_password">👁️ Mostra/Nascondi</button>
</div>
</div>
<!-- Smart suggestions + forecast -->
<div class="settings-card" style="margin-top:12px">
<h4 data-i18n="settings.shopping.ai_section_title">Assistenza AI</h4>
<div class="form-group">
<label class="toggle-row">
<span data-i18n="settings.shopping.smart_suggestions_label">Suggerimenti AI</span>
<span class="toggle-switch">
<input type="checkbox" id="setting-shopping-smart-suggestions">
<span class="toggle-slider"></span>
</span>
</label>
</div>
<div class="form-group">
<label class="toggle-row">
<span data-i18n="settings.shopping.forecast_label">Previsione prodotti in esaurimento</span>
<span class="toggle-switch">
<input type="checkbox" id="setting-shopping-forecast">
<span class="toggle-slider"></span>
</span>
</label>
</div>
<div class="form-group" style="margin-top:8px">
<label data-i18n="settings.shopping.auto_add_label">Aggiungi automaticamente quando</label>
<div class="qty-control" style="margin-top:6px">
<button type="button" class="qty-btn" onclick="adjustQty('setting-shopping-auto-add', -1, 0, 20)"></button>
<input type="number" id="setting-shopping-auto-add" value="0" min="0" max="20" class="qty-input">
<button type="button" class="qty-btn" onclick="adjustQty('setting-shopping-auto-add', 1, 0, 20)">+</button>
</div>
<p class="settings-hint" data-i18n="settings.shopping.auto_add_suffix">rimasto in magazzino (0 = solo quando esaurito)</p>
</div>
</div>
<!-- Price Estimation Settings -->
<div class="settings-card" style="margin-top:12px">
<h4 data-i18n="settings.price.title">💰 Stima Prezzi (AI)</h4>
@@ -897,23 +1068,6 @@
<option value="Japan">🇯🇵 Giappone</option>
</select>
</div>
<div class="form-group">
<label data-i18n="settings.price.currency_label">💱 Valuta</label>
<select id="setting-price-currency" class="form-input">
<option value="EUR">€ Euro (EUR)</option>
<option value="USD">$ Dollaro USA (USD)</option>
<option value="GBP">£ Sterlina (GBP)</option>
<option value="CHF">CHF Franco Svizzero</option>
<option value="CAD">CA$ Dollaro Canadese</option>
<option value="AUD">A$ Dollaro Australiano</option>
<option value="BRL">R$ Real Brasiliano</option>
<option value="JPY">¥ Yen Giapponese</option>
<option value="SEK">kr Corona Svedese</option>
<option value="NOK">kr Corona Norvegese</option>
<option value="DKK">kr Corona Danese</option>
<option value="PLN">zł Zloty Polacco</option>
</select>
</div>
<div class="form-group">
<label data-i18n="settings.price.update_label">🔄 Aggiorna prezzi ogni</label>
<div class="qty-control">
@@ -1167,10 +1321,127 @@
</div>
</div><!-- /tts-server-section -->
<button class="btn btn-large btn-secondary full-width mt-2" onclick="testSound()" data-i18n="settings.tts.test_sound_btn">🔔 Esegui Test Suono</button>
<button class="btn btn-large btn-accent full-width mt-2" onclick="testTTS()" data-i18n="settings.tts.test_btn">🔊 Invia Test Vocale</button>
<div id="tts-test-status" style="display:none;margin-top:8px"></div>
<!-- HA TTS quick-fill hint -->
<div style="margin-top:12px;padding:10px 12px;background:rgba(3,169,244,0.07);border:1px solid rgba(3,169,244,0.25);border-radius:8px;font-size:0.82rem">
<span data-i18n="settings.ha.ha_hint">🏠 Se usi Home Assistant, usa il tab <strong>Home Assistant</strong> per configurare TTS, webhook e sensori.</span>
</div>
</div>
</div>
<!-- Home Assistant Tab -->
<div class="settings-panel" id="tab-ha">
<!-- Connection card -->
<div class="settings-card">
<h4 data-i18n="settings.ha.title">🏠 Home Assistant</h4>
<p class="settings-hint" data-i18n="settings.ha.hint">Integra EverShelf con Home Assistant: TTS su speaker smart, webhook per automazioni, sensori per la dashboard.</p>
<div class="form-group" style="margin-bottom:10px">
<label class="toggle-row">
<span data-i18n="settings.ha.enabled">✅ Abilita integrazione Home Assistant</span>
<span class="toggle-switch">
<input type="checkbox" id="setting-ha-enabled" onchange="onHaEnabledChange()">
<span class="toggle-slider"></span>
</span>
</label>
</div>
<div id="ha-config-section">
<div class="form-group">
<label data-i18n="settings.ha.url_label">🌐 Home Assistant URL</label>
<input type="url" id="setting-ha-url" class="form-input" placeholder="http://192.168.1.50:8123">
<p class="settings-hint" data-i18n="settings.ha.url_hint">URL base della tua istanza HA (senza slash finale). Es: <code>http://homeassistant.local:8123</code></p>
</div>
<div class="form-group">
<label data-i18n="settings.ha.token_label">🔑 Long-Lived Access Token</label>
<div style="display:flex;gap:8px;align-items:center">
<input type="password" id="setting-ha-token" class="form-input" style="flex:1" placeholder="eyJhbGci...">
<button class="btn btn-secondary" style="flex-shrink:0" onclick="togglePasswordVisibility('setting-ha-token')" data-i18n="btn.toggle_password">👁️</button>
</div>
<p class="settings-hint" data-i18n="settings.ha.token_hint">Genera un token in HA → Profilo → Token di accesso a lungo termine.</p>
</div>
<button class="btn btn-secondary full-width" onclick="testHaConnection()" data-i18n="settings.ha.test_btn">🔗 Testa connessione HA</button>
<div id="ha-test-status" style="display:none;margin-top:8px" class="settings-status"></div>
</div>
</div>
<!-- TTS via HA card -->
<div class="settings-card">
<h4 data-i18n="settings.ha.tts_title">🔊 TTS su Speaker Smart</h4>
<p class="settings-hint" data-i18n="settings.ha.tts_hint">Leggi i passi della ricetta su un altoparlante gestito da HA (Sonos, Echo, Google Home, ecc.).</p>
<div class="form-group">
<label data-i18n="settings.ha.tts_entity_label">🔈 Entity ID del media player</label>
<input type="text" id="setting-ha-tts-entity" class="form-input" placeholder="media_player.living_room">
<p class="settings-hint" data-i18n="settings.ha.tts_entity_hint">Copia l'entity ID del media player da HA → Strumenti sviluppatore → Stati.</p>
</div>
<div class="form-group">
<label data-i18n="settings.ha.tts_platform_label">🎙️ Piattaforma TTS</label>
<select id="setting-ha-tts-platform" class="form-input">
<option value="tts.speak" data-i18n="settings.ha.tts_platform_speak">tts.speak (raccomandato)</option>
<option value="notify" data-i18n="settings.ha.tts_platform_notify">notify.* (servizio notifiche)</option>
</select>
</div>
<button class="btn btn-secondary full-width" onclick="applyHaTtsPreset()" data-i18n="settings.ha.tts_apply_btn">✅ Applica preset HA al TTS</button>
<p class="settings-hint mt-2" data-i18n="settings.ha.tts_apply_hint">Configura automaticamente il tab TTS con i parametri HA corretti.</p>
</div>
<!-- Webhook card -->
<div class="settings-card">
<h4 data-i18n="settings.ha.webhook_title">⚡ Automazioni Webhook</h4>
<p class="settings-hint" data-i18n="settings.ha.webhook_hint">EverShelf chiama il webhook HA quando si verificano eventi (prodotto in scadenza, aggiunto alla lista, ecc.).</p>
<div class="form-group">
<label data-i18n="settings.ha.webhook_id_label">🔗 Webhook ID</label>
<input type="text" id="setting-ha-webhook-id" class="form-input" placeholder="evershelf_events">
<p class="settings-hint" data-i18n="settings.ha.webhook_id_hint">Crea un'automazione in HA con trigger "Webhook" e copia qui l'ID. <a href="#" onclick="showHaWebhookHelp();return false" style="color:var(--accent)">Come farlo?</a></p>
</div>
<div class="form-group">
<label data-i18n="settings.ha.webhook_events_label">📋 Eventi da notificare</label>
<div style="display:flex;flex-direction:column;gap:6px;margin-top:4px">
<label style="display:flex;align-items:center;gap:8px;font-weight:normal">
<input type="checkbox" id="ha-event-expiry" value="expiry"> <span data-i18n="settings.ha.event_expiry">Prodotti in scadenza (cron giornaliero)</span>
</label>
<label style="display:flex;align-items:center;gap:8px;font-weight:normal">
<input type="checkbox" id="ha-event-shopping" value="shopping_add"> <span data-i18n="settings.ha.event_shopping">Aggiunta alla lista della spesa</span>
</label>
<label style="display:flex;align-items:center;gap:8px;font-weight:normal">
<input type="checkbox" id="ha-event-stock" value="stock_update"> <span data-i18n="settings.ha.event_stock">Aggiornamento scorte (quantità modificata)</span>
</label>
</div>
</div>
<div class="form-group">
<label data-i18n="settings.ha.expiry_days_label">📅 Giorni anticipo per scadenze</label>
<input type="number" id="setting-ha-expiry-days" class="form-input" min="1" max="30" value="3">
<p class="settings-hint" data-i18n="settings.ha.expiry_days_hint">Quanti giorni prima della scadenza inviare l'alert.</p>
</div>
</div>
<!-- Notify service card -->
<div class="settings-card">
<h4 data-i18n="settings.ha.notify_title">📱 Notifiche Push</h4>
<p class="settings-hint" data-i18n="settings.ha.notify_hint">EverShelf invia notifiche push tramite il servizio <code>notify.*</code> di HA (Telegram, Pushover, app mobile, ecc.).</p>
<div class="form-group">
<label data-i18n="settings.ha.notify_service_label">📣 Servizio notify</label>
<input type="text" id="setting-ha-notify-service" class="form-input" placeholder="notify.mobile_app_mio_telefono">
<p class="settings-hint" data-i18n="settings.ha.notify_service_hint">Formato: <code>notify.NOME_SERVIZIO</code>. Lascia vuoto per disabilitare. Richiede token HA configurato.</p>
</div>
</div>
<!-- Sensor card (read-only info) -->
<div class="settings-card">
<h4 data-i18n="settings.ha.sensor_title">📊 Sensori REST per HA</h4>
<p class="settings-hint" data-i18n="settings.ha.sensor_hint">HA può leggere i dati dell'inventario via REST polling. Aggiungi questo snippet a <code>configuration.yaml</code>:</p>
<div id="ha-sensor-yaml" style="background:var(--bg-secondary,#f1f5f9);border-radius:8px;padding:12px;font-family:monospace;font-size:0.75rem;white-space:pre;overflow-x:auto;max-height:220px;overflow-y:auto;border:1px solid var(--border,#e2e8f0)"></div>
<button class="btn btn-secondary full-width mt-2" onclick="copyHaSensorYaml()" data-i18n="settings.ha.sensor_copy_btn">📋 Copia YAML</button>
</div>
<!-- Save button -->
<button class="btn btn-large btn-accent full-width" onclick="saveHaSettings()" data-i18n="settings.ha.save_btn">💾 Salva impostazioni HA</button>
<div id="ha-save-status" style="display:none;margin-top:8px" class="settings-status"></div>
</div>
<!-- Scale Tab -->
<div class="settings-panel" id="tab-scale">
<div class="settings-card">
@@ -1255,77 +1526,123 @@
</div>
</div>
<!-- Language Tab -->
<div class="settings-panel" id="tab-language">
<!-- Backup Tab -->
<div class="settings-panel" id="tab-backup">
<!-- Local Backup -->
<div class="settings-card">
<h4 data-i18n="settings.language.title">🌐 Lingua / Language</h4>
<p class="settings-hint" data-i18n="settings.language.hint">Seleziona la lingua dell'interfaccia. Select the interface language.</p>
<div class="form-group">
<label data-i18n="settings.language.label">🌐 Lingua</label>
<select id="setting-language" class="form-input" onchange="changeLanguage(this.value)">
</select>
<p class="settings-hint mt-2" data-i18n="settings.language.restart_notice">La pagina verrà ricaricata per applicare la nuova lingua.</p>
<h4 data-i18n="settings.backup.local_title">💾 Backup Locale</h4>
<p class="settings-hint" data-i18n="settings.backup.local_hint">Snapshot giornaliero automatico del database. Massimo 3 giorni di storico (configurabile).</p>
<div id="backup-last-info" style="margin-bottom:12px;padding:10px 12px;background:var(--bg-secondary,#f8fafc);border-radius:8px;font-size:0.83rem;color:var(--text-secondary)">
<span data-i18n="settings.info.loading">Caricamento…</span>
</div>
<div class="form-group" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:14px">
<label data-i18n="settings.backup.retention_days" style="flex-shrink:0">Retention (giorni):</label>
<input type="number" id="setting-backup-retention-days" class="form-input" style="width:80px" min="1" max="90" value="3">
</div>
<button class="btn btn-large btn-accent full-width" onclick="_backupNow()" id="btn-backup-now" data-i18n="settings.backup.backup_now">💾 Backup Ora</button>
<div id="backup-status" style="display:none;margin-top:8px" class="settings-status"></div>
<!-- List of backups -->
<div id="backup-list-container" style="margin-top:14px">
<p class="settings-hint" data-i18n="settings.info.loading">Caricamento…</p>
</div>
</div>
<!-- Google Drive -->
<div class="settings-card">
<h4 data-i18n="settings.screensaver.card_title">🌙 Salvaschermo</h4>
<p class="settings-hint" data-i18n="settings.screensaver.card_hint">Mostra un orologio con fatti utili dopo un periodo di inattività. Di default è disattivato.</p>
<div class="form-group">
<h4 data-i18n="settings.backup.gdrive_title">☁️ Google Drive</h4>
<p class="settings-hint" data-i18n="settings.backup.gdrive_hint">Carica automaticamente il backup su Google Drive usando un Service Account.</p>
<div class="form-group" style="margin-bottom:10px">
<label class="toggle-row">
<span data-i18n="settings.screensaver.label">Attiva salvaschermo</span>
<span data-i18n="settings.backup.gdrive_enabled">Abilita backup Google Drive</span>
<span class="toggle-switch">
<input type="checkbox" id="setting-screensaver-enabled">
<input type="checkbox" id="setting-gdrive-enabled" onchange="saveSettings()">
<span class="toggle-slider"></span>
</span>
</label>
</div>
<div class="form-group" id="screensaver-timeout-row" style="margin-top:10px">
<label for="setting-screensaver-timeout" style="font-size:0.85rem;font-weight:600;color:var(--text-secondary)" data-i18n="settings.screensaver.start_after">⏱️ Avvia dopo</label>
<select id="setting-screensaver-timeout" class="form-control" style="margin-top:6px;max-width:200px">
<option value="1" data-i18n="settings.screensaver.timeout_1">1 minuto</option>
<option value="2" data-i18n="settings.screensaver.timeout_2">2 minuti</option>
<option value="5" selected data-i18n="settings.screensaver.timeout_5">5 minuti</option>
<option value="10" data-i18n="settings.screensaver.timeout_10">10 minuti</option>
<option value="15" data-i18n="settings.screensaver.timeout_15">15 minuti</option>
<option value="30" data-i18n="settings.screensaver.timeout_30">30 minuti</option>
<option value="60" data-i18n="settings.screensaver.timeout_60">1 ora</option>
</select>
<div id="gdrive-config-section">
<!-- Folder ID (shared between both methods) -->
<div class="form-group">
<label data-i18n="settings.backup.gdrive_folder_id">ID Cartella Drive</label>
<input type="text" id="setting-gdrive-folder-id" class="form-input" placeholder="1ABCdef_xyz…">
<p class="settings-hint" data-i18n="settings.backup.gdrive_folder_id_hint">Copia l'ID dalla URL della cartella Drive: …/folders/<strong>ID</strong></p>
</div>
<!-- OAuth 2.0 section -->
<div id="gdrive-oauth-section">
<details style="margin-bottom:14px;background:var(--bg-secondary,#f8fafc);border-radius:8px;padding:10px 14px">
<summary style="cursor:pointer;font-weight:600;font-size:0.83rem" data-i18n="settings.backup.gdrive_oauth_how_to">📋 Come configurare OAuth 2.0 (passo dopo passo)</summary>
<ol style="margin:10px 0 0 16px;font-size:0.8rem;color:var(--text-secondary);line-height:1.8" data-i18n-html="settings.backup.gdrive_oauth_steps"></ol>
</details>
<div class="form-group">
<label data-i18n="settings.backup.gdrive_client_id">Client ID</label>
<input type="text" id="setting-gdrive-client-id" class="form-input" placeholder="1234567890-abc….apps.googleusercontent.com">
</div>
<div class="form-group">
<label data-i18n="settings.backup.gdrive_client_secret">Client Secret</label>
<input type="password" id="setting-gdrive-client-secret" class="form-input" placeholder="GOCSPX-…">
</div>
<div class="form-group" style="background:var(--bg-secondary,#f8fafc);border-radius:8px;padding:10px 14px;font-size:0.82rem">
<span data-i18n="settings.backup.gdrive_redirect_uri_label">Redirect URI (aggiungi in Google Cloud Console):</span>
<code id="gdrive-redirect-uri-display" style="display:block;margin-top:4px;word-break:break-all;color:var(--text-primary);font-size:0.78rem">http://localhost</code>
<p class="settings-hint" style="margin-top:6px;margin-bottom:0" data-i18n="settings.backup.gdrive_redirect_uri_hint">Registra questo URI in Google Cloud Console come "URI di reindirizzamento autorizzato". Per le installazioni senza dominio pubblico usa <strong>http://localhost</strong>.</p>
</div>
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:8px">
<button class="btn btn-secondary" onclick="_gdriveAuthorize()" id="btn-gdrive-authorize" data-i18n="settings.backup.gdrive_oauth_authorize">🔑 Autorizza con Google</button>
<span id="gdrive-oauth-token-status" style="font-size:0.83rem"></span>
</div>
<!-- Manual code entry (appears after clicking Authorize) -->
<div id="gdrive-code-section" style="display:none;margin-top:12px;padding:12px 14px;background:var(--bg-secondary,#f8fafc);border-radius:8px;border:1px solid var(--border)">
<p style="font-size:0.82rem;margin-bottom:8px;font-weight:600" data-i18n="settings.backup.gdrive_code_title">Incolla l'URL o il codice di autorizzazione</p>
<p class="settings-hint" style="margin-bottom:8px" data-i18n="settings.backup.gdrive_code_hint">Dopo aver autorizzato su Google, il browser proverà ad aprire <code>http://localhost</code> e mostrerà un errore. Copia l'intero URL dalla barra degli indirizzi e incollalo qui sotto.</p>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<input type="text" id="gdrive-code-input" class="form-input" style="flex:1;min-width:0" placeholder="http://localhost/?code=4%2F0A… oppure solo il codice">
<button class="btn btn-primary" onclick="_gdriveSubmitCode()" id="btn-gdrive-submit-code" data-i18n="settings.backup.gdrive_code_submit">Conferma</button>
</div>
</div>
</div>
<!-- Retention + action buttons (shared) -->
<div class="form-group" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:10px">
<label data-i18n="settings.backup.gdrive_retention_days" style="flex-shrink:0">Retention Drive (giorni, 0=tutto):</label>
<input type="number" id="setting-gdrive-retention-days" class="form-input" style="width:80px" min="0" max="365" value="30">
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:12px">
<button class="btn btn-secondary" onclick="_gdriveTest()" id="btn-gdrive-test" data-i18n="settings.backup.gdrive_test">🔗 Testa Connessione</button>
<button class="btn btn-accent" onclick="_gdrivePushNow()" id="btn-gdrive-push" data-i18n="settings.backup.gdrive_push_now">☁️ Carica Ora su Drive</button>
</div>
<div id="gdrive-test-status" style="display:none;margin-top:8px" class="settings-status"></div>
</div>
</div>
</div>
<!-- Info Tab -->
<div class="settings-panel" id="tab-info">
<!-- Gemini AI Usage card -->
<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>
<h4 data-i18n="settings.info.ai_title">Gemini AI — Token Usage</h4>
<p class="settings-hint info-ai-subtitle" data-i18n="settings.info.ai_overview">Utilizzo AI, inventario e sistema</p>
<div id="info-ai-content" style="margin-top:10px">
<p class="settings-hint" data-i18n="settings.info.loading">Caricamento…</p>
</div>
</div>
<!-- Inventory card -->
<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>
<h4 data-i18n="settings.info.inv_title">Inventario</h4>
<div id="info-inv-content" style="margin-top:10px">
<p class="settings-hint" data-i18n="settings.info.loading">Caricamento…</p>
</div>
</div>
<!-- Activity card -->
<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>
<h4 data-i18n="settings.info.act_title">Attività del mese</h4>
<div id="info-act-content" style="margin-top:10px">
<p class="settings-hint" data-i18n="settings.info.loading">Caricamento…</p>
</div>
</div>
<!-- System Info card -->
<div class="settings-card">
<h4 data-i18n="settings.info.system_title">Sistema</h4>
<div id="info-system-content" style="margin-top:10px">
<p class="settings-hint" data-i18n="settings.info.loading">Caricamento…</p>
</div>
</div>
</div>
@@ -1507,9 +1824,15 @@
</div>
<div id="recipe-result" style="display:none" class="recipe-result">
<div id="recipe-content"></div>
<button class="btn btn-large btn-secondary full-width mt-2" onclick="regenerateRecipe()" data-i18n="recipes.regenerate">
<button id="recipe-regen-btn" class="btn btn-large btn-secondary full-width mt-2" onclick="showRegenChoice()" data-i18n="recipes.regenerate">
🔄 Generane un'altra
</button>
<div id="recipe-regen-choice" style="display:none" class="recipe-regen-choice">
<p class="recipe-regen-choice-title" data-i18n="recipes.regen_choice_title">Cosa vuoi fare con questa ricetta?</p>
<button class="btn btn-large btn-warning full-width" onclick="doRegenerateReplace()" data-i18n="recipes.regen_replace">🔄 Genera un'altra (scarta questa)</button>
<button class="btn btn-large btn-success full-width mt-2" onclick="doRegenerateSave()" data-i18n="recipes.regen_save_new">💾 Salva nell'archivio e genera nuova</button>
<button class="btn btn-large btn-ghost full-width mt-2" onclick="cancelRegenChoice()" data-i18n="action.cancel">Annulla</button>
</div>
<button class="btn btn-large btn-primary full-width mt-2" onclick="closeRecipeDialog()" data-i18n="recipes.close_btn">
✅ Chiudi
</button>
@@ -1566,6 +1889,17 @@
</div>
</div>
<!-- ===== NETWORK ERROR OVERLAY ===== -->
<div id="network-error-overlay" style="display:none" aria-live="assertive" role="alert">
<div class="net-error-body">
<div class="net-error-icon" id="net-error-icon">📡</div>
<div class="net-error-title" id="net-error-title" data-i18n="error.offline_title">Nessuna connessione</div>
<div class="net-error-subtitle" id="net-error-subtitle" data-i18n="error.offline_subtitle">L'app non riesce a raggiungere il server. Verifica la connessione Wi-Fi.</div>
<div class="net-error-status" id="net-error-status"></div>
<button class="net-error-continue-btn" id="net-error-continue-btn" onclick="_enterOfflineMode()" data-i18n="error.offline_continue" style="display:none">Continua in modalità offline</button>
</div>
</div>
<!-- ===== COOKING MODE OVERLAY ===== -->
<div id="cooking-overlay" class="cooking-overlay" style="display:none" aria-live="polite">
<div id="cooking-flash-overlay" class="cooking-flash-overlay"></div>
@@ -1607,6 +1941,6 @@
</div>
</div>
<script src="assets/js/app.js?v=20260520a"></script>
<script src="assets/js/app.js?v=20260518c"></script>
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
# logs/
This directory contains EverShelf runtime log files.
Files are generated automatically by `api/logger.php` and follow the naming pattern:
```
evershelf_YYYY-MM-DD_HH.log
```
The directory is tracked in git (via this README) but `.log` files are ignored via `.gitignore`.
## Configuration (`.env`)
| Variable | Default | Description |
|---|---|---|
| `LOG_LEVEL` | `INFO` | Minimum log level: `DEBUG`, `INFO`, `WARN`, `ERROR` |
| `LOG_ROTATE_HOURS` | `24` | Hours per file before rotating |
| `LOG_MAX_FILES` | `14` | Maximum number of rotated files to keep |
## Format
```
[2026-05-18 14:23:11] [INFO ] [rid=a1b2c3d4] [action] Message {"ctx":"value"}
```
## Remote inspection
```
GET /api/?action=get_logs&lines=100&level=WARN
```
+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.20",
"version": "1.7.25",
"start_url": "/evershelf/",
"display": "standalone",
"background_color": "#f0f4e8",
+1437 -1196
View File
File diff suppressed because it is too large Load Diff
+1437 -1196
View File
File diff suppressed because it is too large Load Diff
+1380 -1196
View File
File diff suppressed because it is too large Load Diff
+1380 -1196
View File
File diff suppressed because it is too large Load Diff
+1436 -1196
View File
File diff suppressed because it is too large Load Diff