Compare commits

..

143 Commits

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

Fix: wrap the v.lang access in _initBrowserTtsVoices filter() inside its
own try/catch — bad proxies are silently discarded.
2026-05-16 16:28:06 +00:00
dependabot[bot] 91b4ecd670 ci: bump actions/checkout from 4 to 6 (#47)
chore: dependabot CI dependency update
2026-05-16 18:27:43 +02:00
dadaloop82 380fa8ee99 Merge develop → main: roadmap → GitHub Project 2026-05-16 16:21:26 +00:00
dadaloop82 89b8686f4f docs: replace static roadmap with link to GitHub Project 2026-05-16 16:21:22 +00:00
dadaloop82 b6aa07a1fd Merge develop → main: v1.7.15 settings centralization 2026-05-16 16:09:59 +00:00
dadaloop82 47c26ffdc8 v1.7.15 — centralize all settings to server (.env + SQLite)
- TTS: tts_engine, tts_rate, tts_pitch, tts_auth_header_name, tts_auth_header_value,
  tts_extra_fields now stored in .env and synced across devices via get_settings/save_settings
- meal_plan: persisted to SQLite app_settings table on every edit (selectMealPlanType,
  resetMealPlan) and restored on startup via syncSettingsFromDB — all devices stay in sync
- tts_voice: also synced to SQLite for best-effort cross-device restore
- saveSettings() sends meal_plan + tts_voice to app_settings_save after env write
- Remove deprecated SPESA_PROVIDER and SPESA_AI_PROMPT from .env
- .env.example: full rewrite documenting all 30+ keys in labelled sections
  (AI, Shopping, TTS, Preferences, Appliances, Scale, Meal Plan, Screensaver, Prices,
  Security, Developer)
2026-05-16 16:09:49 +00:00
dadaloop82 12357db933 v1.7.15 — i18n audit, appliance translation, splash min 3s, demo GIF, decimal precision, gemini key fix 2026-05-16 15:48:53 +00:00
dadaloop82 6def94948b v1.7.15 — appliance translation, gemini key preserve on save
- _applianceDisplayName(): reverse lookup from canonical Italian names
  to settings.appliances.* i18n keys, with emoji stripping — appliance
  chips now show 'Air fryer', 'Heißluftfritteuse', etc. in EN/DE
- renderAppliances(): uses translated display name; remove button title
  uses t('btn.delete') instead of hardcoded 'Rimuovi'
- addApplianceQuick(): toast now uses t('toast.appliance_added') instead
  of hardcoded Italian ' aggiunto'
- saveSettings(): gemini_key in localStorage preserved when input is empty
  (key is not pre-populated for security — blank != user deleted the key)
- saveSettings(): _geminiAvailable re-synced from server after each save
  so recipe buttons immediately reflect correct state without page reload
2026-05-16 15:48:37 +00:00
github-actions[bot] abbc2772ff chore: auto-merge develop → main
Triggered by: 473d3f5 v1.7.15 — i18n audit, splash min 3s, decimal precision, demo GIF, README fixes
2026-05-16 15:38:14 +00:00
dadaloop82 473d3f59a4 v1.7.15 — i18n audit, splash min 3s, decimal precision, demo GIF, README fixes
- Complete i18n audit: 25+ new translation keys (en/it/de) — vacuum toast,
  TTS voices, timer steps, product notes, error prefixes, form placeholders,
  barcode hints, recipe/cooking ingredient labels, unit variants
- pz/conf unit labels now use t('units.pz') / t('units.conf') throughout
- Splash screen: minimum 3-second display (_splashStart recorded at parse
  time, fade delayed by remaining ms if app loads faster)
- Quantity decimal precision: qtyNum in recipe/cooking buttons and conf
  fallback display capped to 1 decimal (was showing 7+ from raw AI output)
- Recipe/cooking buttons: removed Italian fallback strings from t() calls
- README: translated remaining Italian phrases; added demo.gif to Screenshots
- CHANGELOG: updated 1.7.15 entry with all session changes
- assets/img/demo.gif: EverShelf.gif processed at 2x speed (~36s)
2026-05-16 15:36:31 +00:00
github-actions[bot] e7ae5c90c7 chore: auto-merge develop → main
Triggered by: 195c3d3 fix(i18n): comprehensive translation pass — inventory tabs, product form, page-ai, nav, settings (recipe/mealplan/TTS/security/camera/scale/kiosk), setup wizard, screensaver timeouts; add 25+ missing i18n keys across all 3 languages
2026-05-16 13:58:16 +00:00
dadaloop82 195c3d3bfa fix(i18n): comprehensive translation pass — inventory tabs, product form, page-ai, nav, settings (recipe/mealplan/TTS/security/camera/scale/kiosk), setup wizard, screensaver timeouts; add 25+ missing i18n keys across all 3 languages 2026-05-16 13:56:41 +00:00
github-actions[bot] 85ba22c7c8 chore: auto-merge develop → main
Triggered by: 698eb72 fix(i18n): add data-i18n to all static page-use/page-add/page-action labels; fix common.cancel → btn.cancel
2026-05-16 13:44:47 +00:00
dadaloop82 698eb721f2 fix(i18n): add data-i18n to all static page-use/page-add/page-action labels; fix common.cancel → btn.cancel 2026-05-16 13:43:11 +00:00
dadaloop82 45dc79e5b7 chore(kiosk): trigger CI build for v1.7.14 with openNativeSettings 2026-05-16 13:32:28 +00:00
dadaloop82 8508993441 chore(kiosk): trigger CI build for v1.7.14 2026-05-16 13:32:03 +00:00
dadaloop82 a3147d704e chore: bump to v1.7.14 — kiosk versionCode 15, CHANGELOG 2026-05-16 13:31:54 +00:00
dadaloop82 834d8efab4 chore: bump to v1.7.14 — kiosk versionCode 15, CHANGELOG 2026-05-16 13:31:31 +00:00
github-actions[bot] 8894a5a2c7 chore: auto-merge develop → main
Triggered by: 5f4c29b feat: in-app bug report form (replaces GitHub link)
2026-05-16 13:27:29 +00:00
dadaloop82 5f4c29bd5a feat: in-app bug report form (replaces GitHub link) 2026-05-16 13:25:51 +00:00
github-actions[bot] 460875430b chore: auto-merge develop → main
Triggered by: 8a596cb fix: openNativeSettings uses try/catch instead of fragile typeof check
2026-05-16 13:19:44 +00:00
dadaloop82 8a596cb7d8 fix: openNativeSettings uses try/catch instead of fragile typeof check 2026-05-16 13:18:07 +00:00
github-actions[bot] 99b8953ccf chore: auto-merge develop → main
Triggered by: c87d7d2 fix: bump manifest.json version to 1.7.13 (was showing false update badge)
2026-05-16 13:14:25 +00:00
dadaloop82 c87d7d2cde fix: bump manifest.json version to 1.7.13 (was showing false update badge) 2026-05-16 13:12:49 +00:00
github-actions[bot] 424fc7bbe3 chore: auto-merge develop → main
Triggered by: 61a2372 feat(kiosk): add native settings shortcut in webapp settings page
2026-05-16 13:09:08 +00:00
dadaloop82 61a2372caa feat(kiosk): add native settings shortcut in webapp settings page 2026-05-16 13:07:29 +00:00
github-actions[bot] ad9be3b705 chore: auto-merge develop → main
Triggered by: bd8dc05 fix(kiosk): restore native settings gear — remove JS ⚙️ (opens wrong settings), restore visibility on modal close
2026-05-16 13:04:28 +00:00
dadaloop82 bd8dc0501a fix(kiosk): restore native settings gear — remove JS ⚙️ (opens wrong settings), restore visibility on modal close 2026-05-16 13:02:49 +00:00
github-actions[bot] c9a6f8ec42 chore: auto-merge develop → main
Triggered by: 0afdf60 fix(kiosk): settings gear lost when Kotlin pre-injects #_kiosk_overlay before JS runs
2026-05-16 12:59:52 +00:00
dadaloop82 0afdf60d38 fix(kiosk): settings gear lost when Kotlin pre-injects #_kiosk_overlay before JS runs 2026-05-16 12:58:10 +00:00
dadaloop82 6ab1da4bd5 ci(kiosk): trigger APK build — versionName 1.7.13 fix 2026-05-16 12:51:43 +00:00
dadaloop82 1566e32a85 ci(kiosk): trigger APK build for v1.7.13 (versionName fix) 2026-05-16 12:50:59 +00:00
github-actions[bot] fe7a047656 chore: auto-merge develop → main
Triggered by: 9c285b4 fix(tts): guard getVoices() against browser extension crash (Brave anti-fingerprinting, issue #61)
2026-05-16 12:48:12 +00:00
dadaloop82 9c285b426f fix(tts): guard getVoices() against browser extension crash (Brave anti-fingerprinting, issue #61) 2026-05-16 12:46:31 +00:00
github-actions[bot] c58705f35c chore: auto-merge develop → main
Triggered by: 8d87494 fix(kiosk): versionName 1.7.2→1.7.13, versionCode 13→14 (stops false update loop)
2026-05-16 12:44:27 +00:00
dadaloop82 8d874944b5 fix(kiosk): versionName 1.7.2→1.7.13, versionCode 13→14 (stops false update loop) 2026-05-16 12:42:46 +00:00
github-actions[bot] b6f85b8e29 chore: auto-merge develop → main
Triggered by: 68693e7 fix(expiry): sealed potatoes shelf life 14→30 days (aligns with JS)
2026-05-16 12:33:04 +00:00
dadaloop82 68693e7168 fix(expiry): sealed potatoes shelf life 14→30 days (aligns with JS) 2026-05-16 12:31:26 +00:00
github-actions[bot] 84c3bb6e4c chore: auto-merge develop → main
Triggered by: d8aec91 fix(cooking): extract tools from step text as fallback for old cached recipes
2026-05-16 10:02:40 +00:00
dadaloop82 d8aec91599 fix(cooking): extract tools from step text as fallback for old cached recipes 2026-05-16 10:01:05 +00:00
github-actions[bot] 11d3209482 chore: auto-merge develop → main
Triggered by: e19c256 feat(cooking): show required tools/appliances bar in cooking mode
2026-05-16 10:00:18 +00:00
dadaloop82 e19c2564f6 feat(cooking): show required tools/appliances bar in cooking mode 2026-05-16 09:58:39 +00:00
github-actions[bot] 6c0ae6627b chore: auto-merge develop → main
Triggered by: 8928c75 feat(recipes): add tools_needed field — appliances shown as chips above ingredients
2026-05-16 09:57:43 +00:00
dadaloop82 8928c75a9d feat(recipes): add tools_needed field — appliances shown as chips above ingredients 2026-05-16 09:56:10 +00:00
dadaloop82 b09b485e80 Merge branch 'main' of github-evershelf:dadaloop82/EverShelf 2026-05-16 09:36:15 +00:00
dadaloop82 9e9528054e merge: develop → main (v1.7.13 — cooking mode kiosk fix, potato shelf life, move-after-use preference) 2026-05-16 09:36:05 +00:00
github-actions[bot] 12cbcb1a29 chore: auto-merge develop → main
Triggered by: 9b9a196 fix(ux): skip move-after-use modal after 2 consistent choices; hide single-location picker
2026-05-16 09:34:22 +00:00
dadaloop82 9b9a196f73 fix(ux): skip move-after-use modal after 2 consistent choices; hide single-location picker 2026-05-16 09:32:46 +00:00
github-actions[bot] 9ce3fbcb9e chore: auto-merge develop → main
Triggered by: 3065b80 fix(expiry): potato shelf life 14→30 days in pantry; add explicit rules for onion/garlic/carrot
2026-05-16 09:26:41 +00:00
dadaloop82 3065b80370 fix(expiry): potato shelf life 14→30 days in pantry; add explicit rules for onion/garlic/carrot 2026-05-16 09:25:04 +00:00
github-actions[bot] 93acc58191 chore: auto-merge develop → main
Triggered by: d9f7755 fix(ux): hide kiosk overlay during cooking mode
2026-05-16 09:21:28 +00:00
dadaloop82 d9f775562f fix(ux): hide kiosk overlay during cooking mode 2026-05-16 09:19:51 +00:00
github-actions[bot] 85d957be2b chore: auto-merge develop → main
Triggered by: 7774fc4 docs: remove stale scale-gateway reference from README
2026-05-16 09:13:50 +00:00
dadaloop82 7774fc4cc8 docs: remove stale scale-gateway reference from README 2026-05-16 09:12:18 +00:00
dadaloop82 a0b0ed0cd7 Merge branch 'develop' 2026-05-16 09:11:31 +00:00
dadaloop82 1e831f05db ci: auto-create GitHub Release on main with version from index.html
After every develop→main merge, reads the version tag from index.html
(e.g. v1.7.13), checks if that tag already exists, and creates a new
GitHub Release if not. Body is pulled from CHANGELOG.md.

This powers the in-app update badge (`check_update` action) so
self-hosted Docker users see a notification when a new version is
available.
2026-05-16 09:10:41 +00:00
dadaloop82 855300cca1 Merge branch 'develop' 2026-05-16 08:47:06 +00:00
dadaloop82 141fca27cf docs: add Ko-fi sponsor button to README 2026-05-16 08:47:04 +00:00
dadaloop82 0ee540210a Merge branch 'develop' 2026-05-16 08:44:55 +00:00
dadaloop82 71c5b16d48 chore: fix Ko-fi username in FUNDING.yml (evershelfproject) 2026-05-16 08:44:53 +00:00
dadaloop82 5ed1fc9ac0 Merge branch 'develop' 2026-05-16 08:35:44 +00:00
dadaloop82 42149012a1 chore: remove deprecated scale-gateway app
The BLE scale gateway is fully integrated into the EverShelf Kiosk app
since v1.6.0. This standalone Android app is no longer needed or maintained.

Removal also resolves GitHub secret scanning alert #1 (legacy plain-text
GitHub PAT in ErrorReporter.kt — already revoked by GitHub automatically).
2026-05-16 08:35:36 +00:00
dadaloop82 c050ec9fa3 Merge branch 'develop' 2026-05-16 07:38:17 +00:00
dadaloop82 3cd439e068 fix(tts): filter null/undefined voices to handle Brave anti-fingerprinting
Brave browser's anti-fingerprinting user-script (makeFakeVoiceFromVoice)
intercepts the SpeechSynthesis voices array and crashes with
'undefined is not an object (evaluating Object.getPrototypeOf(voice))'
when iterating over null voice entries.

Defensive fix: filter null/undefined/no-lang entries from getVoices()
before processing, so Brave's proxy never receives invalid objects.

Fixes #58
2026-05-16 07:38:15 +00:00
dadaloop82 3430e56dfc Merge branch 'develop' 2026-05-16 07:30:36 +00:00
dadaloop82 e75b004ebc ci: trigger security scan also on security.yml changes 2026-05-16 07:30:34 +00:00
dadaloop82 f3b62ed3a1 Merge branch 'develop' 2026-05-16 07:28:33 +00:00
dadaloop82 ba5a52c5dc fix(ci): trivy-action version 0.31.0 → v0.36.0 (correct tag format) 2026-05-16 07:28:31 +00:00
dadaloop82 8366e0691d Merge branch 'develop' 2026-05-16 07:24:03 +00:00
dadaloop82 68906b2f28 fix: switch to php:8.2-apache-bookworm, add apt upgrade, Trivy ignore-unfixed
- Base image: php:8.2-apache → php:8.2-apache-bookworm (Debian 12)
  Reduces OS-level CVEs from ~1200+ to only fixable ones
- Add apt-get upgrade -y before package installs
- Trivy: add ignore-unfixed: true (suppress CVEs with no available fix)
- Pin trivy-action@0.31.0 instead of @master
- Upgrade codeql-action upload-sarif v3 → v4
2026-05-16 07:23:39 +00:00
dadaloop82 5f7d3e71ae merge: fix migration crash 'no such column: undone' (#56) 2026-05-16 07:16:03 +00:00
dadaloop82 6b982b6730 fix: migration crash 'no such column: undone' on old DBs
When migrateDB() upgraded the transactions table to add the 'waste'
CHECK constraint, the new table was created WITHOUT the 'undone' column.
The migration then tried to build idx_transactions_pid_type_undone, which
references 'undone' → PDOException SQLSTATE[HY000].

Fix:
- Add undone INTEGER DEFAULT 0 to the migration CREATE TABLE
- Replace 'INSERT INTO transactions SELECT * FROM transactions_old'
  with explicit column list (transactions_old may predate undone column)

Fixes: #56
2026-05-16 07:15:03 +00:00
dadaloop82 ef0c10ca6b merge: v1.7.14 — shelf life fix + README roadmap 2026-05-16 06:38:26 +00:00
dadaloop82 f121b8804c fix: jam/confiture opened shelf life in fridge 60→180 days
Both PHP and JS rules for opened confettura/marmellata in
section G (fridge condiments) were returning 60 days — too short.
An opened jar of jam lasts ~6 months in the fridge.

Also: update README roadmap with comprehensive, grouped view
matching the internal memory roadmap (high/medium/low/completed).

Fixes: database.php line ~412, app.js line ~1707
2026-05-16 06:38:18 +00:00
dadaloop82 bab6993e5b chore: merge develop into main (wiki English pass + v1.7.13 docs) 2026-05-16 06:33:03 +00:00
dadaloop82 80303f7900 docs(wiki): full English pass + update for v1.7.13 and built-in scale gateway
- Features.md: translate all Italian UI strings to English (chat examples,
  Avvia cottura → Start Cooking, Spiega → Explain, La quantità è giusta → correct)
- Android-Kiosk.md: translate Italian button labels (Concedi permessi →
  Grant permissions, Rileva automaticamente → Auto-discover); fix
  REQUEST_INSTALL_PACKAGES description (OTA kiosk self-updates, not scale APK);
  fix REORDER_TASKS description; add 'Header Overlay Buttons' section documenting
  the three web overlay buttons (✕ ↻ ⚙️) and the permanent native button hiding
- Scale-Gateway.md: translate Italian button labels (Cerca Bilance Bluetooth →
  Find Bluetooth Scales, Leggi bilancia → Read Scale, Disconnetti/Riconnetti →
  Disconnect/Reconnect)
- FAQ.md: translate all Italian strings (AI non disponibile → AI not available,
  Bring! non configurato, Leggi bilancia, Carica altri → Load more); replace
  outdated 'Gateway install fails' section (separate APK no longer exists for
  kiosk users) with 'Kiosk app update fails'; update ✕ button description to
  reflect the new 3-button overlay (✕ ↻ ⚙️); restore missing Getting Help section
- Home.md: update What's New v1.7.13 with complete list of changes; mark
  evershelf-scale-gateway/ as DEPRECATED in repo structure
2026-05-16 06:32:53 +00:00
dadaloop82 46ba537bec chore: merge develop into main (v1.7.13 + cooking wheel UI) 2026-05-16 06:14:03 +00:00
dadaloop82 e21b76ad7f feat(cooking): 3D wheel UI for recipe steps + cooking mode polish
- Replace flat .cooking-step-text with a perspective-based cooking wheel
  (.cooking-wheel) that shows current step, previous ghost (amber/warm)
  and next ghost (blue/cool) in a 3D card-flip layout
- CSS-only 3D: perspective 1100px, rotateX transforms for prev/next ghosts
- Smooth turn-next / turn-prev / snap animations via keyframes
- Float animation on the active step card (subtle translateY loop)
- Radial gradient glow overlay on the wheel container (CSS variable
  --wheel-glow) ready for JS tilt interaction
- prefers-reduced-motion: all animations/transitions disabled
- Mobile (<= 640px): smaller min-height and padding adjustments
- gitignore: add data/category_ai_cache.json (runtime AI cache)
2026-05-16 06:13:53 +00:00
github-actions[bot] 5f69967c7a chore: auto-merge develop → main
Triggered by: 24954cb fix: kiosk settings button position + opened-item expiry badge consistency
2026-05-16 06:11:35 +00:00
dadaloop82 24954cb893 fix: kiosk settings button position + opened-item expiry badge consistency
- kiosk: add gear button (⚙) to the left overlay (between ✕ and ↻)
  so settings are reachable from within kiosk mode without a native
  Android button. The web button calls showPage('settings').
- kiosk: permanently hide the native Android settings button via
  setNativeSettingsVisible(false) after overlay injection. Removes the
  touch bleed-through that caused the camera button tap to open kiosk
  settings instead of the scan page.
- kiosk: closeModal() no longer restores native settings visibility
  (native button is replaced, must stay hidden)
- dashboard opened-items panel: items expired by opened shelf-life but
  classified as safe by getExpiredSafety (level='ok', e.g. jam,
  condiments) now show a gentler amber 'Check soon' badge instead of
  the red  'Scaduto!' that was misleading users. Red  is now
  reserved for warning/danger safety levels only, consistent with the
  top banner which already filtered out safe-level expired items.
- header: version label corrected to v1.7.13
- translations: added expiry.badge_check_soon (it/en/de)
2026-05-16 06:10:01 +00:00
79 changed files with 8333 additions and 3530 deletions
+94 -17
View File
@@ -1,25 +1,102 @@
# EverShelf - Configuration
# Copy this file to .env and fill in your values
# cp .env.example .env
# EverShelf Configuration
# Copy this file to .env and fill in your values:
# cp .env.example .env
#
# All settings here can also be changed from the in-app Settings screen and
# will be written back to this file automatically.
# ─────────────────────────────────────────────────────────────────────────────
# Google Gemini AI API Key (required for AI features)
# Get one at: https://aistudio.google.com/app/apikey
# ── AI ────────────────────────────────────────────────────────────────────────
# Google Gemini API key (required for AI features: expiry reading, recipe gen, …)
# Get one free at: https://aistudio.google.com/app/apikey
GEMINI_API_KEY=
# Bring! Shopping List credentials (optional)
# Sign up at: https://www.getbring.com/
# ── Shopping list (Bring!) ────────────────────────────────────────────────────
# Credentials for the Bring! app (optional — app works without it)
BRING_EMAIL=
BRING_PASSWORD=
# TTS (Text-to-Speech) for cooking mode voice guidance (optional)
# Works with Home Assistant, or any HTTP endpoint that accepts text
TTS_URL=
TTS_TOKEN=
TTS_METHOD=POST
TTS_AUTH_TYPE=bearer
TTS_CONTENT_TYPE=application/json
TTS_PAYLOAD_KEY=message
# ── Text-to-Speech (TTS) ─────────────────────────────────────────────────────
# Works with Home Assistant, a local TTS server, or any HTTP endpoint.
# TTS_ENABLED: master switch (true/false)
TTS_ENABLED=false
# TTS_URL: endpoint that receives the text payload
TTS_URL=
# TTS_TOKEN: Authorization token sent as Bearer header (or empty)
TTS_TOKEN=
# TTS_METHOD: HTTP method (POST or GET)
TTS_METHOD=POST
# TTS_AUTH_TYPE: how the token is sent (bearer | basic | none)
TTS_AUTH_TYPE=bearer
# TTS_CONTENT_TYPE: request Content-Type header
TTS_CONTENT_TYPE=application/json
# TTS_PAYLOAD_KEY: JSON key that carries the text (e.g. "message", "text")
TTS_PAYLOAD_KEY=message
# TTS_ENGINE: preferred browser TTS engine ('browser', 'server', 'custom') — optional
TTS_ENGINE=
# TTS_RATE / TTS_PITCH: speech rate and pitch multipliers (1 = normal)
TTS_RATE=1
TTS_PITCH=1
# TTS_AUTH_HEADER_NAME / VALUE: custom HTTP header for authentication (optional)
TTS_AUTH_HEADER_NAME=
TTS_AUTH_HEADER_VALUE=
# TTS_EXTRA_FIELDS: additional JSON fields as key=value pairs, comma-separated (optional)
TTS_EXTRA_FIELDS=
# GitHub Error Reporting: token is hardcoded in api/index.php (same for all clients).
# No .env entry needed — update GH_ISSUE_TOKEN constant in api/index.php to rotate.
# ── User preferences ─────────────────────────────────────────────────────────
# These mirror the toggle switches in the Settings screen.
DEFAULT_PERSONS=1
PREF_VELOCE=false
PREF_POCAFAME=false
PREF_SCADENZE=true
PREF_HEALTHY=false
PREF_OPENED=true
PREF_ZEROWASTE=false
# Dietary restrictions shown to the AI (e.g. "vegetariano,senza glutine")
DIETARY=
# ── Appliances ────────────────────────────────────────────────────────────────
# Comma-separated list of appliances available in your kitchen.
# Used by the AI when generating recipes.
APPLIANCES=Forno,Microonde,Friggitrice ad aria,Pentola a pressione
# ── Camera ───────────────────────────────────────────────────────────────────
# Default camera for barcode scanning ('environment' = rear, 'user' = front)
CAMERA_FACING=environment
# ── Smart Kitchen Scale ───────────────────────────────────────────────────────
# SCALE_ENABLED: enables the scale integration
SCALE_ENABLED=false
# SCALE_GATEWAY_URL: address of the EverShelf Scale Gateway (Android app)
SCALE_GATEWAY_URL=
# ── Meal Plan ────────────────────────────────────────────────────────────────
# MEAL_PLAN_ENABLED: show the weekly meal planner tab in Settings
MEAL_PLAN_ENABLED=false
# ── Screensaver (kiosk / tablet mode) ────────────────────────────────────────
SCREENSAVER_ENABLED=false
# SCREENSAVER_TIMEOUT: inactivity seconds before screensaver activates (default 5 min)
SCREENSAVER_TIMEOUT=300
# ── Price estimates ───────────────────────────────────────────────────────────
# PRICE_ENABLED: show AI-estimated price column on the shopping list
PRICE_ENABLED=false
# PRICE_COUNTRY: country used for price context (e.g. "Italia", "Germany")
PRICE_COUNTRY=Italia
# PRICE_CURRENCY: ISO 4217 currency code (e.g. EUR, USD, GBP)
PRICE_CURRENCY=EUR
# PRICE_UPDATE_MONTHS: how many months to cache a price before re-fetching (default 3)
PRICE_UPDATE_MONTHS=3
# ── Security ─────────────────────────────────────────────────────────────────
# SETTINGS_TOKEN: if set, the Settings screen requires this token to save changes.
# Leave empty to allow anyone with access to the server to change settings.
SETTINGS_TOKEN=
# ── Developer / demo ─────────────────────────────────────────────────────────
# DEMO_MODE: when true, all write operations are blocked (for public demos)
DEMO_MODE=false
# NOTE: GitHub error reporting uses a token hardcoded in api/index.php.
# To rotate it, update the GH_ISSUE_TOKEN constant there.
+1 -1
View File
@@ -1 +1 @@
ko_fi: dadaloop82
ko_fi: evershelfproject
+2 -2
View File
@@ -17,7 +17,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up JDK 17
uses: actions/setup-java@v4
@@ -26,7 +26,7 @@ jobs:
distribution: 'temurin'
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v6
uses: gradle/actions/setup-gradle@v3
with:
gradle-version: '8.6'
+2 -2
View File
@@ -20,7 +20,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up JDK 17
uses: actions/setup-java@v4
@@ -29,7 +29,7 @@ jobs:
distribution: 'temurin'
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v6
uses: gradle/actions/setup-gradle@v3
with:
gradle-version: '8.4'
+65 -5
View File
@@ -11,7 +11,7 @@ jobs:
name: PHP Syntax Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Setup PHP
uses: shivammathur/setup-php@v2
@@ -27,7 +27,7 @@ jobs:
name: JavaScript Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Check JS syntax
run: |
@@ -37,7 +37,7 @@ jobs:
name: Docker Build Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Build Docker image
run: docker build -t evershelf-test .
@@ -53,7 +53,7 @@ jobs:
name: Validate Translation Files
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Validate JSON syntax
run: |
@@ -99,7 +99,7 @@ jobs:
contents: write
steps:
- name: Checkout (full history)
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
@@ -119,3 +119,63 @@ jobs:
Triggered by: $LAST"
git push origin main
# ── Auto-create GitHub Release on main ───────────────────────────────────
# Runs after auto-merge succeeds. Reads version from index.html,
# creates a release tag vX.Y.Z if it doesn't exist yet.
# This powers the in-app update badge for self-hosted users.
create-release:
name: Create GitHub Release
needs: [auto-merge-to-main]
if: github.ref == 'refs/heads/develop'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout main
uses: actions/checkout@v6
with:
ref: main
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Extract version from index.html
id: version
run: |
VER=$(grep -oP 'header-version">v\K[\d.]+' index.html | head -1)
echo "version=v${VER}" >> $GITHUB_OUTPUT
echo "Detected version: v${VER}"
- name: Check if tag already exists
id: tag_check
run: |
if git ls-remote --tags origin "refs/tags/${{ steps.version.outputs.version }}" | grep -q .; then
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Read CHANGELOG entry for this version
id: changelog
if: steps.tag_check.outputs.exists == 'false'
run: |
VER="${{ steps.version.outputs.version }}"
# Extract the section for this version from CHANGELOG.md
BODY=$(awk "/^## \[?${VER#v}\]?|^## ${VER}/,/^## [0-9]/" CHANGELOG.md | head -50 | tail -n +1 | grep -v "^## [0-9]" || true)
if [ -z "$BODY" ]; then
BODY="See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md) for details."
fi
# Multiline output
echo "body<<EOF" >> $GITHUB_OUTPUT
echo "$BODY" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Create release
if: steps.tag_check.outputs.exists == 'false'
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.version }}
name: "EverShelf ${{ steps.version.outputs.version }}"
body: ${{ steps.changelog.outputs.body }}
target_commitish: main
make_latest: true
+9 -6
View File
@@ -7,6 +7,7 @@ on:
- 'Dockerfile'
- 'docker-compose.yml'
- 'api/**'
- '.github/workflows/security.yml'
schedule:
# Run weekly on Monday at 07:00 UTC
- cron: '0 7 * * 1'
@@ -21,22 +22,23 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Build Docker image
run: docker build -t evershelf:scan .
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
uses: aquasecurity/trivy-action@v0.36.0
with:
image-ref: 'evershelf:scan'
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
ignore-unfixed: true
exit-code: '0' # don't fail the build, just report
- name: Upload Trivy SARIF to GitHub Security tab
uses: github/codeql-action/upload-sarif@v3
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: 'trivy-results.sarif'
category: 'trivy-docker'
@@ -49,20 +51,21 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Run Trivy filesystem scanner
uses: aquasecurity/trivy-action@master
uses: aquasecurity/trivy-action@v0.36.0
with:
scan-type: 'fs'
scan-ref: '.'
format: 'sarif'
output: 'trivy-fs-results.sarif'
severity: 'CRITICAL,HIGH'
ignore-unfixed: true
exit-code: '0'
- name: Upload Trivy FS SARIF
uses: github/codeql-action/upload-sarif@v3
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: 'trivy-fs-results.sarif'
category: 'trivy-fs'
+3
View File
@@ -49,3 +49,6 @@ evershelf-kiosk/local.properties
data/error_reports.log
data/latest_release_cache.json
data/food_facts_cache.json
data/category_ai_cache.json
assets/img/logo/*_backup.*
logs/*.log
+108
View File
@@ -5,6 +5,114 @@ All notable changes to EverShelf will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] — Ideas & Roadmap
> Ideas collected during development. No priority or date implied.
- **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.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
- **Startup health check** — During the splash screen, the app now runs a comprehensive server-side diagnostic before loading: PHP version, required extensions (pdo_sqlite, curl, mbstring, json), `data/` directory writability, SQLite database connection and table integrity, `.env` file presence, Gemini AI key and Bring! token. Results are displayed as an animated checklist (✅ / ⚠️ / ❌). Critical failures (DB, extensions, data dir) block the app with a clear error message and a "Retry" button — the app never starts silently broken. Non-critical warnings (missing Gemini key, Bring! token) are shown as amber items but do not block startup.
- New `?action=health_check` PHP endpoint (early-exit, no rate-limit, no auth).
- New translation keys `startup.*` in all 5 languages (IT, EN, DE, FR, ES).
## [1.7.19] - 2026-05-19
### Added
- **Zero-waste tips during cooking** — When cooking mode is active, a ♻️ card appears below each step that generates reusable scraps (peels, cooking water, egg whites, cheese rinds, bread crusts, vegetable tops, etc.). Gemini generates the tips as part of the recipe JSON at no extra API cost. Tips are dismissible per-step and reset on recipe restart. Opt-in toggle in Settings → Zero-waste tips (default OFF). Resolves [#76](https://github.com/dadaloop82/EverShelf/issues/76).
- New translation keys `cooking.zerowaste_*` and `settings.zerowaste.*` in all 5 languages (IT, EN, DE, FR, ES).
## [1.7.18] - 2026-05-19
### Added
- **Dark mode** — New theme selector in Settings (Appearance card): **Off (Light)**, **On (Dark)**, **Auto (follows system)**. Applied immediately on page load to prevent white flash. Resolves [#78](https://github.com/dadaloop82/EverShelf/issues/78).
- **Export inventory** — New 📤 button in inventory page header opens a modal to download the inventory as **CSV** (UTF-8 with BOM, Excel-compatible) or open a **print-ready HTML page** (auto-triggers print dialog for PDF). Export card also available in Settings tab. Resolves [#64](https://github.com/dadaloop82/EverShelf/issues/64).
- `translations/de.json`: fixed missing `log.recipe_prefix` key.
## [1.7.17] - 2026-05-19
### Added
- **French translation (🇫🇷 Français)** — Complete `translations/fr.json` with all 1049 translation keys. Resolves [#77](https://github.com/dadaloop82/EverShelf/issues/77).
- **Spanish translation (🇪🇸 Español)** — Complete `translations/es.json` with all 1049 translation keys. Resolves [#77](https://github.com/dadaloop82/EverShelf/issues/77).
- Language selector in Settings now shows all 5 languages: 🇮🇹 Italiano, 🇬🇧 English, 🇩🇪 Deutsch, 🇫🇷 Français, 🇪🇸 Español.
- Default fallback language changed from Italian to English (for users with unsupported browser locale).
- Setup wizard "Done" screen and navigation buttons localised for French and Spanish.
## [1.7.16] - 2026-05-17
### Added
- **Barcode scan history** — Last 20 scanned products are stored server-side (SQLite `app_settings`) and shown as chips in the scan page (`#scan-recents-chips`). Tapping a chip selects the product directly — no need to scan again. Resolves [#68](https://github.com/dadaloop82/EverShelf/issues/68).
- **Full server-side user-data centralisation** — All user preferences previously siloed in `localStorage` per-device are now synced to the server via `app_settings_save` and loaded back at startup via `app_settings_get`. Affected data: shopping tags, pinned Bring! items, location preferences (use/move), auto-added Bring! entries, Bring! purchased blocklist, no-expiry dismissed products. Data is now shared across all devices (desktop, phone, kiosk, Android app).
- **One-time localStorage migration** — On first load, any data found in the old localStorage keys (`shopping_tags`, `_userPinnedBring`, `_prefUseLoc`, `_prefMoveLoc`, `_autoAddedBring`, `_bringPurchasedBlocklist`, `_noExpiryDismissed`, `evershelf_scan_recents`) is automatically migrated to the server and the local keys are removed.
## [1.7.15] - 2026-05-16
### Added
- **Full i18n audit** — Comprehensive sweep of all user-visible strings in `app.js` and `index.html`. 25+ new translation keys added across `it.json`, `en.json`, `de.json`, covering: vacuum toast, TTS voice controls, timer step labels, product note labels, error messages, expiry form, barcode hint, category select placeholder, cooking step fallback, `form.select_placeholder`, `btn.yes_short`/`no_short`, `add.vacuum_question`, `add.vacuum_saved`, `move.vacuum_seal_rest`, `cooking.step_fallback`, `error.prefix`/`unknown`, `product.select_variant`, and more.
- **Splash screen redesign** — Logo displayed prominently, spinner below, app version shown at the bottom; version label injected dynamically at boot time so it never gets out of sync. Minimum 3-second display duration enforced: `_splashStart` is recorded before `DOMContentLoaded`; the fade-out is delayed by the remaining time if the app loads faster than 3 s.
- **Demo GIF in README** — `assets/img/demo.gif` (processed at 2× speed, ~36 s) added to the `## 📸 Screenshots` section.
- **`pz`/`conf` unit labels translated** — "pz" now shows as "pcs" in English and "Stk" in German; "conf" shows as "pkg" / "Pkg". All `unitLabels` objects in JS now use `t('units.pz')` / `t('units.conf')`.
### Fixed
- **Camera button (📷) opened kiosk SettingsActivity on Android** — The native `btnSettings` ImageButton in the kiosk layout was positioned `top|end` with `alpha=0.12` (nearly invisible), sitting directly on top of the HTML scan button in the webapp header. Every tap on the 📷 button was intercepted by the native View and opened `SettingsActivity`. Fixed: moved `btnSettings` to `bottom|end` (above the bottom nav bar, `marginBottom=80dp`) and increased `alpha` to `0.28` so it is clearly separate from the header. Kiosk versionCode bumped to 16.
- **Camera button (📷) opened settings on Android Chrome/Brave** — `pointerleave` fired before `pointerup` when finger drifted slightly, cancelling the long-press timer and leaving the browser to dispatch a synthetic `click` that bubbled to an unintended handler. Fixed: added `setPointerCapture` (prevents `pointerleave` during touch) and `preventDefault` (blocks synthetic click); replaced `pointerleave` with `pointercancel` handler. Added `touch-action: manipulation` to `.header-scan-btn` CSS.
- **Logo white background on splash screen** — Re-processed both `logo.png` and `logo_icon.png` with fuzz 35% alpha extraction, removing the white background that was visible against the dark splash background (`#0f172a`).
- **Recipe button label** — Shortened to "Ricetta" / "Recipe" / "Rezept" for compact display in the inventory quick-action modal.
- **Quantity decimal precision** — `qtyNum` in recipe/cooking ingredient buttons and `conf` fallback display in inventory cards now limited to 1 decimal place (was showing 7+ decimal places from raw AI output, e.g. `0.25353223 conf`).
- **"Errore" / "Error" fallback strings** — All remaining Italian hardcoded `'Errore'` fallbacks in `showToast()` calls replaced with `t('error.generic')`. Italian fallback strings removed from buttons that already used `t()`.
- **README Italian phrases** — "La quantità è giusta (2 pz)", "🤖 Spiega", "Latte / Affettato / Panna da cucina", "Buon appetito!", "L'ho buttato" replaced with English equivalents in the README.
- **Appliance chips translated** — `renderAppliances()` now shows translated names (e.g. "Air fryer" in EN, "Heißluftfritteuse" in DE) for all known canonical Italian appliance names via `_applianceDisplayName()` lookup. `addApplianceQuick` toast no longer hardcoded Italian. Remove-button title translated.
- **Gemini API key not preserved on settings save** — `saveSettings()` was overwriting `s.gemini_key = ""` when the Gemini input field was empty (it is intentionally not pre-populated for security). Key is now preserved if the input is blank. `_geminiAvailable` is re-fetched from the server after every settings save so the recipe buttons reflect the real state immediately.
## [1.7.14] - 2026-05-16
### Added
- **In-app bug report form** — "Segnala un problema" now opens a modal form instead of redirecting to GitHub. Users can select type (Bug / Feature / Question), write title and description, optionally add reproduction steps. A GitHub issue is created directly with labels and app metadata attached.
### Fixed
- **Kiosk settings button** — "Apri configurazione kiosk" in webapp settings was showing a toast asking to tap a gear icon that no longer exists. Now calls `openNativeSettings()` bridge directly (opens Android SettingsActivity). Fallback for old APKs shows a proper "update the kiosk app" hint.
- **False update badge** — `manifest.json` version was `1.7.12` while the app header showed `v1.7.13`, causing the server to report an older deployed version and triggering a spurious update notification.
- **Kiosk settings gear disappeared** — Race condition where Kotlin's `onPageFinished` injects `#_kiosk_overlay` before JS runs; JS found the element already present and returned early without ever restoring the native gear button. Fixed: JS no longer hides the native gear on load; `closeModal()` restores it with `setNativeSettingsVisible(true)`.
- **`openNativeSettings()` fragile typeof check** — Android `@JavascriptInterface` methods are not always detected as `'function'` by typeof; replaced with try/catch.
## [1.7.13] - 2026-05-16
### Fixed
+2 -2
View File
@@ -1,7 +1,7 @@
FROM php:8.2-apache
FROM php:8.2-apache-bookworm
# Install required PHP extensions + Tesseract OCR for offline expiry date reading
RUN apt-get update && apt-get install -y \
RUN apt-get update && apt-get upgrade -y && apt-get install -y \
libsqlite3-dev \
libcurl4-openssl-dev \
libonig-dev \
+61 -17
View File
@@ -24,26 +24,35 @@
[![PHP](https://img.shields.io/badge/PHP-8.0+-blue.svg)](https://www.php.net/)
[![SQLite](https://img.shields.io/badge/SQLite-3-blue.svg)](https://www.sqlite.org/)
[![Docker](https://img.shields.io/badge/Docker-Ready-2496ED.svg)](Dockerfile)
[![i18n](https://img.shields.io/badge/i18n-IT%20%7C%20EN%20%7C%20DE-orange.svg)](translations/)
[![Version](https://img.shields.io/badge/version-1.7.13-brightgreen.svg)](CHANGELOG.md)
[![i18n](https://img.shields.io/badge/i18n-IT%20%7C%20EN%20%7C%20DE%20%7C%20FR%20%7C%20ES-orange.svg)](translations/)
[![Version](https://img.shields.io/badge/version-1.7.19-brightgreen.svg)](CHANGELOG.md)
[![GitHub stars](https://img.shields.io/github/stars/dadaloop82/EverShelf?style=social)](https://github.com/dadaloop82/EverShelf/stargazers)
[![Last commit](https://img.shields.io/github/last-commit/dadaloop82/EverShelf/main)](https://github.com/dadaloop82/EverShelf/commits/main)
[![Contributors](https://img.shields.io/github/contributors/dadaloop82/EverShelf)](https://github.com/dadaloop82/EverShelf/graphs/contributors)
[![GitHub Discussions](https://img.shields.io/github/discussions/dadaloop82/EverShelf)](https://github.com/dadaloop82/EverShelf/discussions)
[![CI](https://github.com/dadaloop82/EverShelf/actions/workflows/ci.yml/badge.svg)](https://github.com/dadaloop82/EverShelf/actions/workflows/ci.yml)
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/J3J01ZNETZ)
---
## ✨ Features
> ⚙️ **New in v1.7.23 — Global settings tab, DB auto-cleanup, vacuum-sealed expiry**
> A new **Generali** tab groups all global settings (language, currency, theme, screensaver, zero-waste, export) in one place.
> Recipes older than `RECIPE_RETENTION_DAYS` and transactions older than `TRANSACTION_RETENTION_DAYS` are deleted automatically every cron cycle, followed by a SQLite `VACUUM` to keep the database small.
> Vacuum-sealed products get an extended grace period (`VACUUM_EXPIRY_EXTENSION_DAYS`, default 30 days) before being flagged as expired.
> Auto theme now follows **time of day** (dark 20:0007:00) instead of the OS setting, making it server-friendly.
### 📦 Inventory Management
- **Barcode scanning** — Scan products with your phone camera using QuaggaJS
- **Export inventory** — Download the full inventory as a UTF-8 CSV (Excel-compatible) or open a print-ready page to save as PDF; export button always visible in the inventory page header
- **Barcode scanning** — Scan products with your phone camera using QuaggaJS; last 20 scanned products saved as tappable chips so you can re-select them without rescanning
- **AI identification** — Take a photo and let Google Gemini identify the product, with suggestions from your existing inventory; gracefully shows a friendly message when AI quota is exhausted instead of a raw API error
- **Smart locations** — Track items across Pantry, Fridge, Freezer, and custom locations
- **Expiry tracking** — Automatic shelf-life estimation based on product type and storage
- **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
- **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 ("La quantità è giusta (2 pz)")
- **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)
- **Expiry date reading** — Photograph a label and extract the expiry date automatically
@@ -53,24 +62,25 @@
- **Recipe generation** — Get personalized recipes based on what's in your pantry; streams live via Server-Sent Events so results appear as they are generated
- **Smart chat assistant** — Ask questions about your inventory, get cooking tips
- **Shopping suggestions with tips** — AI-powered purchase recommendations, each enriched with a short practical buying/storing tip
- **Anomaly explanation** — "🤖 Spiega" button on anomaly banners explains in plain language why a discrepancy likely occurred and what to do
- **Anomaly explanation** — "Explain" button on anomaly banners explains in plain language why a discrepancy likely occurred and what to do
- **Model fallback** — All AI endpoints try `gemini-2.5-flash` first and fall back to `gemini-2.0-flash` automatically
- **Graceful no-key state** — When no Gemini key is configured, AI entry points show a friendly message; the header button is visually greyed with an amber dot
### 🛒 Shopping List
- **Bring! integration** — Sync with the [Bring!](https://www.getbring.com/) shopping list app
- **Generic shopping names** — Products are grouped by type ("Latte", "Affettato", "Panna da cucina") rather than brand, keeping the Bring! list clean and consolidated
- **Generic shopping names** — Products are grouped by type (e.g. "Milk", "Cold cuts", "Cooking cream") rather than brand, keeping the Bring! list clean and consolidated
- **Smart predictions** — Know what you'll need before you run out
- **Auto-add on depletion** — When a product reaches zero the app adds it to Bring! automatically, no confirmation needed
- **Auto-remove on scan** — Products are removed from the shopping list when scanned in - **Auto-migration** — Items already on the Bring! list are silently renamed to their generic name in the background (throttled, runs on list load)
- **Catalog coverage** — All product types resolve to a German Bring! catalog key for icon and category display in the Bring! app
### 🍳 Cooking Mode
- **♻️ Zero-waste tips** — For each cooking step that generates reusable scraps (peels, cooking water, egg whites, cheese rinds, bread crusts, vegetable tops, etc.), a dismissible ♻️ tip card appears with a practical reuse idea; tips are generated by Gemini as part of the recipe at no extra API cost; opt-in toggle in Settings (default OFF)
- **Step-by-step guidance** — Follow recipes with a hands-free cooking interface
- **Text-to-Speech** — Voice readout of recipe steps; supports browser Web Speech API, native Android TTS (kiosk), or a custom REST endpoint (Home Assistant, etc.); retries voice loading for up to 10 seconds with a fallback refresh button; TTS activates automatically without requiring the global TTS setting to be enabled
- **Auto-read on navigate** — Each step is read aloud automatically when you tap Next or Previous; the first step is read when entering cooking mode
- **Timer voice alerts** — 10-second countdown warning spoken aloud before each timer expires; expiry announced vocally when time is up
- **Recipe completion** — "Buon appetito!" spoken when the last step is confirmed
- **Recipe completion** — "Bon appétit!" announced via TTS when the last step is confirmed
- **Built-in timer** — Automatic timer suggestions based on recipe instructions
- **Ingredient tracking** — Mark ingredients as used during cooking; leftover quantities prompt a "move to another location" flow
@@ -80,7 +90,7 @@
- **Expiry alerts** — Visual warnings for expired and soon-to-expire items
- **Opened products panel** — Tracks partially-used items; expiry is recalculated from the opening date using AI (Gemini) + per-category rule fallback; whole sealed packages always keep their original manufacturer expiry; conf items with mixed whole + fractional units are shown as two separate entries
- **Freezer shelf-life** — Granular per-product estimates (USDA/EFSA): fish 120 d, poultry 270 d, whole red-meat cuts 365 d, mince 120 d, vegetables/fruit 270 d, generic 180 d; AI + cache still take priority over rules
- **Safety ratings** — Smart assessment of expired product safety (by category and location); expired unsafe items shown with a red danger banner and "L'ho buttato" as the primary action
- **Safety ratings** — Smart assessment of expired product safety (by category and location); expired unsafe items shown with a red danger banner and a discard action as the primary action
- **Expired product banner** — Products that have passed their effective shelf-life (including opened-product reduced expiry) appear in the top notification banner; icon, colour and title adapt to the actual safety level (✅ green for safe, 👀 amber to check, 🚫 red for danger); high-risk items get a prominent discard action
- **Quick recipe bar** — One-tap recipe suggestion using expiring products
- **Anomaly banner** — Scrollable banner with suspicious quantities and consumption prediction mismatches, with one-tap correction or inline edit
@@ -88,10 +98,19 @@
- **Swipe navigation** — Touch swipe or tap arrows/dots to browse banner notifications
- **Quick-access buttons** — Recently used and most popular products shown on the inventory page for fast access
### 📱 Progressive Web App
### 🌙 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 **⚙️ Generali** tab groups all system-wide settings (language, currency, theme, screensaver, zero-waste tips, export) at the top of the Settings panel
### 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** — Settings and data sync across devices on the same server
- **Multi-device** — All user data (shopping tags, pinned items, location preferences, scan history) is stored server-side in SQLite and shared across every device on the same instance; no data is siloed in a single browser's localStorage
### ⚖️ Smart Scale Integration (Add-on)
- **Bluetooth gateway** — Connects a BLE smart scale to EverShelf via local WebSocket
@@ -102,7 +121,7 @@
- **Stability + auto-confirm** — 10s stable wait + 5s countdown before confirming
- **Real-time status** — Scale connection indicator always visible in the header
- **Multi-protocol** — Supports Bluetooth SIG Weight Scale, Body Composition, Xiaomi Mi Scale 2 and 100+ models
- **Built into kiosk (v1.6.0+)** — BLE gateway runs as an integrated foreground service inside the [EverShelf Kiosk](evershelf-kiosk/) app; no separate APK needed. The standalone gateway app in [`evershelf-scale-gateway/`](evershelf-scale-gateway/) is deprecated but kept for non-kiosk use cases.
- **Built into kiosk (v1.6.0+)** — BLE gateway runs as an integrated foreground service inside the [EverShelf Kiosk](evershelf-kiosk/) app; no separate APK needed.
### 📺 Android Kiosk Mode (Add-on)
- **Dedicated tablet app** — Full-screen WebView wrapper for wall-mounted kitchen tablets
@@ -180,12 +199,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
@@ -350,10 +389,7 @@ The application uses no build tools — edit files directly and refresh.
## 📋 Roadmap
- [ ] User authentication / multi-user support
- [ ] Offline mode with service worker
- [ ] Export/import inventory data
- [ ] Notification system (Telegram, email) for expiring products
Feature requests, bug reports and planned work are tracked in the [**EverShelf Roadmap**](https://github.com/users/dadaloop82/projects/2) GitHub Project.
---
@@ -366,6 +402,8 @@ The app supports multiple languages via JSON translation files in the `translati
| 🇮🇹 Italian (it) | ✅ Complete (base) |
| 🇬🇧 English (en) | ✅ Complete |
| 🇩🇪 German (de) | ✅ Complete |
| 🇫🇷 French (fr) | ✅ Complete |
| 🇪🇸 Spanish (es) | ✅ Complete |
**Want to add your language?** See the [Translation Guide](CONTRIBUTING.md#-adding-translations) — just copy `translations/it.json`, translate the values, and submit a PR!
@@ -400,6 +438,12 @@ This project is licensed under the **MIT License** — see the [LICENSE](LICENSE
## 📸 Screenshots
<div align="center">
![EverShelf demo — barcode scan, inventory management and AI recipe generation](assets/img/demo.gif)
</div>
For a live walkthrough with real data and full AI enabled, visit the **[live demo](https://evershelfproject.dadaloop.it/demo)** — no installation required.
> Want to contribute a GIF or screenshots? See [CONTRIBUTING.md](CONTRIBUTING.md) — PRs welcome!
> Want to contribute additional screenshots? See [CONTRIBUTING.md](CONTRIBUTING.md) — PRs welcome!
+13
View File
@@ -79,6 +79,19 @@ 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','7') . 'd' . ")\n";
} catch (Throwable $ce) {
echo '[' . date('Y-m-d H:i:s') . '] DB cleanup warning: ' . $ce->getMessage() . "\n";
}
} catch (Throwable $e) {
$msg = $e->getMessage();
echo '[' . date('Y-m-d H:i:s') . '] ERROR: ' . $msg . "\n";
+20 -6
View File
@@ -40,8 +40,13 @@ 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);
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$db->exec("PRAGMA journal_mode=WAL");
@@ -138,11 +143,14 @@ function migrateDB(PDO $db): void {
quantity REAL NOT NULL,
location TEXT NOT NULL DEFAULT 'dispensa',
notes TEXT DEFAULT '',
undone INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE
)
");
$db->exec("INSERT INTO transactions SELECT * FROM transactions_old");
// Insert with explicit columns: transactions_old may lack 'undone' (pre-v1.7.x DB)
$db->exec("INSERT INTO transactions (id, product_id, type, quantity, location, notes, created_at)
SELECT id, product_id, type, quantity, location, notes, created_at FROM transactions_old");
$db->exec("DROP TABLE transactions_old");
$db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_product ON transactions(product_id)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(created_at)");
@@ -360,6 +368,10 @@ function estimateOpenedExpiryDaysPHP(string $name, string $category, string $loc
if (preg_match('/\b(yogurt|yaourt|yoghurt)\b/', $n)) return 2;
if (preg_match('/\blatte\b/', $n)) return 1;
if (preg_match('/\bformaggio\b/', $n)) return 2;
// Root vegetables / tubers in pantry: sfusi in un sacchetto, durano 3-5 settimane
if (preg_match('/\b(patata|patate|tubero)\b/', $n)) return 30;
if (preg_match('/\b(cipolla|cipolle|aglio|scalogno|porro)\b/', $n)) return 30;
if (preg_match('/\b(carota|carote)\b/', $n)) return 14;
return 60; // generic pantry fallback
}
@@ -372,8 +384,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;
@@ -409,7 +423,7 @@ function estimateOpenedExpiryDaysPHP(string $name, string $category, string $loc
if (preg_match('/\b(senape|mustard)\b/', $n)) return 90;
if (preg_match('/salsa\s+di\s+soia|soy\s*sauce/', $n)) return 90;
if (preg_match('/\b(tabasco|worcestershire|sriracha)\b/', $n)) return 180;
if (preg_match('/confettura|marmellata/', $n)) return 60;
if (preg_match('/confettura|marmellata/', $n)) return 180;
if (preg_match('/nutella|cioccolat/', $n)) return 60;
// ── H: Category fallbacks ────────────────────────────────────────────
@@ -442,7 +456,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;
@@ -467,7 +481,7 @@ function estimateSealedExpiryDaysPHP(string $name, string $category, string $loc
elseif (preg_match('/carota|carote|zucchina|zucchine|peperoni|melanzane/', $n)) $days = 7;
elseif (preg_match('/broccoli|cavolfiore|cavolo|spinaci|bietola/', $n)) $days = 5;
elseif (preg_match('/cipolla|cipolle/', $n)) $days = 10;
elseif (preg_match('/patata|patate/', $n)) $days = 14;
elseif (preg_match('/patata|patate/', $n)) $days = 30; // whole tubers in a bag, pantry: 3-5 weeks
elseif (preg_match('/biscott|cracker|grissini|fette\s+biscott/', $n)) $days = 180;
elseif (preg_match('/nutella|marmellata|miele/', $n)) $days = 365;
elseif (preg_match('/passata|pelati|pomodor/', $n)) $days = 730;
+1038 -90
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;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 MiB

+803 -19
View File
@@ -72,12 +72,12 @@ body {
#app-preloader {
position: fixed;
inset: 0;
background: var(--bg-dark, #0f172a);
background: #0c1222;
z-index: 200000;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.35s ease;
transition: opacity 0.45s ease;
}
#app-preloader.fade-out {
opacity: 0;
@@ -87,28 +87,213 @@ body {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
gap: 28px;
}
.app-preloader-spinner {
width: 48px;
height: 48px;
border: 4px solid rgba(255,255,255,0.15);
width: 40px;
height: 40px;
border: 3px solid rgba(255,255,255,0.1);
border-top-color: #4ade80;
border-radius: 50%;
animation: spin 0.8s linear infinite;
animation: spin 0.9s linear infinite;
}
.app-preloader-label {
color: rgba(255,255,255,0.75);
font-size: 1.2rem;
font-weight: 600;
font-size: 1.1rem;
font-weight: 500;
letter-spacing: 0.5px;
}
.app-preloader-logo {
height: 120px;
height: 150px;
width: auto;
object-fit: contain;
filter: drop-shadow(0 2px 8px rgba(0,0,0,0.4));
animation: logoPulse 3.2s ease-in-out infinite;
}
@keyframes logoPulse {
0%, 100% { filter: drop-shadow(0 4px 20px rgba(74,222,128,0.18)); }
50% { filter: drop-shadow(0 4px 36px rgba(74,222,128,0.48)); }
}
.app-preloader-version {
color: rgba(255,255,255,0.22);
font-size: 0.68rem;
font-family: monospace;
letter-spacing: 0.6px;
margin-top: -16px;
}
/* ── Startup progress section ────────────────────────────────────────── */
.preloader-progress-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
width: min(92vw, 520px);
animation: zwFadeIn 0.25s ease;
}
.preloader-bar-track {
width: 100%;
height: 3px;
background: rgba(255,255,255,0.08);
border-radius: 99px;
overflow: visible;
}
.preloader-bar {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #4ade80, #22c55e);
border-radius: 99px;
transition: width 0.22s cubic-bezier(0.4,0,0.2,1), background 0.3s ease;
box-shadow: 0 0 8px rgba(74,222,128,0.55);
}
.preloader-bar.bar-error { background: linear-gradient(90deg,#f87171,#ef4444); box-shadow: 0 0 8px rgba(239,68,68,0.5); }
.preloader-bar.bar-warn { background: linear-gradient(90deg,#fbbf24,#f59e0b); box-shadow: 0 0 8px rgba(251,191,36,0.5); }
.preloader-check-label { display: none; }
/* ── Status line: single element, opacity crossfade via JS ─────────── */
.check-ticker {
width: 100%;
height: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
}
.preloader-status-text {
font-size: clamp(0.78rem, 2vw, 0.9rem);
font-weight: 500;
letter-spacing: 0.03em;
color: rgba(255,255,255,0.45);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
padding: 0 12px;
}
.preloader-status-text.state-ok { color: #86efac; }
.preloader-status-text.state-warn { color: #fde68a; }
.preloader-status-text.state-error { color: #fca5a5; }
.preloader-warnings {
max-width: min(92vw, 600px);
width: 100%;
animation: zwFadeIn 0.3s ease;
}
/* ── Warning popup (auto-close 5s) ─────────────────────────── */
.startup-popup {
border-radius: 12px;
overflow: hidden;
width: 100%;
}
.startup-popup-warn {
background: rgba(30,20,0,0.85);
border: 1px solid rgba(251,191,36,0.45);
}
.startup-popup-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 9px 14px 7px;
font-size: 0.82rem;
font-weight: 600;
color: #fcd34d;
gap: 8px;
}
.startup-popup-countdown {
background: rgba(251,191,36,0.2);
color: #fcd34d;
border-radius: 50%;
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 700;
flex-shrink: 0;
}
.startup-popup-body {
padding: 2px 14px 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
.startup-warn-item {
display: flex;
align-items: flex-start;
gap: 8px;
}
.startup-warn-icon {
font-size: 1rem;
flex-shrink: 0;
margin-top: 1px;
}
.startup-warn-body {
font-size: 0.78rem;
color: #d4c08a;
line-height: 1.4;
}
.startup-warn-body strong {
display: block;
color: #fcd34d;
font-size: 0.82rem;
margin-bottom: 2px;
}
.startup-warn-body p {
margin: 0;
color: #c8a954;
}
.startup-popup-bar-wrap {
height: 3px;
background: rgba(251,191,36,0.15);
}
.startup-popup-bar {
height: 3px;
background: #fbbf24;
width: 100%;
will-change: width;
}
/* ── Error popup (blocking) ─────────────────────────────────── */
/* Keep .preloader-warn-badge for backward compat */
.preloader-warn-badge {
background: rgba(251,191,36,0.15);
color: #fcd34d;
border: 1px solid rgba(251,191,36,0.35);
border-radius: 99px;
padding: 3px 10px;
font-size: 0.71rem;
white-space: nowrap;
}
.preloader-error-msg {
color: #fca5a5;
background: rgba(239,68,68,0.18);
border: 1px solid rgba(239,68,68,0.4);
border-radius: 12px;
padding: 14px 18px;
font-size: 0.80rem;
text-align: left;
max-width: 300px;
line-height: 1.6;
white-space: pre-line;
animation: zwFadeIn 0.3s ease;
}
.preloader-error-msg strong {
display: block;
font-size: 0.9rem;
color: #f87171;
margin-bottom: 8px;
text-align: center;
}
.preloader-retry-btn {
background: #ef4444;
color: #fff;
border: none;
border-radius: 8px;
padding: 9px 22px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
animation: zwFadeIn 0.3s ease;
}
.preloader-retry-btn:active { opacity: 0.8; }
.header-logo-icon {
height: 28px;
width: auto;
@@ -272,6 +457,7 @@ body {
height: 48px;
box-shadow: 0 2px 8px rgba(0,0,0,0.18);
animation: pulse-scan 2s ease-in-out infinite;
touch-action: manipulation; /* prevent 300ms delay and double-tap zoom on mobile */
}
.header-scan-btn:active {
background: rgba(255,255,255,0.45);
@@ -1267,6 +1453,16 @@ body.server-offline .bottom-nav {
color: white;
}
#btn-report-bug {
background: #f97316;
color: #fff;
border-color: #ea580c;
}
#btn-report-bug:hover {
background: #ea580c;
border-color: #c2410c;
}
.btn-secondary {
background: var(--bg-card);
color: var(--text);
@@ -3103,6 +3299,36 @@ body.server-offline .bottom-nav {
justify-content: center;
}
/* ── Bug report form ── */
.bug-type-pills {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.bug-type-pill {
flex: 1;
min-width: 80px;
padding: 7px 10px;
border: 1.5px solid #cbd5e1;
border-radius: 20px;
background: #fff;
color: #475569;
font-size: 0.88rem;
cursor: pointer;
transition: border-color 0.15s, background 0.15s, color 0.15s;
-webkit-tap-highlight-color: transparent;
}
.bug-type-pill.active {
border-color: var(--primary, #2d5016);
background: var(--primary, #2d5016);
color: #fff;
font-weight: 600;
}
.bug-type-pill:not(.active):hover {
border-color: var(--primary, #2d5016);
color: var(--primary, #2d5016);
}
.modal-detail {
display: flex;
flex-direction: column;
@@ -3918,6 +4144,29 @@ body.server-offline .bottom-nav {
line-height: 1.5;
}
.recipe-tools-banner {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
background: #f0f4ff;
border: 1px solid #c7d2fe;
border-radius: var(--radius-sm);
padding: 8px 12px;
font-size: 0.85rem;
color: #3730a3;
margin-bottom: 12px;
line-height: 1.5;
}
.recipe-tool-chip {
background: #e0e7ff;
border-radius: 20px;
padding: 2px 10px;
font-size: 0.8rem;
color: #3730a3;
white-space: nowrap;
}
/* Recipe ingredient use buttons */
.recipe-ingredients {
list-style: none;
@@ -3942,6 +4191,15 @@ body.server-offline .bottom-nav {
min-width: 0;
line-height: 1.4;
}
.recipe-ing-name {
cursor: pointer;
border-bottom: 1px dashed rgba(74,222,128,0.5);
transition: color 0.15s, border-color 0.15s;
}
.recipe-ing-name:hover {
color: #4ade80;
border-bottom-color: #4ade80;
}
.btn-use-ingredient {
flex-shrink: 0;
@@ -4294,14 +4552,153 @@ body.cooking-mode-active .app-header {
transform: scale(1.35);
}
.cooking-step-text {
font-size: clamp(1.4rem, 5vw, 2.2rem);
line-height: 1.5;
font-weight: 500;
color: #fff;
.cooking-wheel {
position: relative;
--wheel-tilt-x: 0deg;
--wheel-tilt-y: 0deg;
--wheel-glow: 0.45;
width: min(90vw, 680px);
max-width: 680px;
min-height: clamp(240px, 36vh, 340px);
display: flex;
align-items: center;
justify-content: center;
perspective: 1100px;
overflow: hidden;
border-radius: 24px;
background: radial-gradient(circle at 50% 50%, rgba(255,255,255,0.07), rgba(255,255,255,0.02) 58%, transparent 100%);
border: 1px solid rgba(255,255,255,0.08);
box-shadow: inset 0 0 48px rgba(0,0,0,0.35);
touch-action: pan-y;
}
.cooking-wheel::before,
.cooking-wheel::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
}
.cooking-wheel::before {
background: radial-gradient(circle at 50% 50%, rgba(56, 189, 248, 0.18), rgba(251, 191, 36, 0.08) 36%, transparent 72%);
opacity: var(--wheel-glow);
transition: opacity 0.2s ease;
}
.cooking-wheel::after {
background: linear-gradient(180deg, rgba(0,0,0,0.34) 0%, transparent 20%, transparent 80%, rgba(0,0,0,0.34) 100%);
}
.cooking-step-text,
.cooking-step-ghost {
width: min(88vw, 620px);
text-align: center;
max-width: 560px;
width: 100%;
line-height: 1.5;
padding: 18px 20px;
border-radius: 20px;
transform-style: preserve-3d;
transition: transform 0.22s ease, opacity 0.22s ease;
}
.cooking-step-text {
position: relative;
z-index: 3;
font-size: clamp(1.35rem, 4.6vw, 2.1rem);
font-weight: 600;
color: #fff;
background: linear-gradient(180deg, rgba(255,255,255,0.14), rgba(255,255,255,0.06));
border: 1px solid rgba(255,255,255,0.20);
box-shadow: 0 18px 35px rgba(0,0,0,0.45), inset 0 1px 0 rgba(255,255,255,0.24);
transform: rotateX(var(--wheel-tilt-x)) rotateY(var(--wheel-tilt-y));
animation: cookingCardFloat 4.2s ease-in-out infinite;
}
.cooking-step-ghost {
position: absolute;
left: 50%;
top: 0;
transform-origin: center center;
font-size: clamp(1.08rem, 3.9vw, 1.52rem);
font-weight: 560;
color: rgba(255,255,255,0.95);
background: linear-gradient(180deg, rgba(255,255,255,0.18), rgba(255,255,255,0.08));
border: 1px solid rgba(255,255,255,0.22);
box-shadow: 0 12px 28px rgba(0,0,0,0.38), inset 0 1px 0 rgba(255,255,255,0.22);
text-shadow: 0 1px 2px rgba(0,0,0,0.34);
opacity: 0.66;
max-height: 42%;
overflow: hidden;
text-overflow: ellipsis;
}
.cooking-step-prev {
color: rgba(255, 243, 210, 0.97);
background: linear-gradient(180deg, rgba(251, 191, 36, 0.24), rgba(251, 191, 36, 0.10));
border-color: rgba(251, 191, 36, 0.36);
transform: translateX(-50%) rotateX(56deg) rotateY(calc(var(--wheel-tilt-y) * 0.28)) scale(0.9);
}
.cooking-step-next {
color: rgba(220, 243, 255, 0.97);
background: linear-gradient(180deg, rgba(56, 189, 248, 0.24), rgba(56, 189, 248, 0.10));
border-color: rgba(56, 189, 248, 0.36);
transform: translateX(-50%) rotateX(-56deg) rotateY(calc(var(--wheel-tilt-y) * 0.28)) scale(0.9);
}
.cooking-step-ghost.is-empty {
opacity: 0;
}
.cooking-wheel.turn-next .cooking-step-text {
animation: cookingWheelCenterNext 0.34s ease;
}
.cooking-wheel.turn-prev .cooking-step-text {
animation: cookingWheelCenterPrev 0.34s ease;
}
.cooking-wheel.turn-next .cooking-step-prev {
animation: cookingWheelGhostNext 0.34s ease;
}
.cooking-wheel.turn-prev .cooking-step-next {
animation: cookingWheelGhostPrev 0.34s ease;
}
.cooking-wheel.snap .cooking-step-text {
animation: cookingWheelSnap 0.28s ease;
}
@keyframes cookingWheelCenterNext {
from { transform: translateY(20px) rotateX(-10deg); opacity: 0.75; }
to { transform: translateY(0) rotateX(0); opacity: 1; }
}
@keyframes cookingWheelCenterPrev {
from { transform: translateY(-20px) rotateX(10deg); opacity: 0.75; }
to { transform: translateY(0) rotateX(0); opacity: 1; }
}
@keyframes cookingWheelGhostNext {
from { opacity: 0.1; transform: translateX(-50%) rotateX(68deg) scale(0.84); }
to { opacity: 0.66; transform: translateX(-50%) rotateX(56deg) scale(0.9); }
}
@keyframes cookingWheelGhostPrev {
from { opacity: 0.1; transform: translateX(-50%) rotateX(-68deg) scale(0.84); }
to { opacity: 0.66; transform: translateX(-50%) rotateX(-56deg) scale(0.9); }
}
@keyframes cookingCardFloat {
0%, 100% { transform: rotateX(var(--wheel-tilt-x)) rotateY(var(--wheel-tilt-y)) translateY(0); }
50% { transform: rotateX(var(--wheel-tilt-x)) rotateY(var(--wheel-tilt-y)) translateY(-3px); }
}
@keyframes cookingWheelSnap {
0% { transform: rotateX(var(--wheel-tilt-x)) rotateY(var(--wheel-tilt-y)) scale(0.97); }
70% { transform: rotateX(var(--wheel-tilt-x)) rotateY(var(--wheel-tilt-y)) scale(1.018); }
100% { transform: rotateX(var(--wheel-tilt-x)) rotateY(var(--wheel-tilt-y)) scale(1); }
}
.cooking-replay-btn {
@@ -4341,6 +4738,28 @@ body.cooking-mode-active .app-header {
}
.cooking-timers-bar::-webkit-scrollbar { display: none; }
.cooking-tools-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 7px 14px;
background: rgba(99,102,241,0.15);
border-bottom: 1px solid rgba(99,102,241,0.25);
flex-shrink: 0;
font-size: 0.78rem;
color: #c7d2fe;
}
.cooking-tool-chip {
background: rgba(99,102,241,0.25);
border: 1px solid rgba(99,102,241,0.4);
border-radius: 20px;
padding: 2px 9px;
font-size: 0.76rem;
color: #e0e7ff;
white-space: nowrap;
}
.cooking-timer-card {
display: flex;
align-items: center;
@@ -4576,6 +4995,32 @@ body.cooking-mode-active .app-header {
background: rgba(255,255,255,0.2);
}
@media (max-width: 640px) {
.cooking-wheel {
min-height: clamp(210px, 34vh, 300px);
border-radius: 18px;
}
.cooking-step-text,
.cooking-step-ghost {
padding: 14px 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.cooking-step-text,
.cooking-step-ghost,
.cooking-wheel.turn-next .cooking-step-text,
.cooking-wheel.turn-prev .cooking-step-text,
.cooking-wheel.turn-next .cooking-step-prev,
.cooking-wheel.turn-prev .cooking-step-next,
.cooking-wheel.snap .cooking-step-text {
animation: none !important;
transition: none !important;
}
}
/* Cooking button in recipe dialog */
.btn-cooking {
background: linear-gradient(135deg, #1e3a5f, #2d5016);
@@ -5493,7 +5938,6 @@ body.cooking-mode-active .app-header {
opacity: 0.75;
}
.alert-item-spoiled .alert-item-name {
text-decoration: line-through;
color: var(--text-light);
}
@@ -6600,3 +7044,343 @@ body.cooking-mode-active .app-header {
color: #9ca3af;
font-size: 0.8em;
}
.btn-banner-vacuum {
background: #ede9fe;
color: #6d28d9;
}
.btn-banner-edit2 {
background: #e0f2fe;
color: #0369a1;
}
/* ===== PAGE HEADER ACTION BUTTON (export etc.) ===== */
.page-header-action-btn {
margin-left: auto;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 12px;
font-size: 1.1rem;
cursor: pointer;
transition: all 0.2s;
color: var(--primary);
}
.page-header-action-btn:active {
transform: scale(0.95);
opacity: 0.8;
}
/* ===== ZERO-WASTE TIP CARD (cooking mode) ===== */
.cooking-zerowaste-tip {
display: flex;
align-items: flex-start;
gap: 10px;
background: rgba(16, 185, 129, 0.10);
border: 1.5px solid rgba(16, 185, 129, 0.35);
border-radius: 12px;
padding: 12px 14px;
margin: 10px 16px 0;
position: relative;
animation: zwFadeIn 0.3s ease;
flex-direction: column;
}
@keyframes zwFadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.cooking-zerowaste-label {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #059669;
}
.cooking-zerowaste-scrap {
font-size: 0.85rem;
font-weight: 700;
color: #065f46;
margin-left: 4px;
}
.cooking-zerowaste-text {
font-size: 0.85rem;
color: var(--text);
margin: 4px 0 0;
line-height: 1.45;
}
.cooking-zerowaste-close {
position: absolute;
top: 8px;
right: 10px;
background: none;
border: none;
font-size: 0.85rem;
cursor: pointer;
color: #6b7280;
padding: 2px 4px;
line-height: 1;
}
.cooking-zerowaste-close:hover { color: #374151; }
[data-theme="dark"] .cooking-zerowaste-tip {
background: rgba(16, 185, 129, 0.08);
border-color: rgba(16, 185, 129, 0.25);
}
[data-theme="dark"] .cooking-zerowaste-scrap { color: #6ee7b7; }
[data-theme="dark"] .cooking-zerowaste-label { color: #34d399; }
[data-theme="dark"] .cooking-zerowaste-close { color: #9ca3af; }
/* ===== DARK MODE ===== */
[data-theme="dark"] {
--bg: #0f172a;
--bg-card: #1e293b;
--bg-dark: #020617;
--text: #e2e8f0;
--text-light: #94a3b8;
--text-muted: #64748b;
--text-secondary: #94a3b8;
--border: #334155;
--shadow: 0 2px 8px rgba(0,0,0,0.45);
--shadow-lg: 0 4px 16px rgba(0,0,0,0.6);
color-scheme: dark;
}
[data-theme="dark"] body {
background: var(--bg);
color: var(--text);
}
/* Bottom nav */
[data-theme="dark"] .bottom-nav {
box-shadow: 0 -2px 10px rgba(0,0,0,0.4);
}
/* Location tabs */
[data-theme="dark"] .tab {
background: var(--bg-card);
color: var(--text-light);
}
[data-theme="dark"] .tab.active {
background: var(--primary);
color: #fff;
}
/* Location selector (add/use modal) */
[data-theme="dark"] .location-option {
background: var(--bg-card);
border-color: var(--border);
color: var(--text);
}
[data-theme="dark"] .location-option.selected {
border-color: var(--primary);
background: rgba(45,80,22,0.3);
}
/* Inputs & selects */
[data-theme="dark"] .form-input,
[data-theme="dark"] .form-control,
[data-theme="dark"] input[type="text"],
[data-theme="dark"] input[type="email"],
[data-theme="dark"] input[type="password"],
[data-theme="dark"] input[type="number"],
[data-theme="dark"] textarea,
[data-theme="dark"] select {
background: var(--bg-card);
color: var(--text);
border-color: var(--border);
}
[data-theme="dark"] input::placeholder,
[data-theme="dark"] textarea::placeholder {
color: var(--text-muted);
}
/* Buttons */
[data-theme="dark"] .btn-secondary,
[data-theme="dark"] .btn-outline,
[data-theme="dark"] .back-btn,
[data-theme="dark"] .page-header-action-btn {
background: var(--bg-card);
color: var(--text);
border-color: var(--border);
}
[data-theme="dark"] .btn-outline {
color: var(--primary-light);
border-color: var(--primary-light);
}
/* Inventory items */
[data-theme="dark"] .inventory-item {
background: var(--bg-card);
}
[data-theme="dark"] .inv-location-badge {
background: rgba(45,80,22,0.35);
color: #86efac;
}
/* Shopping items */
[data-theme="dark"] .shopping-item {
background: var(--bg-card) !important;
}
[data-theme="dark"] .shopping-item-tag-menu-container {
background: var(--bg-card);
border-color: var(--border);
}
[data-theme="dark"] .shopping-item-tag-btn {
background: #1e293b;
color: var(--text-light);
border-color: var(--border);
}
[data-theme="dark"] .badge-local-tag {
background: #0c2a4e;
color: #7dd3fc;
}
[data-theme="dark"] .badge-freq-med {
background: #2e1a4a;
color: #c4b5fd;
}
[data-theme="dark"] .badge-freq-low {
background: #1e293b;
color: #94a3b8;
}
/* Settings rows */
[data-theme="dark"] .settings-row {
border-color: var(--border);
}
[data-theme="dark"] .settings-label {
color: var(--text);
}
[data-theme="dark"] .settings-hint {
color: var(--text-muted);
}
/* Toggle switch */
[data-theme="dark"] .toggle-slider {
background: #334155;
}
/* Search bar */
[data-theme="dark"] .search-bar input {
background: var(--bg-card);
color: var(--text);
border-color: var(--border);
}
/* Action modal location selector */
[data-theme="dark"] .action-location-btn {
background: var(--bg-card);
border-color: var(--border);
color: var(--text);
}
/* Scan page */
[data-theme="dark"] .scan-input-row {
background: var(--bg-card);
}
[data-theme="dark"] .scan-result-item {
background: var(--bg-card);
border-color: var(--border);
}
/* Quick access chips */
[data-theme="dark"] .quick-access-chip {
background: var(--bg-card);
border-color: var(--border);
color: var(--text);
}
/* Scan recents */
[data-theme="dark"] .scan-recent-chip {
background: var(--bg-card);
border-color: var(--border);
color: var(--text-light);
}
/* Alert banners */
[data-theme="dark"] .alert-banner {
background: #1e293b;
border-color: #334155;
}
[data-theme="dark"] .alert-banner.banner-expiring {
background: #1c1300;
border-color: #78350f;
}
[data-theme="dark"] .alert-banner.banner-expired {
background: #1f0808;
border-color: #7f1d1d;
}
[data-theme="dark"] .alert-banner.banner-finished {
background: #0f1f0f;
border-color: #166534;
}
[data-theme="dark"] .alert-banner.banner-anomaly {
background: #1a1a2e;
border-color: #4c1d95;
}
/* Recipe dialog */
[data-theme="dark"] .recipe-dialog-content {
background: var(--bg-card);
}
[data-theme="dark"] .recipe-option-btn {
background: var(--bg-card);
border-color: var(--border);
color: var(--text);
}
[data-theme="dark"] .recipe-option-btn.active {
background: rgba(45,80,22,0.4);
border-color: var(--primary-light);
color: var(--primary-light);
}
/* Log rows */
[data-theme="dark"] .log-item {
background: var(--bg-card);
border-color: var(--border);
}
/* Dashboard stat cards */
[data-theme="dark"] .stat-card {
background: var(--bg-card);
}
/* Screensaver */
[data-theme="dark"] .screensaver-overlay {
background: #020617;
}
/* Charts / nutrition */
[data-theme="dark"] .nutrition-chart-bg {
background: var(--bg-card);
}
/* AW badges */
[data-theme="dark"] .aw-badge-rate { background: #2e1a4a; color: #c4b5fd; border-color: #6d28d9; }
[data-theme="dark"] .aw-badge-money { background: #1c1300; color: #fde047; border-color: #78350f; }
[data-theme="dark"] .aw-badge-meals { background: #0f1f0f; color: #4ade80; border-color: #166534; }
[data-theme="dark"] .aw-badge-co2 { background: #0c1f3a; color: #7dd3fc; border-color: #1e3a5f; }
[data-theme="dark"] .aw-badge-wasted{ background: #1f0808; color: #fca5a5; border-color: #7f1d1d; }
[data-theme="dark"] .aw-badge-better{ background: #0f1f0f; color: #4ade80; border-color: #166534; }
/* Chat */
[data-theme="dark"] .chat-input {
background: var(--bg-card);
color: var(--text);
border-color: var(--border);
}
[data-theme="dark"] .chat-message.user {
background: var(--primary-dark);
}
[data-theme="dark"] .chat-message.bot {
background: var(--bg-card);
}
/* Smart shopping forecast */
[data-theme="dark"] .smart-item {
background: var(--bg-card);
border-color: var(--border);
}
[data-theme="dark"] .smart-filter-btn {
background: var(--bg-card);
color: var(--text-light);
border-color: var(--border);
}
[data-theme="dark"] .smart-filter-btn.active {
background: var(--primary);
color: #fff;
border-color: var(--primary);
}
/* Offline banner */
[data-theme="dark"] #offline-banner {
background: #450a0a;
border-color: #7f1d1d;
}
/* Setup wizard */
[data-theme="dark"] .setup-content {
background: var(--bg-card);
}
[data-theme="dark"] .setup-lang-btn {
background: var(--bg-card);
color: var(--text);
border-color: var(--border);
}
[data-theme="dark"] .setup-lang-btn.selected {
background: rgba(45,80,22,0.4);
border-color: var(--primary-light);
color: var(--primary-light);
}
/* @media prefers-color-scheme: auto handled in JS */
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 289 KiB

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

+1257 -241
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": {}
}
}
-170
View File
@@ -1,170 +0,0 @@
{
"226887def70e33ef73290ebfe75ed4d0": {
"days": 7,
"source": "ai",
"name": "Polpa di pomodoro finissima",
"location": "frigo",
"ts": 1777444819
},
"0ed51c9496aa9edfe38caf41772f54ed": {
"days": 7,
"source": "rule",
"name": "Latte di Montagna",
"location": "frigo",
"ts": 1777444820
},
"2d63d0216a75d46b465150e925d2e7ad": {
"days": 30,
"source": "rule",
"name": "Burro",
"location": "frigo",
"ts": 1777444821
},
"9afdf35c4a256867ef47c32495349eb6": {
"days": 5,
"source": "rule",
"name": "Yaourt Vanille",
"location": "frigo",
"ts": 1777480477
},
"584f57418733a1f2acd29fe2e8816129": {
"days": 5,
"source": "rule",
"name": "Passata di pomodoro",
"location": "frigo",
"ts": 1778133522
},
"baeb7f2021b4bb91c368c9131a61f07c": {
"days": 10,
"source": "rule",
"name": "Formaggio Monte Maria",
"location": "frigo",
"ts": 1778133523
},
"063f2d534407214786d039bb2bffbb93": {
"days": 5,
"source": "rule",
"name": "Carote",
"location": "frigo",
"ts": 1778133524
},
"10a3d07c19bb1f889ebc9293862b4b36": {
"days": 60,
"source": "rule",
"name": "Ovomaltine",
"location": "dispensa",
"ts": 1778419084
},
"0fbad7ccd8b6155c06aaa6b3c17a67d3": {
"days": 365,
"source": "rule",
"name": "Linguine pasta di Gragnano Igp",
"location": "dispensa",
"ts": 1778419084
},
"b4a03e7356e7a0983b9c8af5f3cd8c57": {
"days": 60,
"source": "rule",
"name": "Polpa di pomodoro finissima",
"location": "dispensa",
"ts": 1778419085
},
"b8334ff0febd5c0440c9b24c9f3132ed": {
"days": 180,
"source": "rule",
"name": "Basilico tritato surgelato",
"location": "freezer",
"ts": 1778419086
},
"0cb14384d0ba763ccf12e079d6aa8d34": {
"days": 60,
"source": "rule",
"name": "Salsa Pronta Ciliegini",
"location": "dispensa",
"ts": 1778419086
},
"188634f49edb8b014a46942ee9fad689": {
"days": 180,
"source": "rule",
"name": "Farina Barilla",
"location": "dispensa",
"ts": 1778419204
},
"c8db359d8709c69a95f0e6f68216d220": {
"days": 9999,
"source": "rule",
"name": "Bicarbonato",
"location": "dispensa",
"ts": 1778419205
},
"a6d16a09fd9a6bfbd0a915f05dd71780": {
"days": 7,
"source": "ai",
"name": "Salsa Pronta Ciliegini",
"location": "frigo",
"ts": 1778419205
},
"4f8f1bb04a00e5fc62d7a9cfb21e1796": {
"days": 365,
"source": "rule",
"name": "Riso Chicchi Ricchi Gran Risparmio",
"location": "dispensa",
"ts": 1778419206
},
"e116e4c11084a463f9aaac02e1749fe7": {
"days": 90,
"source": "rule",
"name": "Salsa di soia",
"location": "dispensa",
"ts": 1778419207
},
"b1ad9afd4139b3f225b79af4dae256ce": {
"days": 60,
"source": "rule",
"name": "Tè Al limone",
"location": "dispensa",
"ts": 1778419504
},
"7ff2b7d326dcba52a664cebbf12f78a2": {
"days": 3,
"source": "ai",
"name": "Piselli fini 1\/2 vapore",
"location": "frigo",
"ts": 1778419505
},
"71062dc7ffd82b3ee4f40bad076a7c91": {
"days": 60,
"source": "rule",
"name": "Cioccolato bianco",
"location": "frigo",
"ts": 1778419506
},
"38a0eaea422dfe970eba125494e75981": {
"days": 180,
"source": "rule",
"name": "Zucca a pezzi",
"location": "freezer",
"ts": 1778419506
},
"cde21270e1cd50c431742e49117b225d": {
"days": 7,
"source": "rule",
"name": "Pancetta Dolce",
"location": "frigo",
"ts": 1778419507
},
"9e4189bd3f8cb1121e7389967dd4f74c": {
"days": 180,
"source": "rule",
"name": "Farina di grano tenero tipo rossa",
"location": "dispensa",
"ts": 1778427005
},
"e3472dd051ed13ae18fc96bbebedc1ba": {
"days": 60,
"source": "rule",
"name": "Lievito di birra",
"location": "dispensa",
"ts": 1778427005
}
}
+19 -5
View File
@@ -36,12 +36,12 @@ Overview of what the wizard will configure.
### Step 3 — Permissions
Grant camera, microphone, and storage permissions needed by the web app.
The button transforms from "Concedi permessi" to **"✅ Permessi concessi — Continua →"** (green) once all permissions are granted.
The button transforms from **"Grant permissions"** to **"✅ Permissions granted — Continue →"** (green) once all permissions are granted.
### Step 4 — Server URL
Enter your EverShelf server URL (e.g. `https://192.168.1.100/dispensa`).
**Or tap "Rileva automaticamente"** to let the wizard scan your LAN:
**Or tap "Auto-discover"** to let the wizard scan your LAN:
- 60 parallel threads, TCP pre-check, ports 80/443/8080/8443
- Only scans your actual Wi-Fi/Ethernet subnet (VPN and cellular interfaces ignored)
- Real-time feedback as hosts are tested
@@ -62,9 +62,23 @@ All done — the web app loads in full-screen kiosk mode.
---
## Header Overlay Buttons
Three buttons are injected into the top-left of the web header by the kiosk app:
| Button | Action |
|--------|--------|
| **✕** | Exit kiosk mode (confirmation dialog) |
| **↻** | Hard-refresh — clears WebView cache and reloads the app |
| **⚙️** | Open EverShelf Settings |
The native Android settings button is permanently hidden once the overlay is injected — the **⚙️** web button replaces it entirely.
---
## Exiting Kiosk Mode
Tap the **✕** button in the header (top-left). A confirmation dialog appears.
Tap the **✕** button in the header overlay (top-left). A confirmation dialog appears.
---
@@ -133,6 +147,6 @@ Requires Android Studio or JDK 17+ with the Android SDK.
| `CAMERA` | Barcode scanning and AI photo identification |
| `RECORD_AUDIO` | Voice input in AI chat |
| `WAKE_LOCK` | Keep the screen on |
| `REQUEST_INSTALL_PACKAGES` | Install the Scale Gateway APK |
| `REQUEST_INSTALL_PACKAGES` | Over-the-air kiosk self-updates (installs new APK from GitHub releases) |
| `ACCESS_WIFI_STATE` | LAN auto-discovery |
| `REORDER_TASKS` | Bring app to foreground after gateway launch |
| `REORDER_TASKS` | Bring the kiosk app to foreground when needed |
+12 -14
View File
@@ -43,15 +43,13 @@ docker compose up -d
## AI Features
### AI features don't work / "AI non disponibile"
### "AI not available" error
1. Check that `GEMINI_API_KEY` is set in `.env`
2. Verify the key is valid at [aistudio.google.com](https://aistudio.google.com)
3. Check that you haven't exceeded the free tier quota (15 req/min, 1500 req/day)
4. Look for errors in the PHP error log
### Recipe generation stops midway
This is usually a Gemini API timeout. The app streams results via SSE — if the server PHP timeout is too low, the stream is cut short. Increase `max_execution_time` in `php.ini`:
```ini
@@ -62,7 +60,7 @@ max_execution_time = 120
## Shopping List (Bring!)
### "Bring! non configurato" message in the shopping tab
### "Bring! not configured" message in the shopping tab
Add your Bring! credentials to `.env`:
@@ -90,7 +88,7 @@ BRING_PASSWORD=yourpassword
### Scale shows weight but form doesn't auto-fill
- The auto-fill only triggers for products with unit `g` or `ml`
- Make sure you tapped "⚖️ Leggi bilancia" first to activate the scale modal
- Make sure you tapped **"⚖️ Read Scale"** first to activate the scale modal
- The weight must stabilize (stay within 10g) for the countdown to start
### Bluetooth scale not appearing in the gateway app
@@ -109,19 +107,19 @@ BRING_PASSWORD=yourpassword
- Try entering the URL manually instead of using auto-discovery
- Check that the server responds on the expected port (80/443/8080/8443)
### Gateway install fails with an error dialog
### Kiosk app update fails
The dialog shows the exact failure code. Common causes:
The kiosk checks for a new release every 6 hours and downloads it from GitHub. If the install fails:
| Code | Cause | Fix |
|------|-------|-----|
| `STATUS_FAILURE` (1) | Generic install failure — often OEM restriction | Enable "Install from unknown sources" for the kiosk app in Android Settings |
| `STATUS_FAILURE_CONFLICT` (3) | Signature mismatch with existing install | Uninstall the old gateway app, then retry |
| `STATUS_FAILURE_STORAGE` (6) | Not enough storage | Free up space on the device |
| Symptom | Fix |
|---------|-----|
| "Install from unknown sources" dialog | Enable the setting for the EverShelf Kiosk app in Android Settings |
| Persistent failure after download | Force-stop the app, clear its data, and relaunch the update flow |
| Not enough space | Free up storage on the device |
### Exit button (✕) is not visible
The ✕ button is injected into the header by the kiosk app. If the web app's header is covered or the page failed to load, try the hard refresh (↻) button. If neither is visible, triple-tap the page title area to access the developer settings.
Three buttons are always visible in the kiosk header overlay: **✕** (exit), **↻** (refresh), **⚙️** (settings). If the page failed to load entirely, tap **↻** first. If nothing is visible, restart the device.
### App is stuck in kiosk mode after a crash
@@ -139,7 +137,7 @@ The version is cached by the browser. Do a hard refresh:
### Transactions are missing from the log
The log shows the last 50 entries by default. Tap "Carica altri" to load more. Entries older than the database creation date won't appear.
The log shows the last 50 entries by default. Tap **"Load more"** to load more. Entries older than the database creation date won't appear.
### "Can only undo transactions within 24 hours"
+7 -7
View File
@@ -83,9 +83,9 @@ Recipes stream live via Server-Sent Events so results appear as they are generat
### AI Chat Assistant
Open **💬 Chat** to ask questions like:
- "Cosa posso fare con le uova e la pasta?"
- "Quanti giorni dura il prosciutto cotto aperto in frigo?"
- "Suggeriscimi uno spuntino veloce"
- "What can I make with eggs and pasta?"
- "How long does cooked ham last once opened in the fridge?"
- "Suggest a quick snack"
The assistant knows your current inventory.
@@ -121,7 +121,7 @@ Configure `BRING_EMAIL` and `BRING_PASSWORD` in `.env` to enable.
## 🍳 Cooking Mode
Start cooking mode from any recipe by tapping **Avvia cottura**.
Start cooking mode from any recipe by tapping **Start Cooking**.
### Features
@@ -132,7 +132,7 @@ Start cooking mode from any recipe by tapping **▶ Avvia cottura**.
- Custom REST endpoint (e.g. Home Assistant)
- **Built-in timers** — automatic timer suggestions based on recipe text; 10-second vocal countdown warning before expiry
- **Ingredient tracking** — mark ingredients as used; leftover quantities prompt a "move to another location" flow
- **Recipe completion** — "Buon appetito!" spoken on the last step
- **Recipe completion** — "Buon appetito!" *(Enjoy your meal!)* spoken on the last step
---
@@ -155,8 +155,8 @@ Actions per item: Use, Throw away, Edit, Dismiss. Swipe or tap arrows to navigat
Highlights suspicious quantities (e.g. "You have 0 eggs but used 12 this month"). Actions:
- One-tap correction to the suggested quantity
- Inline edit with free-form quantity
- "🤖 Spiega" for AI explanation
- Dismiss (with current quantity shown: "La quantità è giusta (2 pz)")
- "🤖 Explain" for AI explanation
- Dismiss (with current quantity shown: "The quantity is correct (2 pcs)")
### Anti-Waste Report
+6 -3
View File
@@ -47,8 +47,11 @@ All data stays on your server. No cloud, no subscriptions.
## 🆕 What's New
### v1.7.13 (2026-05-16)
- **Critical fix:** Fresh-install crash resolved — `transactions` schema was missing the `undone` column, causing a database failure on every new installation
- **Fix:** Race condition in DB migrations no longer causes `duplicate column name` errors on concurrent first requests
- **Fix:** Kiosk Settings button (⚙️) added to the web overlay — tapping the camera button no longer accidentally opens kiosk settings
- **Fix:** Opened-item expiry badge is now consistent with the top banner: low-risk items (jams, condiments) show amber ⚠️ "Check soon" instead of misleading red ⛔ "Expired"
- **Cooking Mode:** 3D wheel UI with perspective card flip, ghost steps (prev/next), float animation, and full `prefers-reduced-motion` support
- **CI:** `data/category_ai_cache.json` added to `.gitignore`
- **Critical fix (DB):** Fresh-install crash resolved — `transactions` schema was missing the `undone` column
### v1.7.12 (2026-05-13)
- "Use first" banner now shows opening date and location instead of a confusing calculated expiry
@@ -81,7 +84,7 @@ EverShelf/
├── translations/ # i18n JSON files (it, en, de)
├── docs/openapi.yaml # OpenAPI 3.0 spec
├── evershelf-kiosk/ # Android kiosk app (Kotlin)
└── evershelf-scale-gateway/ # Android BLE gateway app (Kotlin)
└── evershelf-scale-gateway/ # Android BLE gateway app (Kotlin) — DEPRECATED, built into kiosk since v1.6.0
```
---
+3 -3
View File
@@ -75,7 +75,7 @@ In EverShelf **Settings → Scale**:
### 4. Connect your scale
Tap **"Cerca Bilance Bluetooth"** (Find Bluetooth Scales). Make sure your scale is powered on. Tap it in the list to pair and connect.
Tap **"Find Bluetooth Scales"**. Make sure your scale is powered on. Tap it in the list to pair and connect.
---
@@ -84,7 +84,7 @@ Tap **"Cerca Bilance Bluetooth"** (Find Bluetooth Scales). Make sure your scale
When scale integration is enabled:
1. Open the **Add** or **Use** form for any product with unit `g` or `ml`
2. A **"⚖️ Leggi bilancia"** button appears
2. A **"⚖️ Read Scale"** button appears
3. Tap it — a live weight display appears with a stability indicator
4. Step on or place the product on the scale
5. When the reading stabilizes, a **5-second countdown** starts
@@ -127,7 +127,7 @@ Every 6 hours the gateway app checks GitHub releases. If a newer version is avai
### Weight not appearing in EverShelf
- Confirm the Gateway URL in EverShelf Settings matches the URL shown in the gateway app
- Check that the Android device and the EverShelf server are on the same network
- Tap "Disconnetti / Riconnetti" in the gateway app to refresh the WebSocket connection
- Tap "Disconnect / Reconnect" in the gateway app to refresh the WebSocket connection
### "Mixed content" error in browser
- Make sure you are accessing EverShelf over HTTPS (not plain HTTP)
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "it.dadaloop.evershelf.kiosk"
minSdk = 24
targetSdk = 34
versionCode = 13
versionName = "1.7.2"
versionCode = 17
versionName = "1.7.16"
}
signingConfigs {
@@ -113,7 +113,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"
@@ -515,6 +517,17 @@ class KioskActivity : AppCompatActivity() {
btnSettings.visibility = if (visible) View.VISIBLE else View.GONE
}
}
/**
* Open the native SettingsActivity from the webapp settings page.
* Allows configuring server URL, BLE scale and screensaver without
* the user having to find the native gear button.
*/
@JavascriptInterface
fun openNativeSettings() {
runOnUiThread {
startActivity(Intent(this@KioskActivity, SettingsActivity::class.java))
}
}
}, "_kioskBridge")
val url = prefs.getString(KEY_URL, "http://evershelf.local") ?: "http://evershelf.local"
@@ -616,10 +629,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 {
@@ -634,29 +653,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)
@@ -669,12 +690,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"))
}
@@ -791,6 +811,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)
@@ -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() }
@@ -362,10 +402,10 @@ class SetupActivity : AppCompatActivity() {
bleSetupCard.visibility = View.VISIBLE
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 +421,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 +468,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 +496,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 +534,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 +545,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 +893,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 +906,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 +919,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,19 +943,19 @@ 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
@@ -896,7 +970,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 +992,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 +1045,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 +1078,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 {
@@ -43,17 +43,18 @@
android:layout_height="match_parent"
android:visibility="gone" />
<!-- Settings gear (shown after setup, over WebView) — top-right corner to avoid overlapping modals -->
<!-- Settings gear (shown after setup, over WebView) — bottom-right corner so it never
overlaps the webapp header buttons (e.g. the 📷 scan button at top-right) -->
<ImageButton
android:id="@+id/btnSettings"
android:layout_width="44dp"
android:layout_height="44dp"
android:layout_gravity="top|end"
android:layout_marginTop="8dp"
android:layout_gravity="bottom|end"
android:layout_marginBottom="80dp"
android:layout_marginEnd="8dp"
android:background="@android:color/transparent"
android:src="@android:drawable/ic_menu_manage"
android:alpha="0.12"
android:alpha="0.28"
android:contentDescription="Settings"
android:scaleType="centerInside"
android:visibility="gone" />
@@ -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>
</resources>
<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>
</resources>
<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>
+2
View File
@@ -1,3 +1,5 @@
android.useAndroidX=true
android.enableJetifier=true
# Build trigger: versionName 1.7.13 fix (8d87494)
# Build trigger: TTS bridge fix (95389eb)
# Build trigger: v1.7.14 with openNativeSettings fix (834d8ef)
-7
View File
@@ -1,7 +0,0 @@
.gradle/
build/
local.properties
*.apk
*.aab
*.class
*.dex
-156
View File
@@ -1,156 +0,0 @@
# ~~EverShelf Scale Gateway~~ — DEPRECATED
> ⚠️ **This app is deprecated and no longer maintained.**
>
> As of **EverShelf Kiosk v1.6.0**, BLE scale support is fully integrated into the kiosk app itself. You no longer need to install or configure this separate gateway app.
>
> **If you are using the EverShelf Kiosk app** → the scale gateway runs automatically as a background service. Configure your Bluetooth scale in **step 4 of the setup wizard**.
>
> **If you are NOT using the kiosk app** (standalone Android tablet) → you may still use this APK, but no new releases will be published.
---
# EverShelf Scale Gateway (legacy)
> Android gateway app that bridges Bluetooth LE smart scales with EverShelf via WebSocket.
---
## How it works
```
Smart Scale ──(BLE)──► Android Gateway App ──(WebSocket/LAN)──► EverShelf Server ──(SSE)──► Browser
```
The app runs a local WebSocket server (port **8765**) on your Android device. The EverShelf server connects to it via a server-side relay (`api/scale_relay.php` SSE + `api/scale_ping.php` WebSocket client), avoiding mixed-content (HTTPS→WS) issues. Weight readings are streamed to the browser in real time.
> **Kiosk integration (v1.6.0+):** The gateway is now **built into the EverShelf Kiosk app** as a foreground service. This separate app is not needed when using the kiosk.
---
## Supported scale protocols
| Protocol | Service UUID | Notes |
|---|---|---|
| **Bluetooth SIG Weight Scale** | `0x181D` / char `0x2A9D` | Most compatible; works with most smart scales |
| **Bluetooth SIG Body Composition** | `0x181B` / char `0x2A9C` | Reports weight + body fat %, BMI |
| **Generic fallback** | Any notifiable characteristic | Auto-heuristic parsing for 100+ models |
### Verified compatible scales (community list)
- Xiaomi Mi Body Composition Scale 2
- Renpho Smart Body Fat Scale
- INEVIFIT Smart Body Fat Scale
- Any OpenScale-compatible scale (see [openScale supported devices](https://github.com/oliexdev/openScale/wiki/Supported-scales))
> **Your scale (B09MRXVBV6):** If it implements the standard BLE Weight Scale or Body Composition profile (very likely for modern Amazon smart scales), the gateway will connect automatically. If not, check the [openScale wiki](https://github.com/oliexdev/openScale/wiki/Supported-scales) and open an issue.
---
## Download
Download the latest APK directly: **[evershelf-scale-gateway.apk](https://github.com/dadaloop82/EverShelf/releases/latest/download/evershelf-scale-gateway.apk)**
---
## Requirements
- Android **7.0** (API 24) or later
- Bluetooth LE (BLE) support
- Both the Android device and the device running EverShelf must be on the **same Wi-Fi network**
---
## Setup (step by step)
### 1. Install the APK
Download and install the APK from the Releases page. You may need to allow "Install from unknown sources" in Android settings.
### 2. Launch the app
The app starts the WebSocket gateway server immediately. You will see the **gateway URL** (e.g. `ws://192.168.1.100:8765`) at the top.
### 3. Connect your scale
Tap **"Cerca Bilance Bluetooth"** (Find Bluetooth Scales). Make sure your scale is turned on. Tap it in the list to connect.
### 4. Configure EverShelf
In EverShelf → ⚙️ Settings → **⚖️ Bilancia Smart**:
1. Enable the toggle
2. Paste the gateway URL shown in the Android app
3. Tap **"Testa connessione"** — you should see ✅
### 5. Use it
When adding or consuming a product with unit **g** or **ml**, a **"⚖️ Leggi dalla bilancia"** button appears. Tap it, place the product on the scale, and the weight is filled in automatically.
---
## WebSocket protocol reference
All messages are JSON. The server sends these to connected clients:
```json
// Scale status update
{"type":"status","state":"connected","device":"Mi Scale 2","battery":85}
{"type":"status","state":"disconnected"}
// Weight reading (broadcast continuously while scale is active)
{"type":"weight","value":72.50,"unit":"kg","stable":true,"timestamp":1712345678000}
// Response to ping
{"type":"pong"}
```
Clients can send:
```json
{"type":"get_status"} // Request current status
{"type":"get_weight"} // Request next stable weight reading
{"type":"ping"} // Keep-alive
```
---
## Build from source
### Prerequisites
- Android Studio Hedgehog (2023.1) or later
- Java 8+
### Steps
```bash
# 1. Clone the repo
git clone https://github.com/dadaloop82/EverShelf.git
cd EverShelf/evershelf-scale-gateway
# 2. Download the Gradle wrapper (if not included)
gradle wrapper --gradle-version 8.4
# 3. Build debug APK
./gradlew assembleDebug
# APK is at: app/build/outputs/apk/debug/app-debug.apk
```
---
## Project structure
```
evershelf-scale-gateway/
├── app/src/main/
│ ├── kotlin/it/dadaloop/evershelf/scalegate/
│ │ ├── MainActivity.kt — UI, orchestration
│ │ ├── BleScaleManager.kt — BLE scanning & GATT connection
│ │ ├── ScaleProtocol.kt — Parsing for all supported protocols
│ │ └── GatewayWebSocketServer.kt — WebSocket server (Java-WebSocket)
│ ├── res/layout/
│ │ ├── activity_main.xml
│ │ └── item_device.xml
│ └── AndroidManifest.xml
├── build.gradle.kts
└── settings.gradle.kts
```
---
## License
MIT — see [LICENSE](../LICENSE)
@@ -1,41 +0,0 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "it.dadaloop.evershelf.scalegate"
compileSdk = 34
defaultConfig {
applicationId = "it.dadaloop.evershelf.scalegate"
minSdk = 24
targetSdk = 34
versionCode = 8
versionName = "2.1.1"
}
buildFeatures {
viewBinding = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.11.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
implementation("androidx.recyclerview:recyclerview:1.3.2")
// WebSocket server
implementation("org.java-websocket:Java-WebSocket:1.5.5")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
}
@@ -1,64 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- BLE permissions for Android < 12 -->
<uses-permission android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<!-- BLE permissions for Android 12+ -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Location (required for BLE scanning on Android 611) -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- Network (for WebSocket server) -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- Keep screen on while gateway is active -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Self-update: install APK downloaded at runtime -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.MaterialComponents.Light.NoActionBar">
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- FileProvider for serving the downloaded APK to the installer -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -1,455 +0,0 @@
package it.dadaloop.evershelf.scalegate
import android.Manifest
import android.bluetooth.*
import android.bluetooth.le.*
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.core.content.ContextCompat
private const val TAG = "BleScaleManager"
private const val SCAN_PERIOD_MS = 15_000L
private const val PREFS_NAME = "evershelf_gateway"
private const val PREF_LAST_DEVICE = "last_device_address"
/**
* Represents a discovered BLE device during scan.
*/
data class BleDeviceInfo(
val device: BluetoothDevice,
val name: String,
val rssi: Int,
val proximity: String,
val scaleScore: Int,
)
/**
* Callback interface for BLE events dispatched back to the UI.
*/
interface BleScaleListener {
fun onDeviceFound(info: BleDeviceInfo)
fun onConnecting(device: BluetoothDevice)
fun onConnected(deviceName: String)
fun onDisconnected()
fun onWeightReceived(reading: WeightReading)
fun onBatteryReceived(level: Int)
fun onError(message: String)
fun onScanStopped()
fun onDebugEvent(message: String)
}
/**
* Manages BLE scanning and connection to a smart scale.
* All listener callbacks are dispatched on the main thread.
*/
class BleScaleManager(
private val context: Context,
private val listener: BleScaleListener,
) {
private val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
private val bluetoothAdapter: BluetoothAdapter? get() = bluetoothManager.adapter
private val mainHandler = Handler(Looper.getMainLooper())
private var leScanner: BluetoothLeScanner? = null
private var gatt: BluetoothGatt? = null
private var isScanning = false
private var connectedDeviceName: String = ""
private var autoConnectAddress: String? = null
// The characteristics we will subscribe to (multiple may exist).
private val pendingSubscriptions = ArrayDeque<BluetoothGattCharacteristic>()
// ─── Public state ──────────────────────────────────────────────────────────
val isConnected: Boolean get() = gatt != null && connectedDeviceName.isNotEmpty()
// ─── Saved device (auto-reconnect) ─────────────────────────────────────────
fun getSavedDeviceAddress(): String? {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getString(PREF_LAST_DEVICE, null)
}
private fun saveDeviceAddress(address: String) {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit().putString(PREF_LAST_DEVICE, address).apply()
}
fun enableAutoConnect() {
autoConnectAddress = getSavedDeviceAddress()
}
// ─── Permissions helper ────────────────────────────────────────────────────
fun hasRequiredPermissions(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED
} else {
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
}
}
// ─── Scanning ──────────────────────────────────────────────────────────────
fun startScan() {
val adapter = bluetoothAdapter ?: run {
listener.onError("Bluetooth not available on this device.")
return
}
if (!adapter.isEnabled) {
listener.onError("Bluetooth is off. Enable it and try again.")
return
}
if (isScanning) stopScan()
leScanner = adapter.bluetoothLeScanner
val settings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.build()
// No service UUID filters — many consumer scales use proprietary UUIDs
// and would be invisible with strict filtering. We show all named BLE devices.
isScanning = true
try {
leScanner?.startScan(null, settings, scanCallback)
} catch (e: Exception) {
leScanner?.startScan(scanCallback)
}
// Auto-stop after SCAN_PERIOD_MS
mainHandler.postDelayed({
stopScan()
listener.onScanStopped()
}, SCAN_PERIOD_MS)
}
fun stopScan() {
if (!isScanning) return
isScanning = false
try {
leScanner?.stopScan(scanCallback)
} catch (e: Exception) { /* ignore */ }
leScanner = null
}
private val scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
val device = result.device
val name = result.scanRecord?.deviceName?.takeIf { it.isNotBlank() }
?: getDeviceName(device)
val proximity = rssiToProximity(result.rssi)
val score = scoreLikelyScale(name, result.scanRecord)
val info = BleDeviceInfo(device, name, result.rssi, proximity, score)
mainHandler.post { listener.onDeviceFound(info) }
// Auto-connect to saved device
if (autoConnectAddress != null && device.address == autoConnectAddress && !isConnected) {
autoConnectAddress = null // prevent re-trigger
mainHandler.post {
listener.onDebugEvent("\uD83D\uDD04 Auto-connecting to $name (${device.address})")
connect(device)
}
}
}
override fun onScanFailed(errorCode: Int) {
isScanning = false
mainHandler.post { listener.onError("BLE scan failed (code: $errorCode)") }
}
}
private fun getDeviceName(device: BluetoothDevice): String {
return try {
device.name?.takeIf { it.isNotBlank() } ?: "Unnamed"
} catch (e: SecurityException) {
"Unnamed"
}
}
private fun rssiToProximity(rssi: Int) = when {
rssi >= -60 -> "📶 Near"
rssi >= -80 -> "📶 Medium"
else -> "📶 Far"
}
private fun scoreLikelyScale(name: String, scanRecord: android.bluetooth.le.ScanRecord?): Int {
var score = 0
val lower = name.lowercase()
// Kitchen / food scale brand and model keywords
val foodKeywords = listOf(
"scale", "bilancia", "kitchen", "food", "cucina",
"coffee", "caffe", "balance", "weight", "waage",
"arboleaf", "ck10", "ck20", "ek-",
"acaia", "felicita", "decent", "skale",
"timemore", "brewista", "hario",
"greater goods", "ozeri", "etekcity", "nutri",
"nicewell", "koios", "renpho", "eatsmart",
)
if (foodKeywords.any { lower.contains(it) }) score += 10
// Negative: body/fitness scale keywords (demote but don't hide)
val bodyKeywords = listOf(
"body", "fat", "bmi", "composition", "fitness",
"mi body", "lepulse", "qardio", "garmin", "withings",
)
if (bodyKeywords.any { lower.contains(it) }) score -= 5
// Service UUID scoring
scanRecord?.serviceUuids?.let { uuids ->
val us = uuids.map { it.uuid.toString().lowercase() }
// SIG Weight Scale service
if (us.any { it.startsWith("0000181d") }) score += 15
// Common vendor services on kitchen scales
if (us.any { it.startsWith("0000ffe0") || it.startsWith("0000fff0") }) score += 10
// Acaia coffee scale
if (us.any { it.startsWith("49535343") }) score += 20
// Body Composition service = body scale, demote
if (us.any { it.startsWith("0000181b") }) score -= 10
}
return score
}
// ─── Connection ────────────────────────────────────────────────────────────
fun connect(device: BluetoothDevice) {
stopScan()
disconnect()
connectedDeviceName = ""
ScaleProtocol.resetState()
mainHandler.post { listener.onConnecting(device) }
try {
gatt = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
} else {
device.connectGatt(context, false, gattCallback)
}
} catch (e: SecurityException) {
mainHandler.post { listener.onError("Missing permission: ${e.message}") }
}
}
fun disconnect() {
pendingSubscriptions.clear()
try {
gatt?.disconnect()
gatt?.close()
} catch (e: Exception) { /* ignore */ }
gatt = null
connectedDeviceName = ""
}
// ─── GATT callbacks ────────────────────────────────────────────────────────
private val gattCallback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
when (newState) {
BluetoothProfile.STATE_CONNECTED -> {
Log.d(TAG, "Connected — discovering services…")
mainHandler.postDelayed({ gatt.discoverServices() }, 500)
}
BluetoothProfile.STATE_DISCONNECTED -> {
Log.d(TAG, "Disconnected (status=$status)")
this@BleScaleManager.gatt?.close()
this@BleScaleManager.gatt = null
connectedDeviceName = ""
mainHandler.post { listener.onDisconnected() }
}
}
}
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
if (status != BluetoothGatt.GATT_SUCCESS) {
mainHandler.post { listener.onError("Servizi GATT non trovati (status=$status)") }
return
}
val targetChars = mutableListOf<BluetoothGattCharacteristic>()
// Priority 1: BLE SIG Weight Scale Service
gatt.getService(BleUuids.WEIGHT_SCALE_SERVICE)
?.getCharacteristic(BleUuids.WEIGHT_MEASUREMENT_CHAR)
?.let { targetChars.add(it) }
// Priority 2: Common vendor service FFE0 (arboleaf, generic kitchen scales)
gatt.getService(BleUuids.FFE0)?.let { svc ->
svc.getCharacteristic(BleUuids.FFE1)?.let { targetChars.add(it) }
}
// Priority 3: Common vendor service FFF0
gatt.getService(BleUuids.FFF0)?.let { svc ->
svc.getCharacteristic(BleUuids.FFF4)?.let { targetChars.add(it) }
?: svc.getCharacteristic(BleUuids.FFF1)?.let { targetChars.add(it) }
}
// Priority 4: Acaia coffee scale
gatt.getService(BleUuids.ACAIA_SERVICE)?.let { svc ->
svc.getCharacteristic(BleUuids.ACAIA_CHAR)?.let { targetChars.add(it) }
}
// Fallback: any notifiable characteristic from remaining services
if (targetChars.isEmpty()) {
for (service in gatt.services) {
if (service.uuid.toString().startsWith("00001800") ||
service.uuid.toString().startsWith("00001801")) continue
for (char in service.characteristics) {
val props = char.properties
if ((props and BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0 ||
(props and BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0) {
if (!targetChars.contains(char)) targetChars.add(char)
}
}
}
}
if (targetChars.isEmpty()) {
mainHandler.post { listener.onError("No weight characteristic found. Make sure it's a BLE kitchen scale.") }
return
}
// Battery (optional)
gatt.getService(BleUuids.BATTERY_SERVICE)
?.getCharacteristic(BleUuids.BATTERY_LEVEL_CHAR)
?.let { targetChars.add(it) }
// Debug: log all discovered services and characteristics
val dbg = buildString {
append("GATT services (${gatt.services.size}):\n")
for (svc in gatt.services) {
append(" SVC: ${svc.uuid}\n")
for (ch in svc.characteristics) {
val p = ch.properties
val flags = buildString {
if (p and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0) append("N")
if (p and BluetoothGattCharacteristic.PROPERTY_INDICATE != 0) append("I")
if (p and BluetoothGattCharacteristic.PROPERTY_READ != 0) append("R")
if (p and BluetoothGattCharacteristic.PROPERTY_WRITE != 0) append("W")
}
append(" CHAR: ${ch.uuid} [$flags]\n")
}
}
append("Subscribed to ${targetChars.size} characteristics")
}
mainHandler.post { listener.onDebugEvent(dbg) }
// Save device for auto-reconnect
try { gatt.device?.address?.let { saveDeviceAddress(it) } } catch (_: SecurityException) {}
pendingSubscriptions.clear()
pendingSubscriptions.addAll(targetChars)
val deviceName = try { gatt.device?.name ?: "Scale" } catch (e: SecurityException) { "Scale" }
connectedDeviceName = deviceName
mainHandler.post { listener.onConnected(deviceName) }
// Subscribe one at a time (Android BLE requires sequential descriptor writes)
subscribeNext(gatt)
}
override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
// Subscribe to the next characteristic
subscribeNext(gatt)
}
@Suppress("DEPRECATION")
override fun onCharacteristicChanged(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
) {
val data = characteristic.value ?: return
processCharacteristicData(characteristic, data)
}
// Android 13+ override
override fun onCharacteristicChanged(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray,
) {
processCharacteristicData(characteristic, value)
}
@Suppress("DEPRECATION")
override fun onCharacteristicRead(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
status: Int,
) {
if (status == BluetoothGatt.GATT_SUCCESS && characteristic.uuid == BleUuids.BATTERY_LEVEL_CHAR) {
val level = characteristic.value?.firstOrNull()?.toInt()?.and(0xFF)
if (level != null) mainHandler.post { listener.onBatteryReceived(level) }
}
}
}
// ─── Helpers ───────────────────────────────────────────────────────────────
private fun subscribeNext(gatt: BluetoothGatt) {
val char = pendingSubscriptions.removeFirstOrNull() ?: return
// Battery characteristic — read once instead of notify
if (char.uuid == BleUuids.BATTERY_LEVEL_CHAR) {
try { gatt.readCharacteristic(char) } catch (e: SecurityException) { /* ignore */ }
return
}
val props = char.properties
val notifyType = when {
(props and BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0 ->
BluetoothGattDescriptor.ENABLE_INDICATION_VALUE
else -> BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
}
try {
gatt.setCharacteristicNotification(char, true)
val descriptor = char.getDescriptor(CCCD_UUID) ?: run {
// No CCCD — skip and try next
subscribeNext(gatt)
return
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
gatt.writeDescriptor(descriptor, notifyType)
} else {
@Suppress("DEPRECATION")
descriptor.value = notifyType
@Suppress("DEPRECATION")
gatt.writeDescriptor(descriptor)
}
} catch (e: SecurityException) {
Log.e(TAG, "SecurityException enabling notification", e)
}
}
private fun processCharacteristicData(char: BluetoothGattCharacteristic, data: ByteArray) {
// Battery level
if (char.uuid == BleUuids.BATTERY_LEVEL_CHAR && data.isNotEmpty()) {
val level = data[0].toInt() and 0xFF
mainHandler.post { listener.onBatteryReceived(level) }
return
}
// Debug: log raw bytes received
val hex = data.joinToString(" ") { "%02X".format(it) }
mainHandler.post { listener.onDebugEvent("📡 ${char.uuid}\n HEX [${data.size}B]: $hex") }
// Parse weight data
val reading = ScaleProtocol.parse(char, data) { msg ->
mainHandler.post { listener.onDebugEvent(msg) }
}
if (reading != null && reading.value > 0f) {
mainHandler.post { listener.onWeightReceived(reading) }
} else {
val rawDump = data.mapIndexed { i, b ->
val v = b.toInt() and 0xFF
val h = "%02X".format(v)
"[$i]=$v(0x$h)"
}.joinToString(" ")
mainHandler.post { listener.onDebugEvent("\u26a0\ufe0f Weight not decoded\n RAW: $rawDump") }
}
}
}
@@ -1,249 +0,0 @@
package it.dadaloop.evershelf.scalegate
import android.content.Context
import android.os.Build
import android.util.Log
import org.json.JSONArray
import org.json.JSONObject
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.net.URL
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.Executors
/**
* Centralized error reporter for EverShelf Scale Gateway.
*
* Unlike the Kiosk (which relays errors through the EverShelf PHP backend),
* the Scale Gateway has no knowledge of the EverShelf server URL, so it
* calls the GitHub Issues REST API directly.
*
* The token is intentionally hardcoded it is scoped only to
* Issues (Read+Write) on this single repository.
*
* Usage:
* ErrorReporter.init(applicationContext)
* ErrorReporter.report(exception, "methodName", mapOf("extra" to "info"))
* ErrorReporter.reportMessage("ble-disconnect", "Scale disconnected after 3 retries")
*/
object ErrorReporter {
private const val TAG = "ScaleGWErrorReporter"
// ── XOR-obfuscated GitHub token (scoped: Issues R+W on dadaloop82/EverShelf) ──
// Stored encoded so the literal token string never appears in source or git history.
private const val GH_TOKEN_ENC = "23580718460c2c444031290243627e7971622b29035e2a647726407d194f61440b6e05246a0c067c79730e77114b774501730043433d1866682225511b5443417170444443142941673c4046086c05737363293e7821006e470a466a1d"
private const val GH_TOKEN_KEY = "D1sp3ns4!Ev3r#26"
private const val GH_REPO = "dadaloop82/EverShelf"
private var _ghTokenCache: String? = null
private fun ghToken(): String {
_ghTokenCache?.let { return it }
val enc = GH_TOKEN_ENC.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val key = GH_TOKEN_KEY
val out = String(ByteArray(enc.size) { i -> (enc[i].toInt() xor key[i % key.length].code).toByte() })
_ghTokenCache = out
return out
}
// SharedPreferences key for pending (unsent) crash reports
private const val PREFS_NAME = "evershelf_scalegw_errors"
private const val KEY_PENDING = "pending_crash_json"
private val executor = Executors.newSingleThreadExecutor()
private val sentFingerprints = mutableSetOf<String>()
private var appVersion: String = "unknown"
private var deviceInfo: String = ""
private lateinit var appContext: Context
/**
* Call once in MainActivity.onCreate() or Application.onCreate().
*/
fun init(context: Context) {
appContext = context.applicationContext
deviceInfo = "${Build.MANUFACTURER} ${Build.MODEL} (Android ${Build.VERSION.RELEASE})"
try {
val pi = context.packageManager.getPackageInfo(context.packageName, 0)
appVersion = pi.versionName ?: "unknown"
} catch (_: Exception) {}
// Send any crash report that was saved from the previous session
sendPendingCrash()
// Install global UncaughtExceptionHandler
val previous = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
try {
val crash = buildPayload(
type = "uncaught-exception",
message = "${throwable.javaClass.simpleName}: ${throwable.message}",
stack = throwable.stackTraceToString(),
context = mapOf("thread" to thread.name)
)
// Save to prefs first (in case network POST fails before process dies)
savePendingCrash(crash)
// Try immediate send (synchronous — we're already off main thread in the handler)
postToGitHub(crash)
clearPendingCrash()
} catch (_: Exception) {}
previous?.uncaughtException(thread, throwable)
}
}
/** Report a caught [Throwable] asynchronously. */
fun report(throwable: Throwable, location: String = "", extra: Map<String, Any?> = emptyMap()) {
val ctx = mutableMapOf<String, Any?>("device" to deviceInfo)
if (location.isNotEmpty()) ctx["location"] = location
ctx.putAll(extra)
enqueue(
type = "scale-exception",
message = "${throwable.javaClass.simpleName}: ${throwable.message}",
stack = throwable.stackTraceToString(),
context = ctx
)
}
/** Report a non-exception event (e.g. BLE disconnect, WebSocket error). */
fun reportMessage(type: String, message: String, extra: Map<String, Any?> = emptyMap()) {
val ctx = mutableMapOf<String, Any?>("device" to deviceInfo)
ctx.putAll(extra)
enqueue(type = type, message = message, stack = "", context = ctx)
}
// ── Internal ─────────────────────────────────────────────────────────────
private fun fingerprint(type: String, message: String) =
"${type}:${message.take(120)}".hashCode().toString(16)
private fun enqueue(type: String, message: String, stack: String, context: Map<String, Any?>) {
val fp = fingerprint(type, message)
synchronized(sentFingerprints) {
if (!sentFingerprints.add(fp)) return
}
val payload = buildPayload(type, message, stack, context)
executor.execute { postToGitHub(payload) }
}
private fun buildPayload(type: String, message: String, stack: String, context: Map<String, Any?>): JSONObject {
val ctxJson = JSONObject()
context.forEach { (k, v) -> ctxJson.put(k, v) }
return JSONObject().apply {
put("source", "scale")
put("type", type)
put("message", message)
put("stack", stack)
put("context", ctxJson)
put("version", appVersion)
put("user_agent", "EverShelf-ScaleGateway/$appVersion (Android ${Build.VERSION.RELEASE}; ${Build.MODEL})")
put("ts", SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date()))
}
}
/** Persist crash payload to SharedPreferences so it survives a process kill. */
private fun savePendingCrash(payload: JSONObject) {
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit().putString(KEY_PENDING, payload.toString()).apply()
}
private fun clearPendingCrash() {
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit().remove(KEY_PENDING).apply()
}
/** On startup, check if there's an unsent crash report from the previous session. */
private fun sendPendingCrash() {
val json = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getString(KEY_PENDING, null) ?: return
clearPendingCrash() // remove before sending to prevent re-sending on next crash
executor.execute {
try {
val payload = JSONObject(json)
// Tag it as a "survived-crash" so we know it was saved and retried
payload.put("type", "uncaught-exception-survived")
payload.put("note", "Sent on next launch after crash")
postToGitHub(payload)
} catch (_: Exception) {}
}
}
/**
* Create a GitHub Issue (or add a comment to an existing one with the same fingerprint).
* Uses the GitHub Issues Search API to deduplicate.
*/
private fun postToGitHub(payload: JSONObject) {
val source = payload.optString("source", "scale")
val type = payload.optString("type", "error")
val message = payload.optString("message", "")
val stack = payload.optString("stack", "")
val version = payload.optString("version", "")
val ua = payload.optString("user_agent", "")
val ts = payload.optString("ts", "")
val ctxJson = payload.optJSONObject("context") ?: JSONObject()
val fp = fingerprint(type, message)
// ── 1. Search for existing open issue ──────────────────────────────
val searchQ = "repo:$GH_REPO is:issue is:open label:auto-report \"fp:$fp\" in:body"
val searchUrl = "https://api.github.com/search/issues?q=${java.net.URLEncoder.encode(searchQ, "UTF-8")}&per_page=1"
val searchResult = ghGet(searchUrl) ?: JSONObject()
val existingNumber = searchResult.optJSONArray("items")?.optJSONObject(0)?.optInt("number", 0)?.takeIf { it > 0 }
// ── 2. Build body ─────────────────────────────────────────────────
val ctxMd = if (ctxJson.length() > 0) "\n**Context:**\n```json\n${ctxJson.toString(2)}\n```\n" else ""
val stackMd = if (stack.isNotEmpty()) "\n**Stack trace:**\n```\n$stack\n```\n" else ""
if (existingNumber != null) {
// Comment on existing issue
val body = "### 🔁 Recurrence — $ts\n**Source:** `$source` | **Type:** `$type`\n**UA:** `$ua`\n$ctxMd$stackMd\n---\n_fp:${fp}_"
ghPost("https://api.github.com/repos/$GH_REPO/issues/$existingNumber/comments", JSONObject().put("body", body))
} else {
// Create new issue
val shortMsg = if (message.length > 70) "${message.take(70)}" else message
val title = "[SCALE] $shortMsg"
val body = "## 🚨 Automatic Error Report\n\n**Source:** `$source` \n**Type:** `$type` \n**Reported at:** $ts \n**UA:** `$ua` \n**Version:** `$version`\n\n**Error message:**\n> $message\n$stackMd$ctxMd\n---\n<!-- auto-report fp:$fp -->\n_This issue was created automatically by EverShelf Scale Gateway error reporter. fp:`${fp}`_"
ghPost(
"https://api.github.com/repos/$GH_REPO/issues",
JSONObject()
.put("title", title)
.put("body", body)
.put("labels", JSONArray().put("auto-report").put("scale-error"))
)
}
}
private fun ghGet(url: String): JSONObject? = try {
val conn = URL(url).openConnection() as HttpURLConnection
conn.setRequestProperty("Authorization", "token ${ghToken()}")
conn.setRequestProperty("Accept", "application/vnd.github+json")
conn.setRequestProperty("X-GitHub-Api-Version", "2022-11-28")
conn.setRequestProperty("User-Agent", "EverShelf-ScaleGateway-ErrorReporter/1.0")
conn.connectTimeout = 8000
conn.readTimeout = 8000
val raw = BufferedReader(InputStreamReader(conn.inputStream)).readText()
conn.disconnect()
JSONObject(raw)
} catch (e: Exception) { Log.w(TAG, "ghGet failed: ${e.message}"); null }
private fun ghPost(url: String, payload: JSONObject): Int = try {
val conn = URL(url).openConnection() as HttpURLConnection
conn.requestMethod = "POST"
conn.setRequestProperty("Authorization", "token ${ghToken()}")
conn.setRequestProperty("Accept", "application/vnd.github+json")
conn.setRequestProperty("X-GitHub-Api-Version", "2022-11-28")
conn.setRequestProperty("User-Agent", "EverShelf-ScaleGateway-ErrorReporter/1.0")
conn.setRequestProperty("Content-Type", "application/json; charset=utf-8")
conn.doOutput = true
conn.connectTimeout = 8000
conn.readTimeout = 8000
OutputStreamWriter(conn.outputStream, Charsets.UTF_8).use { it.write(payload.toString()) }
val code = conn.responseCode
conn.disconnect()
Log.d(TAG, "ghPost $url → HTTP $code")
code
} catch (e: Exception) { Log.w(TAG, "ghPost failed: ${e.message}"); -1 }
}
@@ -1,151 +0,0 @@
package it.dadaloop.evershelf.scalegate
import android.util.Log
import org.java_websocket.WebSocket
import org.java_websocket.handshake.ClientHandshake
import org.java_websocket.server.WebSocketServer
import org.json.JSONObject
import java.net.InetSocketAddress
import java.util.Collections
private const val TAG = "GatewayWsServer"
/**
* Callbacks for the WebSocket server, dispatched on the server's internal thread.
* The caller (MainActivity) is responsible for switching to the main thread if needed.
*/
interface ServerEventListener {
fun onClientConnected(address: String)
fun onClientDisconnected(address: String)
fun onClientRequestedWeight()
}
/**
* WebSocket server that exposes smart-scale data to EverShelf running in a browser.
*
* Message protocol (JSON):
*
* Server -> Client:
* {"type":"status","state":"connected"|"disconnected","device":"QN-KS","battery":80}
* {"type":"weight","value":17.0,"unit":"g","stable":true,"timestamp":1712345678000}
* {"type":"pong"}
*
* Client Server:
* {"type":"get_status"} server responds with current status message
* {"type":"get_weight"} server will push the next stable weight reading
* {"type":"ping"} server responds with {"type":"pong"}
*/
class GatewayWebSocketServer(
port: Int,
private val eventListener: ServerEventListener?,
) : WebSocketServer(InetSocketAddress(port)) {
// Thread-safe set of clients waiting for the next stable weight reading
private val pendingWeightRequests: MutableSet<WebSocket> =
Collections.synchronizedSet(mutableSetOf())
// Last known scale state (to send to new clients immediately)
@Volatile private var lastStatusJson: String = buildStatusJson("disconnected", null, null)
@Volatile private var lastWeightJson: String? = null
// ─── Server lifecycle ──────────────────────────────────────────────────────
override fun onStart() {
Log.i(TAG, "WebSocket server started on port ${address.port}")
connectionLostTimeout = 30
}
override fun onOpen(conn: WebSocket, handshake: ClientHandshake) {
val addr = conn.remoteSocketAddress?.toString() ?: "?"
Log.d(TAG, "Client connected: $addr")
// Immediately send current status so the web app knows the scale state
conn.send(lastStatusJson)
lastWeightJson?.let { conn.send(it) }
eventListener?.onClientConnected(addr)
}
override fun onClose(conn: WebSocket, code: Int, reason: String, remote: Boolean) {
val addr = conn.remoteSocketAddress?.toString() ?: "?"
Log.d(TAG, "Client disconnected: $addr (code=$code)")
pendingWeightRequests.remove(conn)
eventListener?.onClientDisconnected(addr)
}
override fun onMessage(conn: WebSocket, message: String) {
try {
val json = JSONObject(message)
when (json.optString("type")) {
"ping" -> conn.send("""{"type":"pong"}""")
"get_status" -> conn.send(lastStatusJson)
"get_weight" -> {
// Add to pending set; next stable weight will be sent to this client
pendingWeightRequests.add(conn)
eventListener?.onClientRequestedWeight()
// If we already have a recent weight, send it immediately
lastWeightJson?.let { conn.send(it) }
}
}
} catch (e: Exception) {
Log.w(TAG, "Malformed message: $message")
}
}
override fun onError(conn: WebSocket?, ex: Exception) {
Log.e(TAG, "WebSocket error on ${conn?.remoteSocketAddress}", ex)
ErrorReporter.report(ex, "GatewayWebSocketServer.onError",
mapOf("remote_addr" to (conn?.remoteSocketAddress?.toString() ?: "null")))
}
// ─── Publishing API ────────────────────────────────────────────────────────
/**
* Broadcast scale connection status to all connected WebSocket clients.
*/
fun publishStatus(state: String, deviceName: String?, battery: Int?) {
lastStatusJson = buildStatusJson(state, deviceName, battery)
broadcast(lastStatusJson)
}
/**
* Broadcast a weight reading to all clients.
* If [stable] is true, also fulfil pending on-demand weight requests.
*/
fun publishWeight(value: Float, unit: String, stable: Boolean, battery: Int? = null) {
val json = buildWeightJson(value, unit, stable)
lastWeightJson = json
broadcast(json)
if (stable) {
synchronized(pendingWeightRequests) {
// Clients that requested on-demand readings are already served by broadcast;
// just clear the pending set.
pendingWeightRequests.clear()
}
}
}
// ─── JSON builders ─────────────────────────────────────────────────────────
private fun buildStatusJson(state: String, device: String?, battery: Int?): String {
val obj = JSONObject()
obj.put("type", "status")
obj.put("state", state)
if (device != null) obj.put("device", device)
if (battery != null) obj.put("battery", battery)
return obj.toString()
}
private fun buildWeightJson(value: Float, unit: String, stable: Boolean): String {
val obj = JSONObject()
obj.put("type", "weight")
// Round to 1 decimal to avoid floating point noise (e.g. 17.000001)
val rounded = Math.round(value * 10f) / 10.0
obj.put("value", rounded)
obj.put("unit", unit)
obj.put("stable", stable)
obj.put("timestamp", System.currentTimeMillis())
return obj.toString()
}
}
@@ -1,674 +0,0 @@
package it.dadaloop.evershelf.scalegate
import android.Manifest
import android.app.DownloadManager
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.app.PendingIntent
import android.content.pm.PackageInstaller
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.button.MaterialButton
import it.dadaloop.evershelf.scalegate.databinding.ActivityMainBinding
import java.net.Inet4Address
import java.net.NetworkInterface
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import org.json.JSONObject
private const val WS_PORT = 8765
class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener {
private lateinit var binding: ActivityMainBinding
private lateinit var bleManager: BleScaleManager
private var wsServer: GatewayWebSocketServer? = null
private val devices = mutableListOf<BleDeviceInfo>()
private lateinit var deviceAdapter: DeviceAdapter
private var batteryLevel: Int? = null
private val debugLines = mutableListOf<String>()
private var debugVisible = false
private var lastDebugUpdate = 0L
private val debugTimeFmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
private var isAutoReconnecting = false
// Update banner
private var pendingApkDownloadUrl = ""
private var pendingInstallFile: java.io.File? = null
private companion object {
const val MAX_DEBUG_LINES = 150
const val DEBUG_THROTTLE_MS = 200L
const val GITHUB_RELEASES_API = "https://api.github.com/repos/dadaloop82/EverShelf/releases/latest"
const val APK_DOWNLOAD_URL = "https://github.com/dadaloop82/EverShelf/releases/latest/download/evershelf-scale-gateway.apk"
}
// ─── Permission launcher ───────────────────────────────────────────────────
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { granted ->
if (granted.values.all { it }) {
startGatewayServer()
} else {
showDialog("Missing permissions",
"The app requires Bluetooth and Location permissions to function.")
}
}
private val enableBtLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == RESULT_OK) checkPermissionsAndStart()
else showDialog("Bluetooth required", "Please enable Bluetooth to use the gateway.")
}
/** Returns from ACTION_MANAGE_UNKNOWN_APP_SOURCES — retry the download. */
private val installPermLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { _ ->
val url = pendingApkDownloadUrl
if (url.isNotEmpty()) triggerApkDownload(url)
}
/** Returns from system installer dialog — if not OK the install failed (signature conflict?). */
private val installConfirmLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode != RESULT_OK) {
val f = pendingInstallFile
if (f != null && f.exists()) {
runOnUiThread {
AlertDialog.Builder(this)
.setTitle("⚠️ Installazione non riuscita")
.setMessage("Se hai visto un errore di conflitto firma, devi disinstallare la versione precedente.\n\nDisinstalla ora? L'installazione ripartirà automaticamente.")
.setPositiveButton("Disinstalla") { _, _ ->
uninstallLauncher.launch(
Intent(Intent.ACTION_DELETE, android.net.Uri.parse("package:$packageName"))
)
}
.setNegativeButton("Annulla", null)
.show()
}
}
}
}
/** Returns from uninstall screen — auto-retry the install with the saved APK file. */
private val uninstallLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { _ ->
val f = pendingInstallFile
if (f != null && f.exists()) installApk(f)
}
// ─── Lifecycle ─────────────────────────────────────────────────────────────
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
bleManager = BleScaleManager(this, this)
// Initialise error reporter early so the UncaughtExceptionHandler is installed
// and any pending crash from a previous session is sent
ErrorReporter.init(this)
deviceAdapter = DeviceAdapter(devices) { info ->
bleManager.connect(info.device)
}
binding.rvDevices.apply {
layoutManager = LinearLayoutManager(this@MainActivity)
adapter = deviceAdapter
}
binding.btnScan.setOnClickListener { startScanIfPermitted() }
binding.btnDisconnect.setOnClickListener {
bleManager.disconnect()
updateUiDisconnected()
}
binding.btnDebug.setOnClickListener {
debugVisible = !debugVisible
binding.svDebugLog.visibility = if (debugVisible) View.VISIBLE else View.GONE
binding.btnCopyLog.visibility = if (debugVisible) View.VISIBLE else View.GONE
binding.btnShareLog.visibility = if (debugVisible) View.VISIBLE else View.GONE
binding.btnDebug.text = if (debugVisible) "\uD83D\uDC1B Hide Debug" else "\uD83D\uDC1B Debug"
}
binding.btnCopyLog.setOnClickListener {
val log = debugLines.joinToString("\n")
val cm = getSystemService(CLIPBOARD_SERVICE) as android.content.ClipboardManager
cm.setPrimaryClip(android.content.ClipData.newPlainText("EverShelf Scale Log", log))
Toast.makeText(this, "Log copied to clipboard", Toast.LENGTH_SHORT).show()
}
binding.btnShareLog.setOnClickListener {
val log = debugLines.joinToString("\n")
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_SUBJECT, "EverShelf Scale Gateway - Debug Log")
putExtra(Intent.EXTRA_TEXT, log)
}
startActivity(Intent.createChooser(intent, "Share log"))
}
// Show app version
try {
val pInfo = packageManager.getPackageInfo(packageName, 0)
binding.tvVersion.text = "v${pInfo.versionName} (${pInfo.longVersionCode})"
} catch (_: Exception) { }
updateGatewayUrl()
checkPermissionsAndStart()
// Wire update banner buttons
binding.btnDismissUpdate.setOnClickListener { binding.updateBanner.visibility = View.GONE }
binding.btnInstallUpdate.setOnClickListener { triggerApkDownload(pendingApkDownloadUrl) }
// Check for a newer release (background thread, at most once every 6 h)
checkForUpdates()
// Auto-connect: if we have a saved device, start scanning with auto-connect enabled
if (bleManager.getSavedDeviceAddress() != null) {
binding.tvScanHint.visibility = View.VISIBLE
binding.tvScanHint.text = "\uD83D\uDD04 Reconnecting to saved scale\u2026"
}
}
override fun onDestroy() {
super.onDestroy()
bleManager.disconnect()
wsServer?.stop(1000)
}
// ─── Permissions & startup ─────────────────────────────────────────────────
private fun checkPermissionsAndStart() {
val required = buildList {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
add(Manifest.permission.BLUETOOTH_SCAN)
add(Manifest.permission.BLUETOOTH_CONNECT)
} else {
add(Manifest.permission.ACCESS_FINE_LOCATION)
}
}
val missing = required.filter {
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
}
when {
missing.isNotEmpty() -> permissionLauncher.launch(missing.toTypedArray())
!isBluetoothEnabled() -> enableBtLauncher.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE))
else -> startGatewayServer()
}
}
private fun isBluetoothEnabled(): Boolean {
val adapter = android.bluetooth.BluetoothManager::class.java.let {
getSystemService(it)
} as? android.bluetooth.BluetoothManager
return adapter?.adapter?.isEnabled == true
}
private fun startScanIfPermitted() {
if (!bleManager.hasRequiredPermissions()) {
checkPermissionsAndStart()
return
}
devices.clear()
deviceAdapter.notifyDataSetChanged()
debugLines.clear()
binding.tvDebugLog.text = ""
binding.tvScanHint.visibility = View.VISIBLE
binding.tvScanHint.text = "Scanning for BLE scales\u2026"
binding.btnScan.isEnabled = false
bleManager.enableAutoConnect()
isAutoReconnecting = false // manual scan — stop any pending auto-reconnect cycle
bleManager.startScan()
}
// ─── WebSocket gateway ─────────────────────────────────────────────────────
private fun startGatewayServer() {
if (wsServer != null) return
try {
wsServer = GatewayWebSocketServer(WS_PORT, this)
wsServer!!.start()
updateGatewayUrl()
binding.tvGatewayStatus.text = "\u2705 Gateway active on port $WS_PORT"
} catch (e: Exception) {
binding.tvGatewayStatus.text = "\u274C Failed to start gateway: ${e.message}"
ErrorReporter.report(e, "startGatewayServer", mapOf("port" to WS_PORT))
}
// Auto-scan if there's a saved device
if (bleManager.getSavedDeviceAddress() != null && bleManager.hasRequiredPermissions()) {
bleManager.enableAutoConnect()
bleManager.startScan()
}
}
private fun updateGatewayUrl() {
val ip = getLocalIpAddress() ?: ""
val url = "ws://$ip:$WS_PORT"
binding.tvGatewayUrl.text = url
binding.tvGatewayUrlHint.text = "Paste this URL in EverShelf \u2192 Settings \u2192 Smart Scale"
binding.btnCopyUrl.setOnClickListener {
val cm = getSystemService(CLIPBOARD_SERVICE) as android.content.ClipboardManager
cm.setPrimaryClip(android.content.ClipData.newPlainText("EverShelf Gateway URL", url))
binding.btnCopyUrl.text = "\u2705 Copied!"
binding.btnCopyUrl.postDelayed({ binding.btnCopyUrl.text = "\uD83D\uDCCB Copy URL" }, 2000)
}
}
// ─── BleScaleListener ─────────────────────────────────────────────────────
override fun onDeviceFound(info: BleDeviceInfo) {
if (devices.none { it.device.address == info.device.address }) {
// Insert keeping descending scaleScore order (scale-likely devices first)
val insertAt = devices.indexOfFirst { it.scaleScore < info.scaleScore }
.let { if (it < 0) devices.size else it }
devices.add(insertAt, info)
deviceAdapter.notifyItemInserted(insertAt)
}
}
override fun onConnecting(device: BluetoothDevice) {
val name = try { device.name ?: device.address } catch (e: SecurityException) { device.address }
binding.tvScaleStatus.text = "\u23f3 Connecting to $name\u2026"
binding.tvWeight.text = "— — —"
binding.cardConnection.setCardBackgroundColor(getColor(android.R.color.holo_orange_light))
}
override fun onConnected(deviceName: String) {
isAutoReconnecting = false
binding.tvScaleStatus.text = "\u2705 Connected: $deviceName"
binding.tvWeight.text = "Waiting for weight\u2026"
binding.cardConnection.setCardBackgroundColor(getColor(android.R.color.holo_green_light))
binding.btnDisconnect.visibility = View.VISIBLE
binding.rvDevices.visibility = View.GONE
binding.btnScan.visibility = View.GONE
binding.tvScanHint.visibility = View.GONE
wsServer?.publishStatus("connected", deviceName, batteryLevel)
}
override fun onDisconnected() {
wsServer?.publishStatus("disconnected", null, null)
updateUiDisconnected()
// Auto-reconnect: if a saved device exists, restart scan after a short delay.
// This handles the scale turning off by itself (auto-off) — when it powers
// back on it will start advertising again and we will pick it up.
if (bleManager.getSavedDeviceAddress() != null && bleManager.hasRequiredPermissions()) {
isAutoReconnecting = true
binding.tvScanHint.visibility = View.VISIBLE
binding.tvScanHint.text = "\uD83D\uDD04 Reconnecting to saved scale in 5 s\u2026"
binding.root.postDelayed({
if (!bleManager.isConnected && isAutoReconnecting) {
bleManager.enableAutoConnect()
bleManager.startScan()
}
}, 5_000L)
}
}
override fun onWeightReceived(reading: WeightReading) {
val displayValue = if (reading.value % 1f == 0f) reading.value.toInt().toString()
else "%.1f".format(reading.value)
binding.tvWeight.text = "$displayValue ${reading.unit}"
if (reading.stable) {
binding.tvWeightHint.text = "\u2713 Stable reading"
} else {
binding.tvWeightHint.text = "\u23f3 Measuring\u2026"
}
wsServer?.publishWeight(reading.value, reading.unit, reading.stable, batteryLevel)
}
override fun onBatteryReceived(level: Int) {
batteryLevel = level
binding.tvBattery.text = "🔋 $level%"
binding.tvBattery.visibility = View.VISIBLE
wsServer?.publishStatus("connected", binding.tvScaleStatus.text.toString()
.removePrefix("\u2705 Connected: "), level)
}
override fun onError(message: String) {
binding.tvScaleStatus.text = "$message"
binding.cardConnection.setCardBackgroundColor(getColor(android.R.color.holo_red_light))
ErrorReporter.reportMessage(
type = "ble-error",
message = message,
extra = mapOf("connected_device" to (bleManager.getSavedDeviceAddress() ?: "none"))
)
}
override fun onScanStopped() {
binding.btnScan.isEnabled = true
if (isAutoReconnecting && !bleManager.isConnected && bleManager.getSavedDeviceAddress() != null) {
// Scale not found yet — retry scan after 10 s indefinitely until reconnected
binding.tvScanHint.visibility = View.VISIBLE
binding.tvScanHint.text = "\uD83D\uDD04 Bilancia non trovata, riprovo tra 10 s\u2026"
binding.root.postDelayed({
if (!bleManager.isConnected && isAutoReconnecting) {
binding.tvScanHint.text = "\uD83D\uDD04 Cerco la bilancia\u2026"
bleManager.enableAutoConnect()
bleManager.startScan()
}
}, 10_000L)
} else if (devices.isEmpty()) {
binding.tvScanHint.text = "No scale found. Make sure it's on, then scan again."
} else {
binding.tvScanHint.text = "Tap a scale to connect."
}
}
override fun onDebugEvent(message: String) {
runOnUiThread {
val ts = debugTimeFmt.format(Date())
debugLines.add("[$ts] $message")
// Keep only last MAX_DEBUG_LINES
while (debugLines.size > MAX_DEBUG_LINES) debugLines.removeAt(0)
// Throttle UI updates to avoid freezing
val now = System.currentTimeMillis()
if (now - lastDebugUpdate >= DEBUG_THROTTLE_MS) {
lastDebugUpdate = now
binding.tvDebugLog.text = debugLines.joinToString("\n")
if (debugVisible) {
binding.svDebugLog.post { binding.svDebugLog.fullScroll(View.FOCUS_DOWN) }
}
}
}
}
// ─── ServerEventListener ──────────────────────────────────────────────────
override fun onClientConnected(address: String) {
runOnUiThread {
binding.tvClientCount.text = "\uD83C\uDF10 Client connected: $address"
binding.tvClientCount.visibility = View.VISIBLE
}
}
override fun onClientDisconnected(address: String) {
runOnUiThread {
binding.tvClientCount.visibility = View.GONE
}
}
override fun onClientRequestedWeight() { /* Nothing extra needed */ }
// ─── UI helpers ───────────────────────────────────────────────────────────
private fun updateUiDisconnected() {
binding.tvScaleStatus.text = "\u26a1 Ready \u2014 scan for a scale"
binding.tvWeight.text = "— — —"
binding.tvWeightHint.text = ""
binding.tvBattery.visibility = View.GONE
binding.cardConnection.setCardBackgroundColor(getColor(android.R.color.darker_gray))
binding.btnDisconnect.visibility = View.GONE
binding.rvDevices.visibility = View.VISIBLE
binding.btnScan.visibility = View.VISIBLE
}
private fun getLocalIpAddress(): String? {
return try {
NetworkInterface.getNetworkInterfaces().toList()
.flatMap { it.inetAddresses.toList() }
.filterIsInstance<Inet4Address>()
.firstOrNull { !it.isLoopbackAddress }
?.hostAddress
} catch (e: Exception) { null }
}
private fun showDialog(title: String, message: String) {
AlertDialog.Builder(this)
.setTitle(title)
.setMessage(message)
.setPositiveButton("OK", null)
.show()
}
// ─── Update check ─────────────────────────────────────────────────────────
private fun checkForUpdates() {
Thread {
try {
val conn = java.net.URL(GITHUB_RELEASES_API).openConnection() as java.net.HttpURLConnection
conn.setRequestProperty("Accept", "application/vnd.github+json")
conn.connectTimeout = 5000
conn.readTimeout = 5000
val body = conn.inputStream.bufferedReader().readText()
conn.disconnect()
val json = JSONObject(body)
val latestTag = json.optString("tag_name", "").ifEmpty { return@Thread }
val current = try { packageManager.getPackageInfo(packageName, 0).versionName ?: "" } catch (_: Exception) { "" }
val norm = { v: String -> v.trimStart('v') }
val isSemver = latestTag.trimStart('v').matches(Regex("\\d+\\.\\d+.*"))
// Find scale-gateway APK in release assets
var apkUrl = ""
val assets = json.optJSONArray("assets")
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("gateway") || name.contains("scale")) && url.isNotEmpty()) {
apkUrl = url; break
}
}
}
// Only show banner if the release actually contains our APK
if (apkUrl.isEmpty()) return@Thread
// Proper semver comparison: only update if remote is strictly newer
fun semverNewer(remote: String, local: String): Boolean {
val r = remote.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 }
val l = local.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 }
val len = maxOf(r.size, l.size)
for (i in 0 until len) {
val rv = r.getOrElse(i) { 0 }
val lv = l.getOrElse(i) { 0 }
if (rv != lv) return rv > lv
}
return false
}
if (current.isEmpty()) return@Thread
if (isSemver && !semverNewer(norm(latestTag), norm(current))) return@Thread
val label = if (isSemver) "$current$latestTag" else latestTag
val msg = "⬆️ Scale Gateway $label"
runOnUiThread { showNativeUpdateBanner(msg, apkUrl) }
} catch (_: Exception) {}
}.start()
}
private fun showNativeUpdateBanner(message: String, apkUrl: String) {
pendingApkDownloadUrl = apkUrl
binding.tvUpdateMessage.text = message
binding.updateBanner.visibility = View.VISIBLE
binding.updateBanner.postDelayed({ binding.updateBanner.visibility = View.GONE }, 30_000)
}
private fun triggerApkDownload(apkUrl: String) {
if (apkUrl.isEmpty()) return
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
!packageManager.canRequestPackageInstalls()) {
pendingApkDownloadUrl = apkUrl // remember for retry
installPermLauncher.launch(
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:$packageName"))
)
Toast.makeText(this, "Abilita 'Installa app sconosciute', poi torna qui", Toast.LENGTH_LONG).show()
return
}
// Download to app-private external dir — no storage permission needed
val destDir = getExternalFilesDir(null) ?: filesDir
val destFile = java.io.File(destDir, "evershelf-scale-update.apk")
pendingInstallFile = destFile
val dm = getSystemService(DOWNLOAD_SERVICE) as DownloadManager
val req = DownloadManager.Request(Uri.parse(apkUrl)).apply {
setTitle("EverShelf Scale Gateway — Aggiornamento")
setDescription("Scaricamento aggiornamento…")
setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
setDestinationUri(Uri.fromFile(destFile))
setMimeType("application/vnd.android.package-archive")
}
val downloadId = dm.enqueue(req)
Toast.makeText(this, "Download avviato…", Toast.LENGTH_SHORT).show()
val receiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context?, intent: Intent?) {
val id = intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1)
if (id != downloadId) return
unregisterReceiver(this)
val q = DownloadManager.Query().setFilterById(downloadId)
val c = (getSystemService(DOWNLOAD_SERVICE) as DownloadManager).query(q)
var ok = false
if (c.moveToFirst()) {
val status = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
ok = (status == DownloadManager.STATUS_SUCCESSFUL)
}
c.close()
if (ok) installApk(destFile)
else runOnUiThread {
Toast.makeText(this@MainActivity, "Download fallito, riprova", Toast.LENGTH_LONG).show()
}
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// RECEIVER_EXPORTED required: ACTION_DOWNLOAD_COMPLETE is sent by the system DownloadManager
// (an external process), so NOT_EXPORTED would silently block the broadcast on API 33+.
registerReceiver(receiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE), RECEIVER_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
registerReceiver(receiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE))
}
} catch (e: Exception) {
Toast.makeText(this, "Errore download: ${e.message}", Toast.LENGTH_LONG).show()
}
}
private fun installApk(file: java.io.File) {
if (!file.exists() || file.length() == 0L) {
runOnUiThread { Toast.makeText(this, "File APK non trovato", Toast.LENGTH_LONG).show() }
return
}
try {
val pi = packageManager.packageInstaller
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
params.setAppPackageName(packageName)
val sessionId = pi.createSession(params)
pi.openSession(sessionId).use { session ->
file.inputStream().use { input ->
session.openWrite("package", 0, file.length()).use { out ->
input.copyTo(out)
session.fsync(out)
}
}
val action = "it.dadaloop.evershelf.scalegate.INSTALL_RESULT_$sessionId"
val resultReceiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context?, intent: Intent?) {
unregisterReceiver(this)
val status = intent?.getIntExtra(
PackageInstaller.EXTRA_STATUS,
PackageInstaller.STATUS_FAILURE
) ?: PackageInstaller.STATUS_FAILURE
when (status) {
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
// Use launcher so we get notified if system installer fails
@Suppress("DEPRECATION")
val confirmIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
intent?.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
else intent?.getParcelableExtra(Intent.EXTRA_INTENT)
if (confirmIntent != null) installConfirmLauncher.launch(confirmIntent)
}
PackageInstaller.STATUS_SUCCESS ->
runOnUiThread { Toast.makeText(this@MainActivity, "✅ Aggiornamento installato", Toast.LENGTH_SHORT).show() }
PackageInstaller.STATUS_FAILURE_INCOMPATIBLE,
PackageInstaller.STATUS_FAILURE_CONFLICT -> {
runOnUiThread {
AlertDialog.Builder(this@MainActivity)
.setTitle("⚠️ Conflitto firma APK")
.setMessage("L'app installata usa una firma diversa.\n\nDisinstalla la versione precedente: al termine l'installazione riparte automaticamente.")
.setPositiveButton("Disinstalla") { _, _ ->
uninstallLauncher.launch(
Intent(Intent.ACTION_DELETE, android.net.Uri.parse("package:$packageName"))
)
}
.setNegativeButton("Annulla", null)
.show()
}
}
else -> {
val msg = intent?.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
?: "status=$status"
runOnUiThread { Toast.makeText(this@MainActivity, "Installazione: $msg", Toast.LENGTH_LONG).show() }
}
}
}
}
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
RECEIVER_NOT_EXPORTED else 0
registerReceiver(resultReceiver, IntentFilter(action), flags)
val pi2 = PendingIntent.getBroadcast(
this, sessionId,
Intent(action).setPackage(packageName),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
session.commit(pi2.intentSender)
}
Toast.makeText(this, "Installazione in corso…", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
runOnUiThread { Toast.makeText(this, "Errore installazione: ${e.message}", Toast.LENGTH_LONG).show() }
}
}
// ─── RecyclerView adapter ──────────────────────────────────────────────────
inner class DeviceAdapter(
private val items: List<BleDeviceInfo>,
private val onClick: (BleDeviceInfo) -> Unit,
) : RecyclerView.Adapter<DeviceAdapter.VH>() {
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
val tvName: TextView = view.findViewById(R.id.tv_device_name)
val tvAddr: TextView = view.findViewById(R.id.tv_device_addr)
val tvRssi: TextView = view.findViewById(R.id.tv_device_rssi)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_device, parent, false)
return VH(view)
}
override fun onBindViewHolder(holder: VH, position: Int) {
val info = items[position]
holder.tvName.text = info.name
holder.tvAddr.text = info.device.address
holder.tvRssi.text = info.proximity
holder.itemView.setOnClickListener { onClick(info) }
}
override fun getItemCount() = items.size
}
}
@@ -1,208 +0,0 @@
package it.dadaloop.evershelf.scalegate
import android.bluetooth.BluetoothGattCharacteristic
import java.util.UUID
// --- Data model ---
/**
* A single weight reading from a BLE scale.
* [value] is in the scale's current display unit (grams, oz, ml, lb).
* [unit] is "g", "oz", "ml", or "lb".
*/
data class WeightReading(
val value: Float,
val unit: String,
val stable: Boolean,
)
// --- UUIDs ---
val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
object BleUuids {
// BLE SIG Weight Scale (some kitchen scales use this)
val WEIGHT_SCALE_SERVICE = UUID.fromString("0000181d-0000-1000-8000-00805f9b34fb")
val WEIGHT_MEASUREMENT_CHAR = UUID.fromString("00002a9d-0000-1000-8000-00805f9b34fb")
// Battery
val BATTERY_SERVICE = UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb")
val BATTERY_LEVEL_CHAR = UUID.fromString("00002a19-0000-1000-8000-00805f9b34fb")
// Common vendor services used by kitchen scales
val FFE0 = UUID.fromString("0000ffe0-0000-1000-8000-00805f9b34fb")
val FFE1 = UUID.fromString("0000ffe1-0000-1000-8000-00805f9b34fb")
val FFF0 = UUID.fromString("0000fff0-0000-1000-8000-00805f9b34fb")
val FFF1 = UUID.fromString("0000fff1-0000-1000-8000-00805f9b34fb")
val FFF4 = UUID.fromString("0000fff4-0000-1000-8000-00805f9b34fb")
// Acaia / Brewista coffee scales
val ACAIA_SERVICE = UUID.fromString("49535343-fe7d-4ae5-8fa9-9fafd205e455")
val ACAIA_CHAR = UUID.fromString("49535343-8841-43f4-a8d4-ecbe34729bb3")
// QN/Yolanda food scale secondary service (QN-KS, etc.)
val QN_AE00 = UUID.fromString("0000ae00-0000-1000-8000-00805f9b34fb")
val QN_AE02 = UUID.fromString("0000ae02-0000-1000-8000-00805f9b34fb")
}
// --- Food scale protocol parser ---
object ScaleProtocol {
// Plausible kitchen scale range
private const val MAX_GRAMS = 15000f
private const val MIN_GRAMS = 0.5f // allow tare/small values
fun resetState() { /* reserved for future use */ }
fun parse(
char: BluetoothGattCharacteristic,
data: ByteArray,
debug: ((String) -> Unit)? = null,
): WeightReading? {
if (data.size < 2) {
debug?.invoke("skip: packet too short (" + data.size + "B)")
return null
}
// UUID-specific parsers
when (char.uuid) {
BleUuids.WEIGHT_MEASUREMENT_CHAR -> return parseSigWeight(data, debug)
}
// QN/Yolanda food scale (QN-KS, BC-KS, etc.):
// 18-byte frame starting with 0x10 0x12 on FFF1
if (data.size == 18
&& (data[0].toInt() and 0xFF) == 0x10
&& (data[1].toInt() and 0xFF) == 0x12) {
return parseQNFood(data, debug)
}
return parseGeneric(data, debug)
}
// -------------------------------------------------------------------------
// BLE SIG 0x2A9D Weight Measurement
// -------------------------------------------------------------------------
private fun parseSigWeight(data: ByteArray, debug: ((String) -> Unit)? = null): WeightReading? {
if (data.size < 3) return null
val flags = data[0].toInt() and 0xFF
val isImperial = (flags and 0x01) != 0
val raw = u16le(data, 1)
return if (isImperial) {
val lb = raw * 0.01f
debug?.invoke("SIG 2A9D: raw=$raw -> ${lb}lb")
if (lb < 0.01f || lb > 33f) null
else WeightReading(lb, "lb", stable = true)
} else {
val g = raw * 5f // 0.005 kg resolution = 5 g/unit
debug?.invoke("SIG 2A9D: raw=$raw -> ${g}g")
if (g < MIN_GRAMS || g > MAX_GRAMS) null
else WeightReading(g, "g", stable = true)
}
}
// -------------------------------------------------------------------------
// QN / Yolanda food scale (QN-KS, BC-KS, YolandaKS, ...)
//
// 18-byte notification on service 0xFFF0, char 0xFFF1:
// [0x10][0x12][00][??][unit][02][05][01][flags][w_hi][w_lo][7E][1F][02][58][02][01][crc]
// index: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
//
// weight = u16BE(data, 9) / 10.0 (0.1-unit resolution)
// unit = byte[4]: 0x01=g, 0x02=oz, 0x03=ml(water), 0x04=ml(milk)
// stable = bit3 of byte[8] != 0 (0xF8=stable, 0xF0=settling)
// crc = sum(bytes[0..16]) mod 256
// -------------------------------------------------------------------------
private fun parseQNFood(data: ByteArray, debug: ((String) -> Unit)? = null): WeightReading? {
// Verify checksum
val calc = data.take(17).sumOf { it.toInt() and 0xFF } and 0xFF
if (calc != (data[17].toInt() and 0xFF)) {
debug?.invoke("QN-KS: CRC mismatch (calc=0x%02X got=0x%02X)".format(calc, data[17].toInt() and 0xFF))
return null
}
val rawValue = u16be(data, 9)
val stable = (data[8].toInt() and 0x08) != 0
val unit = when (data[4].toInt() and 0xFF) {
0x01 -> "g"
0x02 -> "oz"
0x03 -> "ml" // water mode
0x04 -> "ml" // milk mode
else -> "g"
}
// Resolution is 0.1 unit (e.g. 170 raw = 17.0 g, 195 raw = 19.5 g)
val value = rawValue / 10f
debug?.invoke("QN-KS: ${value}${unit} stable=$stable (raw=$rawValue unit_byte=0x%02X)".format(data[4].toInt() and 0xFF))
if (rawValue == 0) return null
// Convert to grams for range check
val valueG = if (unit == "oz") value * 28.3495f else value
if (valueG < MIN_GRAMS || valueG > MAX_GRAMS) return null
return WeightReading(value, unit, stable)
}
// -------------------------------------------------------------------------
// Generic fallback parser
// Tries common frame layouts used by many BLE kitchen scales.
// -------------------------------------------------------------------------
private fun parseGeneric(data: ByteArray, debug: ((String) -> Unit)? = null): WeightReading? {
if (data.size < 3) {
debug?.invoke("generic: skip short packet (" + data.size + "B)")
return null
}
data class C(val pos: Int, val be: Boolean, val div: Float, val label: String)
val candidates = listOf(
// Direct grams (1g resolution)
C(1, false, 1f, "pos1 LE g"),
C(1, true, 1f, "pos1 BE g"),
C(2, false, 1f, "pos2 LE g"),
C(2, true, 1f, "pos2 BE g"),
C(3, false, 1f, "pos3 LE g"),
C(3, true, 1f, "pos3 BE g"),
// 0.1g resolution (high-precision scales)
C(1, false, 10f, "pos1 LE 0.1g"),
C(1, true, 10f, "pos1 BE 0.1g"),
C(2, false, 10f, "pos2 LE 0.1g"),
C(2, true, 10f, "pos2 BE 0.1g"),
C(3, false, 10f, "pos3 LE 0.1g"),
C(3, true, 10f, "pos3 BE 0.1g"),
// 0.5g resolution
C(1, false, 2f, "pos1 LE 0.5g"),
C(1, true, 2f, "pos1 BE 0.5g"),
// Raw = centgrams (raw*10 = g)
C(1, false, 0.1f, "pos1 LE cg"),
C(1, true, 0.1f, "pos1 BE cg"),
C(3, false, 0.1f, "pos3 LE cg"),
C(3, true, 0.1f, "pos3 BE cg"),
)
for (c in candidates) {
if (c.pos + 1 >= data.size) continue
val raw = if (c.be) u16be(data, c.pos) else u16le(data, c.pos)
if (raw == 0) continue
val g = raw / c.div
if (g in MIN_GRAMS..MAX_GRAMS) {
debug?.invoke("generic [${c.label}]: raw=$raw -> ${g}g (unstable)")
return WeightReading(g, "g", stable = false)
}
}
debug?.invoke("generic: no valid candidate in " + data.size + " bytes")
return null
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private fun u16le(b: ByteArray, off: Int): Int =
(b[off].toInt() and 0xFF) or ((b[off + 1].toInt() and 0xFF) shl 8)
private fun u16be(b: ByteArray, off: Int): Int =
((b[off].toInt() and 0xFF) shl 8) or (b[off + 1].toInt() and 0xFF)
}
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#6C63FF"
android:pathData="M0,0h108v108H0z" />
</vector>
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FFFFFF"
android:pathData="M54,30L54,70 M40,38L68,38 M36,54L44,38 M64,54L72,38 M36,54C36,56 44,56 44,54 M64,54C64,56 72,56 72,54" />
</vector>
@@ -1,340 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="#F3F4F6">
<!-- ── Update banner (shown at the TOP when a new version is available) ─ -->
<LinearLayout
android:id="@+id/updateBanner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:background="#1e293b"
android:paddingStart="16dp"
android:paddingEnd="8dp"
android:paddingTop="10dp"
android:paddingBottom="10dp"
android:gravity="center_vertical"
android:visibility="gone">
<TextView
android:id="@+id/tvUpdateMessage"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textColor="#fbbf24"
android:textSize="13sp"
android:text="" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnInstallUpdate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="⬇ Scarica"
android:textSize="12sp"
android:textColor="#1e293b"
android:backgroundTint="#fbbf24"
style="@style/Widget.MaterialComponents.Button" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnDismissUpdate"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_marginStart="4dp"
android:text="✕"
android:textSize="14sp"
android:textColor="#94a3b8"
android:backgroundTint="@android:color/transparent"
style="@style/Widget.MaterialComponents.Button.TextButton" />
</LinearLayout>
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<!-- ── Header ─────────────────────────────────────────────────────── -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="⚖️ EverShelf Scale Gateway"
android:textSize="22sp"
android:textStyle="bold"
android:textColor="#1E293B"
android:paddingBottom="4dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingBottom="16dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Connect your smart scale to EverShelf via Bluetooth"
android:textSize="13sp"
android:textColor="#64748B" />
<TextView
android:id="@+id/tv_version"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="v?.?.?"
android:textSize="11sp"
android:textColor="#94A3B8"
android:fontFamily="monospace"
android:gravity="end" />
</LinearLayout>
<!-- ── Gateway URL card ───────────────────────────────────────────── -->
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
app:cardCornerRadius="12dp"
app:cardElevation="2dp"
app:cardBackgroundColor="#EFF6FF">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="🌐 Gateway URL (paste into EverShelf)"
android:textSize="12sp"
android:textColor="#64748B"
android:paddingBottom="4dp" />
<TextView
android:id="@+id/tv_gateway_url"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="ws://…:8765"
android:textSize="18sp"
android:textStyle="bold"
android:textColor="#1D4ED8"
android:fontFamily="monospace"
android:paddingBottom="4dp" />
<TextView
android:id="@+id/tv_gateway_url_hint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Settings → Smart Scale"
android:textSize="11sp"
android:textColor="#94A3B8"
android:paddingBottom="8dp" />
<Button
android:id="@+id/btn_copy_url"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="📋 Copy URL"
android:backgroundTint="#1D4ED8"
android:textColor="#FFFFFF"
style="@style/Widget.MaterialComponents.Button" />
</LinearLayout>
</androidx.cardview.widget.CardView>
<!-- ── Gateway status ────────────────────────────────────────────── -->
<TextView
android:id="@+id/tv_gateway_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="⏳ Starting gateway…"
android:textSize="13sp"
android:textColor="#64748B"
android:paddingBottom="4dp" />
<TextView
android:id="@+id/tv_client_count"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text=""
android:textSize="12sp"
android:textColor="#059669"
android:paddingBottom="12dp"
android:visibility="gone" />
<!-- ── Scale connection card ──────────────────────────────────────── -->
<androidx.cardview.widget.CardView
android:id="@+id/card_connection"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
app:cardCornerRadius="12dp"
app:cardElevation="2dp"
app:cardBackgroundColor="@android:color/darker_gray">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/tv_scale_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="⚡ Ready — scan for a scale"
android:textSize="15sp"
android:textStyle="bold"
android:textColor="#FFFFFF"
android:paddingBottom="8dp" />
<TextView
android:id="@+id/tv_weight"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="— — —"
android:textSize="46sp"
android:textStyle="bold"
android:textColor="#FFFFFF"
android:gravity="center"
android:paddingTop="8dp"
android:paddingBottom="4dp" />
<TextView
android:id="@+id/tv_weight_hint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text=""
android:textSize="12sp"
android:textColor="#E2E8F0"
android:gravity="center"
android:paddingBottom="8dp" />
<TextView
android:id="@+id/tv_battery"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text=""
android:textSize="12sp"
android:textColor="#E2E8F0"
android:visibility="gone" />
<Button
android:id="@+id/btn_disconnect"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="🔌 Disconnect scale"
android:backgroundTint="#EF4444"
android:textColor="#FFFFFF"
android:visibility="gone"
android:layout_marginTop="8dp"
style="@style/Widget.MaterialComponents.Button" />
</LinearLayout>
</androidx.cardview.widget.CardView>
<!-- ── Scan controls ──────────────────────────────────────────────── -->
<Button
android:id="@+id/btn_scan"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="🔍 Scan for Bluetooth Scales"
android:backgroundTint="#7C3AED"
android:textColor="#FFFFFF"
android:layout_marginBottom="8dp"
style="@style/Widget.MaterialComponents.Button" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<Button
android:id="@+id/btn_debug"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="\uD83D\uDC1B Debug"
android:backgroundTint="#374151"
android:textColor="#FFFFFF"
android:layout_marginEnd="4dp"
style="@style/Widget.MaterialComponents.Button" />
<Button
android:id="@+id/btn_copy_log"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="\uD83D\uDCCB"
android:backgroundTint="#374151"
android:textColor="#FFFFFF"
android:minWidth="48dp"
android:layout_marginEnd="4dp"
android:visibility="gone"
style="@style/Widget.MaterialComponents.Button" />
<Button
android:id="@+id/btn_share_log"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="\uD83D\uDCE4"
android:backgroundTint="#374151"
android:textColor="#FFFFFF"
android:minWidth="48dp"
android:visibility="gone"
style="@style/Widget.MaterialComponents.Button" />
</LinearLayout>
<ScrollView
android:id="@+id/sv_debug_log"
android:layout_width="match_parent"
android:layout_height="220dp"
android:layout_marginBottom="12dp"
android:background="#111827"
android:visibility="gone">
<TextView
android:id="@+id/tv_debug_log"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text=""
android:textSize="10sp"
android:fontFamily="monospace"
android:textColor="#4ADE80"
android:padding="8dp" />
</ScrollView>
<TextView
android:id="@+id/tv_scan_hint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Press to scan for nearby BLE scales.\nMake sure the scale is turned on."
android:textSize="12sp"
android:textColor="#64748B"
android:paddingBottom="12dp"
android:visibility="gone" />
<!-- ── Device list ─────────────────────────────────────────────────── -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_devices"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:nestedScrollingEnabled="false" />
</LinearLayout>
</ScrollView>
</LinearLayout>
@@ -1,58 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
app:cardCornerRadius="10dp"
app:cardElevation="1dp"
app:cardBackgroundColor="#FFFFFF">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="14dp"
android:gravity="center_vertical">
<TextView
android:layout_width="32dp"
android:layout_height="32dp"
android:text="⚖️"
android:textSize="20sp"
android:gravity="center"
android:layout_marginEnd="12dp" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tv_device_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="15sp"
android:textStyle="bold"
android:textColor="#1E293B" />
<TextView
android:id="@+id/tv_device_addr"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="11sp"
android:fontFamily="monospace"
android:textColor="#94A3B8" />
</LinearLayout>
<TextView
android:id="@+id/tv_device_rssi"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="11sp"
android:textColor="#64748B" />
</LinearLayout>
</androidx.cardview.widget.CardView>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 413 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 413 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 546 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 546 B

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="white">#FFFFFFFF</color>
<color name="black">#FF000000</color>
<color name="accent">#7C3AED</color>
<color name="green">#059669</color>
<color name="red">#EF4444</color>
<color name="blue">#1D4ED8</color>
</resources>
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">EverShelf Scale Gateway</string>
</resources>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<!-- App-private external dir: no storage permission needed -->
<external-files-path name="apk_downloads" path="." />
</paths>
-5
View File
@@ -1,5 +0,0 @@
// Top-level build file
plugins {
id("com.android.application") version "8.2.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
}
@@ -1,2 +0,0 @@
android.useAndroidX=true
android.enableJetifier=true
@@ -1,6 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
@@ -1,17 +0,0 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "EverShelf Scale Gateway"
include(":app")
+298 -193
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=20260513a">
<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 -->
@@ -53,8 +53,18 @@
<!-- ===== APP PRELOADER (hidden by JS once _initApp completes) ===== -->
<div id="app-preloader" aria-hidden="true">
<div class="app-preloader-inner">
<div class="app-preloader-spinner"></div>
<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-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.23</span>
</div>
</div>
@@ -67,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.12</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.23</span>
</h1>
<!-- Update badge — shown alongside title, never replaces it -->
<span class="header-update-badge" id="header-update-badge" style="display:none"></span>
@@ -184,13 +194,14 @@
<div class="page-header">
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
<h2 id="inventory-title" data-i18n="inventory.title">Dispensa</h2>
<button class="page-header-action-btn" onclick="_showExportModal()" title="Export" data-i18n-title="export.btn_title">📤</button>
</div>
<div class="location-tabs" id="location-tabs">
<button class="tab active" onclick="filterLocation('')" data-loc="" data-i18n="inventory.filter_all">Tutti</button>
<button class="tab" onclick="filterLocation('dispensa')" data-loc="dispensa">🗄️ Dispensa</button>
<button class="tab" onclick="filterLocation('frigo')" data-loc="frigo">🧊 Frigo</button>
<button class="tab" onclick="filterLocation('freezer')" data-loc="freezer">❄️ Freezer</button>
<button class="tab" onclick="filterLocation('altro')" data-loc="altro">📦 Altro</button>
<button class="tab" onclick="filterLocation('dispensa')" data-loc="dispensa">🗄️ <span data-i18n="locations.dispensa">Dispensa</span></button>
<button class="tab" onclick="filterLocation('frigo')" data-loc="frigo">🧊 <span data-i18n="locations.frigo">Frigo</span></button>
<button class="tab" onclick="filterLocation('freezer')" data-loc="freezer">❄️ <span data-i18n="locations.freezer">Freezer</span></button>
<button class="tab" onclick="filterLocation('altro')" data-loc="altro">📦 <span data-i18n="locations.altro">Altro</span></button>
</div>
<div class="search-bar">
<input type="text" id="inventory-search" placeholder="🔍 Cerca prodotto..." oninput="filterInventory()" data-i18n-placeholder="inventory.search_placeholder">
@@ -325,11 +336,11 @@
<div class="action-buttons" id="action-buttons-container">
<button class="btn btn-huge btn-success" onclick="showAddForm()">
<span class="btn-icon">📥</span>
<span class="btn-text">AGGIUNGI<br><small>in dispensa/frigo</small></span>
<span class="btn-text"><span data-i18n="action.add_btn">AGGIUNGI</span><br><small data-i18n="action.add_sub">in dispensa/frigo</small></span>
</button>
<button class="btn btn-huge btn-danger" onclick="showUseForm()">
<span class="btn-icon">📤</span>
<span class="btn-text">USA / CONSUMA<br><small>dalla dispensa/frigo</small></span>
<span class="btn-text"><span data-i18n="action.use_btn">USA / CONSUMA</span><br><small data-i18n="action.use_sub">dalla dispensa/frigo</small></span>
</button>
</div>
</section>
@@ -337,23 +348,23 @@
<!-- ===== ADD TO INVENTORY FORM ===== -->
<section class="page" id="page-add">
<div class="page-header">
<button class="back-btn" onclick="showPage('action')">← Indietro</button>
<h2>Aggiungi alla Dispensa</h2>
<button class="back-btn" onclick="showPage('action')" data-i18n="btn.back">← Indietro</button>
<h2 data-i18n="add.title">Aggiungi alla Dispensa</h2>
</div>
<div class="product-preview-small" id="add-product-preview"></div>
<form class="form" onsubmit="submitAdd(event)">
<div class="form-group">
<label>📍 Dove lo metti?</label>
<label data-i18n="add.location_label">📍 Dove lo metti?</label>
<div class="location-selector">
<button type="button" class="loc-btn active" onclick="selectLocation(this, 'dispensa')">🗄️ Dispensa</button>
<button type="button" class="loc-btn" onclick="selectLocation(this, 'frigo')">🧊 Frigo</button>
<button type="button" class="loc-btn" onclick="selectLocation(this, 'freezer')">❄️ Freezer</button>
<button type="button" class="loc-btn" onclick="selectLocation(this, 'altro')">📦 Altro</button>
<button type="button" class="loc-btn active" onclick="selectLocation(this, 'dispensa')">🗄️ <span data-i18n="locations.dispensa">Dispensa</span></button>
<button type="button" class="loc-btn" onclick="selectLocation(this, 'frigo')">🧊 <span data-i18n="locations.frigo">Frigo</span></button>
<button type="button" class="loc-btn" onclick="selectLocation(this, 'freezer')">❄️ <span data-i18n="locations.freezer">Freezer</span></button>
<button type="button" class="loc-btn" onclick="selectLocation(this, 'altro')">📦 <span data-i18n="locations.altro">Altro</span></button>
</div>
<input type="hidden" id="add-location" value="dispensa">
</div>
<div class="form-group">
<label>📦 Quantità</label>
<label data-i18n="add.quantity_label">📦 Quantità</label>
<div class="qty-unit-row">
<div class="qty-control flex-1">
<button type="button" class="qty-btn" onclick="adjustAddQty(-1)"></button>
@@ -369,7 +380,7 @@
</div>
<button type="button" id="btn-scale-add" class="btn btn-secondary scale-read-btn" style="display:none" onclick="readScaleWeight('add-quantity', function(){ return document.getElementById('add-unit').value; })" data-i18n="scale.read_btn">⚖️ Leggi dalla bilancia</button>
<div id="add-conf-size-row" class="conf-size-row" style="display:none">
<label class="conf-size-label">📦 Ogni confezione contiene:</label>
<label class="conf-size-label" data-i18n="add.conf_size_label">📦 Ogni confezione contiene:</label>
<div class="conf-size-inputs">
<input type="number" id="add-conf-size" class="form-input conf-size-input" min="1" step="any" placeholder="es. 300">
<select id="add-conf-unit" class="form-input conf-size-unit">
@@ -382,43 +393,43 @@
</div>
<div class="form-group" id="add-vacuum-group">
<label class="toggle-row" onclick="toggleVacuumSealed()">
<span>🫙 Sotto vuoto</span>
<span data-i18n="add.vacuum_label">🫙 Sotto vuoto</span>
<span class="toggle-switch" id="add-vacuum-toggle">
<input type="checkbox" id="add-vacuum-sealed" onchange="onVacuumSealedChange()">
<span class="toggle-slider"></span>
</span>
</label>
<p class="form-hint" id="add-vacuum-hint" style="display:none">La scadenza verrà estesa automaticamente</p>
<p class="form-hint" id="add-vacuum-hint" style="display:none" data-i18n="add.vacuum_hint">La scadenza verrà estesa automaticamente</p>
</div>
<div class="form-group" id="add-expiry-section">
<!-- Populated dynamically by showAddForm() -->
</div>
<button type="submit" class="btn btn-large btn-success full-width">✅ Aggiungi</button>
<button type="submit" class="btn btn-large btn-success full-width" data-i18n="add.submit">✅ Aggiungi</button>
</form>
</section>
<!-- ===== USE FROM INVENTORY FORM ===== -->
<section class="page" id="page-use">
<div class="page-header">
<button class="back-btn" onclick="showPage('action')">← Indietro</button>
<h2>Usa / Consuma</h2>
<button class="back-btn" onclick="showPage('action')" data-i18n="btn.back">← Indietro</button>
<h2 data-i18n="use.title">Usa / Consuma</h2>
</div>
<div class="product-preview-small" id="use-product-preview"></div>
<div class="use-inventory-info" id="use-inventory-info"></div>
<div id="use-expiry-hint" style="display:none"></div>
<form class="form" onsubmit="submitUse(event)">
<div class="form-group">
<label>📍 Da dove?</label>
<div class="form-group" id="use-location-group">
<label data-i18n="use.location_label">📍 Da dove?</label>
<div class="location-selector" id="use-location-selector">
<button type="button" class="loc-btn active" onclick="selectUseLocation(this, 'dispensa')">🗄️ Dispensa</button>
<button type="button" class="loc-btn" onclick="selectUseLocation(this, 'frigo')">🧊 Frigo</button>
<button type="button" class="loc-btn" onclick="selectUseLocation(this, 'freezer')">❄️ Freezer</button>
<button type="button" class="loc-btn" onclick="selectUseLocation(this, 'altro')">📦 Altro</button>
<button type="button" class="loc-btn active" onclick="selectUseLocation(this, 'dispensa')">🗄️ <span data-i18n="locations.dispensa">Dispensa</span></button>
<button type="button" class="loc-btn" onclick="selectUseLocation(this, 'frigo')">🧊 <span data-i18n="locations.frigo">Frigo</span></button>
<button type="button" class="loc-btn" onclick="selectUseLocation(this, 'freezer')">❄️ <span data-i18n="locations.freezer">Freezer</span></button>
<button type="button" class="loc-btn" onclick="selectUseLocation(this, 'altro')">📦 <span data-i18n="locations.altro">Altro</span></button>
</div>
<input type="hidden" id="use-location" value="dispensa">
</div>
<div class="form-group">
<label>Quanto hai usato?</label>
<label data-i18n="use.quantity_label">Quanto hai usato?</label>
<button type="button" id="btn-scale-use" class="btn btn-secondary scale-read-btn" style="display:none" onclick="readScaleWeight('use-quantity', function(){ return _useNormalUnit || 'g'; })" data-i18n="scale.read_btn">⚖️ Leggi dalla bilancia</button>
<!-- Live scale weight box (visible when scale connected and unit is g/ml) -->
<div id="scale-live-box" class="scale-live-box" style="display:none">
@@ -431,21 +442,21 @@
</div>
<div class="use-unit-switch" id="use-unit-switch" style="display:none">
<button type="button" class="use-unit-btn active" id="use-unit-sub" onclick="switchUseUnit('sub')"></button>
<button type="button" class="use-unit-btn" id="use-unit-conf" onclick="switchUseUnit('conf')">Confezioni</button>
<button type="button" class="use-unit-btn" id="use-unit-conf" onclick="switchUseUnit('conf')" data-i18n="units.boxes">Confezioni</button>
</div>
<div class="use-options">
<button type="button" class="btn btn-large btn-danger full-width use-all-btn" onclick="submitUseAll()">
<button type="button" class="btn btn-large btn-danger full-width use-all-btn" onclick="submitUseAll()" data-i18n="use.use_all">
🗑️ Usato TUTTO / Finito
</button>
<div class="use-partial">
<p id="use-partial-hint">Oppure specifica la quantità usata:</p>
<p id="use-partial-hint" data-i18n="use.partial_hint">Oppure specifica la quantità usata:</p>
<div class="qty-control">
<button type="button" class="qty-btn" id="use-qty-minus" onclick="adjustUseQty(-1)"></button>
<input type="number" id="use-quantity" value="1" min="0.1" step="any" class="qty-input"
oninput="_scaleUserDismissed=true; _cancelScaleTimersOnly();">
<button type="button" class="qty-btn" id="use-qty-plus" onclick="adjustUseQty(1)">+</button>
</div>
<button type="submit" id="btn-use-submit" class="btn btn-large btn-warning full-width mt-2 move-countdown-btn">📤 Usa questa quantità</button>
<button type="submit" id="btn-use-submit" class="btn btn-large btn-warning full-width mt-2 move-countdown-btn" data-i18n="use.submit">📤 Usa questa quantità</button>
</div>
</div>
</div>
@@ -456,19 +467,19 @@
<!-- ===== MANUAL / EDIT PRODUCT FORM ===== -->
<section class="page" id="page-product-form">
<div class="page-header">
<button class="back-btn" onclick="showPage('scan')">← Indietro</button>
<button class="back-btn" onclick="showPage('scan')" data-i18n="btn.back">← Indietro</button>
<h2 id="product-form-title">Nuovo Prodotto</h2>
</div>
<form class="form" onsubmit="submitProduct(event)">
<input type="hidden" id="pf-id">
<div id="pf-ai-fill-row" class="form-group">
<button type="button" class="btn btn-accent full-width" onclick="captureForAIFormFill()">
<button type="button" class="btn btn-accent full-width" onclick="captureForAIFormFill()" data-i18n="product.ai_fill">
📷 Scatta foto e identifica con AI
</button>
<p class="form-hint" style="text-align:center;margin-top:4px">L'AI compilerà automaticamente i campi del prodotto</p>
<p class="form-hint" style="text-align:center;margin-top:4px" data-i18n="product.ai_fill_hint">L'AI compilerà automaticamente i campi del prodotto</p>
</div>
<div class="form-group">
<label>🏷️ Nome Prodotto *</label>
<label data-i18n="product.name_label">🏷️ Nome Prodotto *</label>
<input type="text" id="pf-name" class="form-input" required placeholder="Es: Latte intero, Pasta penne rigate..."
list="common-products" autocomplete="off">
<datalist id="common-products">
@@ -535,7 +546,7 @@
</datalist>
</div>
<div class="form-group">
<label>🏢 Marca</label>
<label data-i18n="product.brand_label">🏢 Marca</label>
<input type="text" id="pf-brand" class="form-input" placeholder="Es: Barilla, Granarolo, Mutti..."
list="common-brands" autocomplete="off">
<datalist id="common-brands">
@@ -575,9 +586,9 @@
</datalist>
</div>
<div class="form-group">
<label>📂 Categoria</label>
<label data-i18n="product.category_label">📂 Categoria</label>
<select id="pf-category" class="form-input" onchange="onCategoryChange(false)">
<option value="">-- Seleziona --</option>
<option value="" data-i18n="form.select_placeholder">-- Seleziona --</option>
<option value="latticini">🥛 Latticini</option>
<option value="carne">🥩 Carne</option>
<option value="pesce">🐟 Pesce</option>
@@ -598,21 +609,21 @@
</div>
<div class="form-row">
<div class="form-group flex-1">
<label>📏 Unità di misura</label>
<label data-i18n="product.unit_label">📏 Unità di misura</label>
<select id="pf-unit" class="form-input" onchange="onPfUnitChange()">
<option value="pz">Pezzi</option>
<option value="g">Grammi</option>
<option value="pz" data-i18n="units.pieces">Pezzi</option>
<option value="g" data-i18n="units.grams">Grammi</option>
<option value="ml">ml</option>
<option value="conf">Confezione</option>
<option value="conf" data-i18n="units.box">Confezione</option>
</select>
</div>
<div class="form-group flex-1">
<label>🔢 Quantità default</label>
<label data-i18n="product.default_qty_label">🔢 Quantità default</label>
<input type="number" id="pf-defqty" class="form-input" value="1" min="0.1" step="any">
</div>
</div>
<div id="pf-conf-size-row" class="conf-size-row" style="display:none">
<label class="conf-size-label">📦 Ogni confezione contiene:</label>
<label class="conf-size-label" data-i18n="product.conf_size_label">📦 Ogni confezione contiene:</label>
<div class="conf-size-inputs">
<input type="number" id="pf-conf-size" class="form-input conf-size-input" min="1" step="any" placeholder="es. 300">
<select id="pf-conf-unit" class="form-input conf-size-unit">
@@ -622,30 +633,30 @@
</div>
</div>
<div class="form-group">
<label>📝 Note</label>
<label data-i18n="product.notes_label">📝 Note</label>
<textarea id="pf-notes" class="form-input" rows="2" placeholder="Es: senza lattosio, bio, conservare in frigo dopo apertura..."></textarea>
</div>
<div class="form-group">
<label>🔖 Barcode</label>
<label data-i18n="product.barcode_label">🔖 Barcode</label>
<div class="expiry-input-row">
<input type="text" id="pf-barcode" class="form-input" placeholder="Codice a barre (se disponibile)" inputmode="numeric">
<input type="text" id="pf-barcode" class="form-input" placeholder="Codice a barre (se disponibile)" inputmode="numeric" data-i18n-placeholder="product.barcode_placeholder">
<button type="button" class="btn btn-accent btn-scan-expiry" id="pf-barcode-scan-btn" onclick="scanBarcodeForForm()" title="Scansiona barcode">📷</button>
</div>
<p class="form-hint" id="pf-barcode-hint" style="display:none">⚠️ Aggiungi il barcode così al prossimo acquisto basta scansionarlo!</p>
<p class="form-hint" id="pf-barcode-hint" style="display:none" data-i18n="product.barcode_hint">⚠️ Aggiungi il barcode così al prossimo acquisto basta scansionarlo!</p>
</div>
<input type="hidden" id="pf-image">
<div class="product-image-preview" id="pf-image-preview" style="display:none">
<img id="pf-image-img" src="" alt="Product">
</div>
<button type="submit" class="btn btn-large btn-primary full-width">💾 Salva Prodotto</button>
<button type="submit" class="btn btn-large btn-primary full-width" data-i18n="btn.save_product">💾 Salva Prodotto</button>
</form>
</section>
<!-- ===== ALL PRODUCTS PAGE ===== -->
<section class="page" id="page-products">
<div class="page-header">
<button class="back-btn" onclick="showPage('dashboard')">← Indietro</button>
<h2>📦 Tutti i Prodotti</h2>
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
<h2 data-i18n="products.title">📦 Tutti i Prodotti</h2>
</div>
<div class="search-bar">
<input type="text" id="products-search" placeholder="🔍 Cerca prodotto..." oninput="searchAllProducts()">
@@ -778,8 +789,8 @@
<!-- ===== AI IDENTIFICATION PAGE ===== -->
<section class="page" id="page-ai">
<div class="page-header">
<button class="back-btn" onclick="stopScanner(); showPage('scan')">← Indietro</button>
<h2>🤖 Identificazione AI</h2>
<button class="back-btn" onclick="stopScanner(); showPage('scan')" data-i18n="btn.back">← Indietro</button>
<h2 data-i18n="ai.title">🤖 Identificazione AI</h2>
</div>
<div class="ai-container">
<div class="ai-capture" id="ai-capture">
@@ -790,15 +801,15 @@
<img id="ai-image" src="" alt="Captured">
</div>
<div class="ai-actions">
<button class="btn btn-large btn-accent" onclick="takePhotoForAI()" id="ai-capture-btn">
<button class="btn btn-large btn-accent" onclick="takePhotoForAI()" id="ai-capture-btn" data-i18n="ai.capture">
📸 Scatta Foto
</button>
<button class="btn btn-large btn-secondary" onclick="retakePhotoAI()" id="ai-retake-btn" style="display:none">
<button class="btn btn-large btn-secondary" onclick="retakePhotoAI()" id="ai-retake-btn" style="display:none" data-i18n="ai.retake">
🔄 Riscatta
</button>
</div>
<div class="ai-result" id="ai-result" style="display:none"></div>
<p class="scan-hint">Scatta una foto del prodotto e l'AI cercherà di identificarlo</p>
<p class="scan-hint" data-i18n="ai.hint">Scatta una foto del prodotto e l'AI cercherà di identificarlo</p>
</div>
</section>
@@ -820,7 +831,8 @@
<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 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" title="Bring!">🛒</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>
@@ -828,19 +840,115 @@
<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-scale')" data-tab="tab-scale" title="Bilancia Smart" data-i18n-title="settings.scale.tab">⚖️</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>
<div class="form-group">
<label>API Key Gemini</label>
<label data-i18n="settings.gemini.key_label">API Key Gemini</label>
<input type="password" id="setting-gemini-key" class="form-input" placeholder="AIza...">
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-gemini-key')">👁️ Mostra/Nascondi</button>
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-gemini-key')" data-i18n="btn.toggle_password">👁️ Mostra/Nascondi</button>
</div>
</div>
</div>
@@ -850,13 +958,13 @@
<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>
<div class="form-group">
<label>📧 Email Bring!</label>
<label data-i18n="settings.bring.email_label">📧 Email Bring!</label>
<input type="email" id="setting-bring-email" class="form-input" placeholder="email@esempio.com">
</div>
<div class="form-group">
<label>🔒 Password Bring!</label>
<label data-i18n="settings.bring.password_label">🔒 Password Bring!</label>
<input type="password" id="setting-bring-password" class="form-input" placeholder="Password">
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-bring-password')">👁️ Mostra/Nascondi</button>
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-bring-password')" data-i18n="btn.toggle_password">👁️ Mostra/Nascondi</button>
</div>
</div>
<!-- Price Estimation Settings -->
@@ -892,23 +1000,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">
@@ -927,7 +1018,7 @@
<h4 data-i18n="settings.recipe.title">🍳 Preferenze Ricette</h4>
<p class="settings-hint" data-i18n="settings.recipe.hint">Configura le opzioni predefinite per la generazione delle ricette.</p>
<div class="form-group">
<label>👥 Persone predefinite</label>
<label data-i18n="settings.recipe.persons_label">👥 Persone predefinite</label>
<div class="qty-control">
<button type="button" class="qty-btn" onclick="adjustQty('setting-default-persons', -1)"></button>
<input type="number" id="setting-default-persons" value="1" min="1" max="20" class="qty-input">
@@ -935,18 +1026,18 @@
</div>
</div>
<div class="form-group">
<label>🎯 Opzioni ricetta predefinite</label>
<label data-i18n="settings.recipe.options_label">🎯 Opzioni ricetta predefinite</label>
<div class="recipe-pref-checks">
<label class="checkbox-label"><input type="checkbox" id="setting-pref-veloce"> ⚡ Pasto Veloce</label>
<label class="checkbox-label"><input type="checkbox" id="setting-pref-pocafame"> 🥗 Poca Fame</label>
<label class="checkbox-label"><input type="checkbox" id="setting-pref-scadenze"> ⏰ Priorità Scadenze</label>
<label class="checkbox-label"><input type="checkbox" id="setting-pref-healthy"> 💚 Extra Salutare</label>
<label class="checkbox-label"><input type="checkbox" id="setting-pref-opened"> 📦 Priorità Cose Aperte</label>
<label class="checkbox-label"><input type="checkbox" id="setting-pref-zerowaste"> ♻️ Zero Sprechi</label>
<label class="checkbox-label"><input type="checkbox" id="setting-pref-veloce"> <span data-i18n="settings.recipe.fast">⚡ Pasto Veloce</span></label>
<label class="checkbox-label"><input type="checkbox" id="setting-pref-pocafame"> <span data-i18n="settings.recipe.light">🥗 Poca Fame</span></label>
<label class="checkbox-label"><input type="checkbox" id="setting-pref-scadenze"> <span data-i18n="settings.recipe.expiry">⏰ Priorità Scadenze</span></label>
<label class="checkbox-label"><input type="checkbox" id="setting-pref-healthy"> <span data-i18n="settings.recipe.healthy">💚 Extra Salutare</span></label>
<label class="checkbox-label"><input type="checkbox" id="setting-pref-opened"> <span data-i18n="settings.recipe.opened">📦 Priorità Cose Aperte</span></label>
<label class="checkbox-label"><input type="checkbox" id="setting-pref-zerowaste"> <span data-i18n="settings.recipe.zerowaste">♻️ Zero Sprechi</span></label>
</div>
</div>
<div class="form-group">
<label>🚫 Intolleranze / Restrizioni</label>
<label data-i18n="settings.recipe.dietary_label">🚫 Intolleranze / Restrizioni</label>
<textarea id="setting-dietary" class="form-input" rows="2" placeholder="Es: senza glutine, senza lattosio, vegetariano..."></textarea>
</div>
</div>
@@ -958,7 +1049,7 @@
<p class="settings-hint" data-i18n="settings.mealplan.hint">Imposta la tipologia di pasto per ogni giorno. Sarà usata come guida nella generazione delle ricette.</p>
<div class="form-group" style="margin-bottom:10px">
<label class="toggle-row">
<span>✅ Attiva piano pasti settimanale</span>
<span data-i18n="settings.mealplan.enabled">✅ Attiva piano pasti settimanale</span>
<span class="toggle-switch">
<input type="checkbox" id="setting-meal-plan-enabled" onchange="onMealPlanEnabledChange(this)">
<span class="toggle-slider"></span>
@@ -969,15 +1060,15 @@
<div id="meal-plan-grid" class="mplan-grid"></div>
<div id="meal-plan-picker" class="mplan-picker" style="display:none"></div>
<div style="margin-top:12px; display:flex; gap:8px; flex-wrap:wrap">
<button class="btn btn-small btn-secondary" onclick="resetMealPlan()">↺ Ripristina default</button>
<button class="btn btn-small btn-secondary" onclick="resetMealPlan()" data-i18n="settings.mealplan.reset_btn">↺ Ripristina default</button>
</div>
<div class="settings-hint" style="margin-top:10px">
<div class="settings-hint" style="margin-top:10px" data-i18n-html="settings.mealplan.legend">
🌤️ = Pranzo &nbsp;·&nbsp; 🌙 = Cena &nbsp;·&nbsp; Tocca un badge per cambiarlo.
</div>
</div>
</div>
<div class="settings-card" id="meal-plan-legend-card">
<h4>📋 Tipologie disponibili</h4>
<h4 data-i18n="settings.mealplan.types_title">📋 Tipologie disponibili</h4>
<div class="mplan-legend"></div>
</div>
</div>
@@ -994,18 +1085,18 @@
</div>
</div>
<div class="common-appliances mt-2">
<p class="settings-hint">Aggiungi velocemente:</p>
<p class="settings-hint" data-i18n="settings.appliances.quick_title">Aggiungi velocemente:</p>
<div class="appliance-quick-tags">
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Forno')">🔥 Forno</button>
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Microonde')">📡 Microonde</button>
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Friggitrice ad aria')">🍟 Friggitrice ad aria</button>
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Macchina del pane')">🍞 Macchina pane</button>
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Bimby/Moulinex Cookeo')">🤖 Bimby/Cookeo</button>
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Planetaria')">🌀 Planetaria</button>
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Vaporiera')">♨️ Vaporiera</button>
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Pentola a pressione')">🫕 Pentola pressione</button>
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Tostapane')">🍞 Tostapane</button>
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Frullatore/Mixer')">🍹 Frullatore</button>
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Forno')" data-i18n="settings.appliances.oven">🔥 Forno</button>
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Microonde')" data-i18n="settings.appliances.microwave">📡 Microonde</button>
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Friggitrice ad aria')" data-i18n="settings.appliances.air_fryer">🍟 Friggitrice ad aria</button>
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Macchina del pane')" data-i18n="settings.appliances.bread_maker">🍞 Macchina pane</button>
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Bimby/Moulinex Cookeo')" data-i18n="settings.appliances.bimby">🤖 Bimby/Cookeo</button>
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Planetaria')" data-i18n="settings.appliances.mixer">🌀 Planetaria</button>
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Vaporiera')" data-i18n="settings.appliances.steamer">♨️ Vaporiera</button>
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Pentola a pressione')" data-i18n="settings.appliances.pressure_cooker">🫕 Pentola pressione</button>
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Tostapane')" data-i18n="settings.appliances.toaster">🍞 Tostapane</button>
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Frullatore/Mixer')" data-i18n="settings.appliances.blender">🍹 Frullatore</button>
</div>
</div>
</div>
@@ -1016,35 +1107,35 @@
<h4 data-i18n="settings.camera.title">📷 Fotocamera</h4>
<p class="settings-hint" data-i18n="settings.camera.hint">Scegli quale fotocamera utilizzare per la scansione barcode e l'identificazione AI.</p>
<div class="form-group">
<label>📸 Fotocamera predefinita</label>
<label data-i18n="settings.camera.device_label">📸 Fotocamera predefinita</label>
<select id="setting-camera-facing" class="form-input">
<option value="environment">📱 Posteriore (default)</option>
<option value="user">🤳 Anteriore</option>
<option value="environment" data-i18n="settings.camera.back">📱 Posteriore (default)</option>
<option value="user" data-i18n="settings.camera.front">🤳 Anteriore</option>
</select>
<p class="settings-hint mt-2">Se hai più fotocamere, puoi selezionarne una specifica dall'elenco sopra dopo aver concesso i permessi.</p>
<button class="btn btn-small btn-secondary mt-2" onclick="loadCameraDevices()">🔄 Rileva fotocamere</button>
<p class="settings-hint mt-2" data-i18n="settings.camera.devices_hint">Se hai più fotocamere, puoi selezionarne una specifica dall'elenco sopra dopo aver concesso i permessi.</p>
<button class="btn btn-small btn-secondary mt-2" onclick="loadCameraDevices()" data-i18n="settings.camera.detect_btn">🔄 Rileva fotocamere</button>
</div>
</div>
</div>
<!-- Security Tab -->
<div class="settings-panel" id="tab-security">
<div class="settings-card">
<h4>🔑 Token Impostazioni</h4>
<p class="settings-hint">Se <code>SETTINGS_TOKEN</code> è configurato nel <code>.env</code> server, inserisci qui il token prima di salvare le impostazioni. Lascia vuoto se non configurato.</p>
<h4 data-i18n="settings.security.token_title">🔑 Token Impostazioni</h4>
<p class="settings-hint" data-i18n="settings.security.token_hint">Se <code>SETTINGS_TOKEN</code> è configurato nel <code>.env</code> server, inserisci qui il token prima di salvare le impostazioni. Lascia vuoto se non configurato.</p>
<div class="form-group">
<label>Token di accesso</label>
<input type="password" id="setting-settings-token" class="form-input" placeholder="(vuoto = nessuna protezione)">
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-settings-token')">👁️ Mostra/Nascondi</button>
<label data-i18n="settings.security.token_label">Token di accesso</label>
<input type="password" id="setting-settings-token" class="form-input" placeholder="(vuoto = nessuna protezione)" data-i18n-placeholder="settings.security.token_placeholder">
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-settings-token')" data-i18n="btn.toggle_password">👁️ Mostra/Nascondi</button>
</div>
<p class="settings-hint" id="settings-token-status-hint" style="display:none;color:var(--accent)">🔒 Questo server richiede un token per salvare le impostazioni.</p>
<p class="settings-hint" id="settings-token-status-hint" style="display:none;color:var(--accent)" data-i18n="settings.security.token_required_hint">🔒 Questo server richiede un token per salvare le impostazioni.</p>
</div>
<div class="settings-card">
<h4>🔒 Certificato HTTPS</h4>
<p class="settings-hint">Se il browser mostra l'errore "La connessione non è privata" (ERR_CERT_AUTHORITY_INVALID), devi installare il certificato CA nel dispositivo.</p>
<h4 data-i18n="settings.security.title">🔒 Certificato HTTPS</h4>
<p class="settings-hint" data-i18n="settings.security.hint">Se il browser mostra l'errore "La connessione non è privata" (ERR_CERT_AUTHORITY_INVALID), devi installare il certificato CA nel dispositivo.</p>
<div class="form-group">
<a href="ca.crt" download="EverShelf_CA.crt" class="btn btn-large btn-accent full-width" style="text-align:center;text-decoration:none;display:block">📥 Scarica Certificato CA</a>
<a href="ca.crt" download="EverShelf_CA.crt" class="btn btn-large btn-accent full-width" style="text-align:center;text-decoration:none;display:block" data-i18n="settings.security.download_btn">📥 Scarica Certificato CA</a>
</div>
<div class="settings-hint" style="margin-top:12px;line-height:1.6">
<div class="settings-hint" style="margin-top:12px;line-height:1.6" data-i18n-html="settings.security.cert_instructions">
<strong>Istruzioni per Chrome (Android):</strong><br>
1. Scarica il certificato qui sopra<br>
2. Vai in <em>Impostazioni → Sicurezza e privacy → Altre impostazioni di sicurezza → Installa da archivio dispositivo</em><br>
@@ -1067,7 +1158,7 @@
<p class="settings-hint" data-i18n="settings.tts.hint">Configura la sintesi vocale. Puoi usare la voce offline del browser oppure un endpoint REST esterno (Home Assistant, ecc.).</p>
<div class="form-group" style="margin-bottom:10px">
<label class="toggle-row">
<span>✅ Attiva TTS</span>
<span data-i18n="settings.tts.enabled">✅ Attiva TTS</span>
<span class="toggle-switch">
<input type="checkbox" id="setting-tts-enabled">
<span class="toggle-slider"></span>
@@ -1075,31 +1166,31 @@
</label>
</div>
<div class="form-group">
<label>⚙️ Motore TTS</label>
<label data-i18n="settings.tts.engine_label">⚙️ Motore TTS</label>
<select id="setting-tts-engine" class="form-input" onchange="onTtsEngineChange(this.value)">
<option value="browser">🔇 Browser (offline, nessuna configurazione)</option>
<option value="server">🌐 Server esterno (Home Assistant, API REST...)</option>
<option value="browser" data-i18n="settings.tts.engine_browser">🔇 Browser (offline, nessuna configurazione)</option>
<option value="server" data-i18n="settings.tts.engine_server">🌐 Server esterno (Home Assistant, API REST...)</option>
</select>
</div>
<!-- Browser TTS section -->
<div id="tts-browser-section">
<div class="form-group">
<label>🗣️ Voce</label>
<label data-i18n="settings.tts.voice_label">🗣️ Voce</label>
<div style="display:flex;gap:8px;align-items:center">
<select id="setting-tts-voice" class="form-input" style="flex:1">
<option value="">— Caricamento voci… —</option>
<option value="" data-i18n="settings.tts.voices_loading">— Caricamento voci… —</option>
</select>
<button type="button" class="btn btn-secondary" style="padding:8px 12px;white-space:nowrap;flex-shrink:0" onclick="_initBrowserTtsVoices(document.getElementById('setting-tts-voice').value)"></button>
</div>
<p class="settings-hint">Le voci disponibili dipendono dal sistema operativo e dal browser. Su macOS/iOS è disponibile la voce <strong>Paola</strong> (italiano). Premi ↺ se la lista non si carica.</p>
<p class="settings-hint" data-i18n="settings.tts.voices_hint">Le voci disponibili dipendono dal sistema operativo e dal browser. Su macOS/iOS è disponibile la voce <strong>Paola</strong> (italiano). Premi ↺ se la lista non si carica.</p>
</div>
<div class="form-group">
<label>⚡ Velocità: <span id="tts-rate-label">1.0</span>×</label>
<label><span data-i18n="settings.tts.rate_label">⚡ Velocità</span>: <span id="tts-rate-label">1.0</span>×</label>
<input type="range" id="setting-tts-rate" class="form-input" min="0.5" max="2" step="0.1" value="1" oninput="document.getElementById('tts-rate-label').textContent=parseFloat(this.value).toFixed(1)">
</div>
<div class="form-group">
<label>🎵 Tono: <span id="tts-pitch-label">1.0</span></label>
<label><span data-i18n="settings.tts.pitch_label">🎵 Tono</span>: <span id="tts-pitch-label">1.0</span></label>
<input type="range" id="setting-tts-pitch" class="form-input" min="0" max="2" step="0.1" value="1" oninput="document.getElementById('tts-pitch-label').textContent=parseFloat(this.value).toFixed(1)">
</div>
</div>
@@ -1107,11 +1198,11 @@
<!-- Server TTS section -->
<div id="tts-server-section" style="display:none">
<div class="form-group">
<label>🌐 URL Endpoint</label>
<label data-i18n="settings.tts.url_label">🌐 URL Endpoint</label>
<input type="url" id="setting-tts-url" class="form-input" placeholder="https://...">
</div>
<div class="form-group">
<label>📡 Metodo HTTP</label>
<label data-i18n="settings.tts.method_label">📡 Metodo HTTP</label>
<select id="setting-tts-method" class="form-input">
<option value="POST">POST</option>
<option value="PUT">PUT</option>
@@ -1120,30 +1211,30 @@
</select>
</div>
<div class="form-group">
<label>🔐 Autenticazione</label>
<label data-i18n="settings.tts.auth_label">🔐 Autenticazione</label>
<select id="setting-tts-auth-type" class="form-input" onchange="onTtsAuthTypeChange(this.value)">
<option value="bearer">Bearer Token</option>
<option value="header">Header personalizzato</option>
<option value="none">Nessuna</option>
<option value="bearer" data-i18n="settings.tts.auth_bearer">Bearer Token</option>
<option value="header" data-i18n="settings.tts.auth_custom">Header personalizzato</option>
<option value="none" data-i18n="settings.tts.auth_none">Nessuna</option>
</select>
</div>
<div class="form-group" id="tts-token-group">
<label>🔑 Bearer Token</label>
<label data-i18n="settings.tts.token_label">🔑 Bearer Token</label>
<input type="password" id="setting-tts-token" class="form-input" placeholder="eyJhbGci...">
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-tts-token')">👁️ Mostra/Nascondi</button>
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-tts-token')" data-i18n="btn.toggle_password">👁️ Mostra/Nascondi</button>
</div>
<div id="tts-custom-header-group" style="display:none">
<div class="form-group">
<label>📋 Nome header</label>
<label data-i18n="settings.tts.custom_header_name">📋 Nome header</label>
<input type="text" id="setting-tts-auth-header-name" class="form-input" placeholder="X-API-Key">
</div>
<div class="form-group">
<label>📋 Valore header</label>
<label data-i18n="settings.tts.custom_header_value">📋 Valore header</label>
<input type="text" id="setting-tts-auth-header-value" class="form-input" placeholder="...">
</div>
</div>
<div class="form-group">
<label>📄 Content-Type</label>
<label data-i18n="settings.tts.content_type_label">📄 Content-Type</label>
<select id="setting-tts-content-type" class="form-input">
<option value="application/json">application/json</option>
<option value="application/x-www-form-urlencoded">application/x-www-form-urlencoded</option>
@@ -1151,18 +1242,18 @@
</select>
</div>
<div class="form-group">
<label>🗝️ Campo testo nel payload</label>
<label data-i18n="settings.tts.payload_key_label">🗝️ Campo testo nel payload</label>
<input type="text" id="setting-tts-payload-key" class="form-input" placeholder="message">
<p class="settings-hint">Nome del campo JSON che conterrà il testo da leggere (es: <code>message</code>, <code>text</code>).</p>
</div>
<div class="form-group">
<label> Campi extra (JSON)</label>
<label data-i18n="settings.tts.extra_fields_label"> Campi extra (JSON)</label>
<textarea id="setting-tts-extra-fields" class="form-input" rows="3" placeholder='{"entity_id": "media_player.living_room"}'></textarea>
<p class="settings-hint">Campi aggiuntivi da includere nel payload, in formato JSON. Lascia vuoto se non necessario.</p>
<p class="settings-hint" data-i18n="settings.tts.extra_fields_hint">Campi aggiuntivi da includere nel payload, in formato JSON. Lascia vuoto se non necessario.</p>
</div>
</div><!-- /tts-server-section -->
<button class="btn btn-large btn-accent full-width mt-2" onclick="testTTS()">🔊 Invia Test Vocale</button>
<button class="btn btn-large btn-accent full-width mt-2" onclick="testTTS()" data-i18n="settings.tts.test_btn">🔊 Invia Test Vocale</button>
<div id="tts-test-status" style="display:none;margin-top:8px"></div>
</div>
</div>
@@ -1174,14 +1265,14 @@
<!-- Kiosk-mode panel: replace WebSocket config with native reconfigure button -->
<div id="scale-kiosk-panel" style="display:none;background:rgba(16,185,129,0.07);border:1px solid rgba(16,185,129,0.25);border-radius:10px;padding:14px;margin-bottom:16px">
<p style="margin:0 0 6px;font-weight:600">📡 Bilancia BLE integrata nel Kiosk</p>
<p class="settings-hint" style="margin-bottom:12px">La bilancia è gestita direttamente dal Gateway BLE interno al kiosk. Per abbinare un nuovo dispositivo usa il wizard di configurazione.</p>
<button class="btn btn-secondary full-width" onclick="_kioskReconfigureScale()">🔄 Riconfigura bilancia BLE</button>
<p style="margin:0 0 6px;font-weight:600" data-i18n="settings.scale.kiosk_title">📡 Bilancia BLE integrata nel Kiosk</p>
<p class="settings-hint" style="margin-bottom:12px" data-i18n="settings.scale.kiosk_hint">La bilancia è gestita direttamente dal Gateway BLE interno al kiosk. Per abbinare un nuovo dispositivo usa il wizard di configurazione.</p>
<button class="btn btn-secondary full-width" onclick="_kioskReconfigureScale()" data-i18n="settings.scale.kiosk_reconfigure">🔄 Riconfigura bilancia BLE</button>
<!-- shown when kiosk APK is too old to have reconfigureScale() -->
<div id="kiosk-needs-update-notice" style="display:none;margin-top:10px;padding:8px 12px;background:rgba(245,158,11,0.1);border:1px solid rgba(245,158,11,0.35);border-radius:8px;font-size:0.83rem">
⚠️ Il kiosk installato non supporta questa funzione.
Aggiorna l'app kiosk per abilitarla.
<a href="https://github.com/dadaloop82/EverShelf/releases/download/kiosk-latest/evershelf-kiosk.apk" target="_blank" rel="noopener noreferrer" style="display:block;margin-top:6px;color:#d97706;font-weight:600;text-decoration:none">📥 Scarica aggiornamento kiosk</a>
<span data-i18n="settings.kiosk.needs_update">⚠️ Il kiosk installato non supporta questa funzione.
Aggiorna l'app kiosk per abilitarla.</span>
<a href="https://github.com/dadaloop82/EverShelf/releases/download/kiosk-latest/evershelf-kiosk.apk" target="_blank" rel="noopener noreferrer" style="display:block;margin-top:6px;color:#d97706;font-weight:600;text-decoration:none" data-i18n="settings.kiosk.download_btn">📥 Scarica aggiornamento kiosk</a>
</div>
</div>
@@ -1229,16 +1320,16 @@
</div>
<div style="text-align:center">
<div id="scale-diag-weight" style="font-size:2rem;font-weight:700;line-height:1;letter-spacing:1px">— g</div>
<div style="font-size:0.72rem;color:var(--text-secondary);margin-top:3px">peso in tempo reale</div>
<div style="font-size:0.72rem;color:var(--text-secondary);margin-top:3px" data-i18n="settings.scale.live_weight">peso in tempo reale</div>
</div>
<div style="margin-top:10px;display:flex;gap:8px;font-size:0.78rem;color:var(--text-secondary)">
<span>🔁 Riconnessione: automatica</span>
<span data-i18n="settings.scale.auto_reconnect">🔁 Riconnessione: automatica</span>
<span style="margin-left:auto" id="scale-diag-proto"></span>
</div>
</div>
<!-- Protocol info -->
<div class="settings-hint" style="margin-top:16px;padding:10px;background:var(--bg-secondary,#f8fafc);border-radius:8px">
<div class="settings-hint" style="margin-top:16px;padding:10px;background:var(--bg-secondary,#f8fafc);border-radius:8px" data-i18n-html="settings.scale.ble_protocols">
<p style="margin:0 0 6px;font-weight:600">🔌 Protocolli BLE supportati:</p>
<ul style="margin:0 0 0 16px;padding:0;font-size:0.8rem">
<li>Bluetooth SIG Weight Scale (0x181D)</li>
@@ -1250,40 +1341,36 @@
</div>
</div>
<!-- Language Tab -->
<div class="settings-panel" id="tab-language">
<!-- Info Tab -->
<div class="settings-panel" id="tab-info">
<!-- Gemini AI Usage card -->
<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.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.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>
<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 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)">⏱️ Avvia dopo</label>
<select id="setting-screensaver-timeout" class="form-control" style="margin-top:6px;max-width:200px">
<option value="1">1 minuto</option>
<option value="2">2 minuti</option>
<option value="5" selected>5 minuti</option>
<option value="10">10 minuti</option>
<option value="15">15 minuti</option>
<option value="30">30 minuti</option>
<option value="60">1 ora</option>
</select>
</div>
<!-- Activity card -->
<div class="settings-card">
<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>
@@ -1302,17 +1389,29 @@
<p class="settings-hint" style="margin-top:8px" data-i18n="settings.kiosk.download_sub">Modalità kiosk full-screen + gateway bilancia integrato. Sorgente: <code>evershelf-kiosk/</code></p>
</div>
<!-- Kiosk native settings panel (visible only inside kiosk WebView) -->
<div id="kiosk-native-settings-panel" style="display:none;background:rgba(99,102,241,0.06);border:1.5px solid rgba(99,102,241,0.2);border-radius:12px;padding:16px;margin-top:16px">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px">
<span style="font-size:1.4rem">🖥️</span>
<div>
<p style="margin:0;font-weight:700;font-size:0.9rem" data-i18n="settings.kiosk.native_title">Configurazione Kiosk</p>
<p class="settings-hint" style="margin:2px 0 0" data-i18n="settings.kiosk.native_hint">URL server, bilancia BLE, salvaschermo e setup wizard.</p>
</div>
</div>
<button class="btn btn-secondary full-width" onclick="_openKioskNativeSettings()">⚙️ <span data-i18n="settings.kiosk.native_btn">Apri configurazione kiosk</span></button>
</div>
<!-- Kiosk self-update panel (visible only inside kiosk WebView) -->
<div id="kiosk-update-panel" style="display:none;background:rgba(16,185,129,0.06);border:1.5px solid rgba(16,185,129,0.2);border-radius:12px;padding:16px;margin-top:16px">
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:10px">
<div style="display:flex;align-items:center;gap:8px">
<span style="font-size:1.4rem">📦</span>
<div>
<p style="margin:0;font-weight:700;font-size:0.9rem">Aggiornamento Kiosk</p>
<p style="margin:0;font-weight:700;font-size:0.9rem" data-i18n="settings.kiosk.update_title">Aggiornamento Kiosk</p>
<p class="settings-hint" style="margin:2px 0 0" id="kiosk-update-version-label"></p>
</div>
</div>
<button class="btn btn-secondary" style="white-space:nowrap;min-width:120px" id="btn-kiosk-check-update" onclick="_kioskCheckForUpdates()">🔍 Cerca aggiornamenti</button>
<button class="btn btn-secondary" style="white-space:nowrap;min-width:120px" id="btn-kiosk-check-update" onclick="_kioskCheckForUpdates()" data-i18n="settings.kiosk.check_updates_btn">🔍 Cerca aggiornamenti</button>
</div>
<div id="kiosk-update-status" style="display:none;padding:10px 12px;border-radius:8px;font-size:0.85rem;line-height:1.4"></div>
<button id="btn-kiosk-install-update" style="display:none;width:100%;margin-top:10px" class="btn btn-accent btn-large" onclick="_kioskInstallUpdate()">⬇️ Installa aggiornamento</button>
@@ -1332,7 +1431,7 @@
<button class="btn btn-outline full-width" onclick="reportBugManual()" id="btn-report-bug">
🐛 <span data-i18n="about.report_bug">Segnala un problema</span>
</button>
<p class="settings-hint" style="text-align:center;margin:0" data-i18n="about.report_bug_hint">Qualcosa non funziona? Apri una segnalazione su GitHub.</p>
<p class="settings-hint" style="text-align:center;margin:0" data-i18n="about.report_bug_hint">Qualcosa non funziona? Inviaci una segnalazione direttamente dall'app.</p>
<div style="display:flex;gap:8px">
<a class="btn btn-outline full-width" style="text-decoration:none;text-align:center"
href="https://github.com/dadaloop82/EverShelf/blob/main/CHANGELOG.md"
@@ -1342,7 +1441,6 @@
target="_blank" rel="noopener" data-i18n="about.github">GitHub</a>
</div>
</div>
<div id="report-bug-status" style="display:none;margin-top:8px;text-align:center;font-size:0.85rem"></div>
</div>
</section>
@@ -1404,7 +1502,7 @@
</button>
<button class="nav-btn" onclick="showPage('settings')" data-page="settings">
<span class="nav-icon">⚙️</span>
<span class="nav-label">Config</span>
<span class="nav-label" data-i18n="nav.settings">Config</span>
</button>
</nav>
@@ -1473,8 +1571,8 @@
</div>
<div class="setup-body" id="setup-body"></div>
<div class="setup-footer">
<button class="btn btn-secondary" id="setup-prev" onclick="setupWizardNav(-1)" style="display:none">← Indietro</button>
<button class="btn btn-accent" id="setup-next" onclick="setupWizardNav(1)">Avanti →</button>
<button class="btn btn-secondary" id="setup-prev" onclick="setupWizardNav(-1)" style="display:none" data-i18n="btn.back">← Indietro</button>
<button class="btn btn-accent" id="setup-next" onclick="setupWizardNav(1)" data-i18n="btn.next">Avanti →</button>
</div>
</div>
</div>
@@ -1522,6 +1620,7 @@
<button class="cooking-tts-btn" id="cooking-tts-btn" onclick="toggleCookingTTS()" title="Leggi ad alta voce">🔊</button>
</div>
<div id="cooking-timers-bar" class="cooking-timers-bar" style="display:none"></div>
<div id="cooking-tools-bar" class="cooking-tools-bar" style="display:none"></div>
<div class="cooking-body">
<div class="cooking-step-header">
<div class="cooking-step-num" id="cooking-step-num">1 / 1</div>
@@ -1540,6 +1639,12 @@
</button>
</div>
<div class="cooking-step-ings" id="cooking-step-ings" style="display:none"></div>
<div id="cooking-zerowaste-tip" class="cooking-zerowaste-tip" style="display:none">
<span class="cooking-zerowaste-label" data-i18n="cooking.zerowaste_label">♻️ Scarto</span>
<span id="cooking-zerowaste-scrap" class="cooking-zerowaste-scrap"></span>
<p id="cooking-zerowaste-text" class="cooking-zerowaste-text"></p>
<button class="cooking-zerowaste-close" onclick="_dismissZeroWasteTip()" aria-label="Chiudi"></button>
</div>
</div>
<div class="cooking-nav">
<button class="cooking-nav-btn cooking-prev-btn" id="cooking-prev" onclick="navigateCookingStep(-1)">◀ Precedente</button>
@@ -1547,6 +1652,6 @@
</div>
</div>
<script src="assets/js/app.js?v=20260513a"></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.12",
"version": "1.7.23",
"start_url": "/evershelf/",
"display": "standalone",
"background_color": "#f0f4e8",
+208 -24
View File
@@ -9,7 +9,8 @@
"inventory": "Vorrat",
"recipes": "Rezepte",
"shopping": "Einkauf",
"log": "Verlauf"
"log": "Verlauf",
"settings": "Einstellungen"
},
"btn": {
"back": "← Zurück",
@@ -19,6 +20,8 @@
"add": "✅ Hinzufügen",
"delete": "Löschen",
"edit": "✏️ Bearbeiten",
"use": "Verwenden",
"edit_item": "Bearbeiten",
"search": "🔍 Suchen",
"go": "✅ Los",
"toggle_password": "👁️ Anzeigen/Ausblenden",
@@ -28,7 +31,12 @@
"restart": "↺ Neustart",
"reset_default": "↺ Standard wiederherstellen",
"save_info": "💾 Info speichern",
"retry": "🔄 Erneut versuchen"
"retry": "🔄 Erneut versuchen",
"yes_short": "Ja",
"no_short": "Nein"
},
"form": {
"select_placeholder": "-- Auswählen --"
},
"locations": {
"dispensa": "Vorratskammer",
@@ -63,7 +71,9 @@
"pieces": "Stück",
"grams": "Gramm",
"box": "Packung",
"boxes": "Packungen"
"boxes": "Packungen",
"millilitres": "Milliliter",
"from": "von"
},
"shopping_sections": {
"frutta_verdura": "Obst & Gemüse",
@@ -103,6 +113,8 @@
"banner_expired_action_finished": "Habe ich verbraucht!",
"banner_expired_action_throw": "Habe ich weggeworfen",
"banner_expired_action_edit": "Datum korrigieren",
"banner_expired_action_modify": "Bearbeiten",
"banner_expired_action_vacuum": "Vakuumieren",
"banner_anomaly_action_edit": "Bestand korrigieren",
"banner_anomaly_action_dismiss": "Menge ist korrekt",
"banner_no_expiry_title": "Ablaufdatum fehlt: {name}",
@@ -207,7 +219,7 @@
"title": "Was möchtest du tun?",
"add_btn": "📥 HINZUFÜGEN",
"add_sub": "in Vorrat/Kühlschrank",
"use_btn": "📤 VERWENDEN / VERBRAUCHEN",
"use_btn": "VERWENDEN",
"use_sub": "aus Vorrat/Kühlschrank",
"have_title": "📦 Schon auf Lager!",
"add_more_sub": "weitere Menge",
@@ -215,7 +227,7 @@
"throw_btn": "🗑️ ENTSORGEN",
"throw_sub": "wegwerfen",
"edit_sub": "Ablauf, Ort…",
"create_recipe_btn": "Rezept damit erstellen"
"create_recipe_btn": "Rezept"
},
"add": {
"title": "Zum Vorrat hinzufügen",
@@ -240,7 +252,9 @@
"scan_expiry_title": "📷 Ablaufdatum scannen",
"product_added": "✅ {name} hinzugefügt!{qty}",
"suffix_freezer_vacuum": "(Tiefkühler + vakuumversiegelt)",
"history_badge_tip": "Durchschnitt aus {n} früheren Einträgen"
"history_badge_tip": "Durchschnitt aus {n} früheren Einträgen",
"vacuum_question": "Vakuumiert?",
"vacuum_saved": "🔒 Als vakuumiert gespeichert"
},
"use": {
"title": "Verwenden / Verbrauchen",
@@ -312,7 +326,13 @@
"edit_info": "✏️ Informationen bearbeiten",
"modify_details": "BEARBEITEN\nAblauf, Ort…",
"already_in_pantry": "📋 Bereits im Vorratsschrank",
"no_barcode": "Kein Barcode"
"no_barcode": "Kein Barcode",
"unknown_product": "Unbekanntes Produkt",
"edit_name_brand": "Name/Marke bearbeiten",
"weight_label": "Gewicht",
"origin_label": "Herkunft",
"labels_label": "Etiketten",
"select_variant": "Genaue Variante auswählen oder KI-Daten nutzen:"
},
"products": {
"title": "📦 Alle Produkte",
@@ -341,6 +361,7 @@
"regenerate": "🔄 Noch eins generieren",
"close_btn": "✅ Schließen",
"ingredients_title": "🧾 Zutaten",
"tools_title": "Benötigte Geräte",
"steps_title": "👨‍🍳 Zubereitung",
"no_steps": "Keine Zubereitungsschritte verfügbar",
"generate_error": "Fehler bei der Generierung",
@@ -481,7 +502,8 @@
"undo_success": "↩ Vorgang rückgängig gemacht für {name}",
"already_undone": "Vorgang bereits rückgängig gemacht",
"too_old": "Vorgänge älter als 24 Stunden können nicht rückgängig gemacht werden",
"undo_error": "Fehler beim Rückgängigmachen"
"undo_error": "Fehler beim Rückgängigmachen",
"recipe_prefix": "Rezept"
},
"chat": {
"title": "Gemini Chef",
@@ -501,7 +523,8 @@
"transfer_to_recipes": "Zu Rezepten hinzufügen",
"transferring": "Übertrage...",
"transferred": "Zu Rezepten hinzugefügt!",
"open_recipe": "Rezept öffnen"
"open_recipe": "Rezept öffnen",
"quick_recipe_prompt": "Schlage mir ein schnelles Rezept FÜR EINE PERSON vor, das die Produkte mit dem nächsten Ablaufdatum verwendet! Ignoriere Tiefkühlprodukte, konzentriere dich auf Kühlschrank und Vorratsschrank."
},
"cooking": {
"close": "Schließen",
@@ -512,13 +535,16 @@
"prev": "◀ Zurück",
"next": "Weiter ▶",
"ingredient_used": "✔️ Abgezogen",
"ingredient_use_btn": "📦 Verwenden",
"ingredient_use_btn": "Usa",
"ingredient_deduct_title": "Von Vorrat abziehen",
"timer_expired_tts": "Timer {label} abgelaufen!",
"timer_warning_tts": "Achtung! {label}: noch 10 Sekunden!",
"recipe_done_tts": "Rezept abgeschlossen! Guten Appetit!",
"expires_chip": "läuft ab {date}",
"finish": "✅ Fertig"
"finish": "✅ Fertig",
"step_fallback": "Schritt {n}",
"zerowaste_label": "♻️ Abfall",
"zerowaste_tip_title": "Zero-Waste-Tipp"
},
"settings": {
"title": "⚙️ Einstellungen",
@@ -571,8 +597,9 @@
"title": "📅 Wöchentlicher Essensplan",
"hint": "Lege die Mahlzeitenart für jeden Tag fest. Wird als Leitfaden bei der Rezeptgenerierung verwendet.",
"enabled": "✅ Wöchentlichen Essensplan aktivieren",
"legend": "🌤️ = Mittagessen · 🌙 = Abendessen · Tippe auf ein Badge, um es zu ändern.",
"types_title": "📋 Verfügbare Typen"
"legend": "🌤️ = Mittagessen &nbsp;·&nbsp; 🌙 = Abendessen &nbsp;·&nbsp; Tippe auf ein Badge, um es zu ändern.",
"types_title": "📋 Verfügbare Typen",
"reset_btn": "↺ Standard wiederherstellen"
},
"appliances": {
"title": "🔌 Verfügbare Geräte",
@@ -626,12 +653,24 @@
"security": {
"title": "🔒 HTTPS-Zertifikat",
"hint": "Wenn der Browser den Fehler \"Verbindung nicht sicher\" (ERR_CERT_AUTHORITY_INVALID) zeigt, installiere das CA-Zertifikat auf dem Gerät.",
"download_btn": "📥 CA-Zertifikat herunterladen"
"download_btn": "📥 CA-Zertifikat herunterladen",
"token_title": "🔑 Einstellungs-Token",
"token_label": "Zugriffstoken",
"token_hint": "Falls `SETTINGS_TOKEN` in der Server-`.env` konfiguriert ist, gib hier den Token ein, bevor du die Einstellungen speicherst. Leer lassen, wenn nicht konfiguriert.",
"token_placeholder": "(leer = kein Schutz)",
"token_required_hint": "🔒 Dieser Server benötigt einen Token zum Speichern der Einstellungen.",
"cert_instructions": "<strong>Anleitung für Chrome (Android):</strong><br>1. Zertifikat oben herunterladen<br>2. Gehe zu <em>Einstellungen &rarr; Sicherheit &amp; Datenschutz &rarr; Weitere Sicherheitseinstellungen &rarr; Vom Gerätespeicher installieren</em><br>3. Wähle die heruntergeladene <em>EverShelf_CA.crt</em> Datei<br>4. Wähle \"CA\" und bestätige<br>5. Chrome neu starten<br><br><strong>Anleitung für Chrome (PC):</strong><br>1. Zertifikat oben herunterladen<br>2. Gehe zu <em>chrome://settings/certificates</em> (oder Einstellungen &rarr; Datenschutz und Sicherheit &rarr; Sicherheit &rarr; Zertifikate verwalten)<br>3. Tab \"Zertifizierungsstellen\" &rarr; Importieren &rarr; Datei auswählen<br>4. Häkchen bei \"Dieser Zertifizierungsstelle für die Identifikation von Webseiten vertrauen\"<br>5. Chrome neu starten"
},
"tts": {
"title": "🔊 Sprache & TTS",
"hint": "Sprachsynthese über externe REST-API konfigurieren. Rezeptschritte und abgelaufene Timer werden an den Endpunkt gesendet.",
"enabled": "✅ TTS aktivieren",
"engine_label": "⚙️ TTS-Engine",
"engine_browser": "🔇 Browser (offline, keine Konfiguration erforderlich)",
"engine_server": "🌐 Externer Server (Home Assistant, REST API...)",
"voice_label": "🗣️ Stimme",
"rate_label": "⚡ Geschwindigkeit",
"pitch_label": "🎵 Tonhöhe",
"url_label": "🌐 Endpunkt-URL",
"method_label": "📡 HTTP-Methode",
"auth_label": "🔐 Authentifizierung",
@@ -647,7 +686,14 @@
"extra_fields_label": " Zusätzliche Felder (JSON)",
"extra_fields_placeholder": "{\"entity_id\": \"media_player.living_room\"}",
"extra_fields_hint": "Zusätzliche Felder im Payload, im JSON-Format. Leer lassen wenn nicht benötigt.",
"test_btn": "🔊 Testansage senden"
"test_btn": "🔊 Testansage senden",
"voices_loading": "Stimmen werden geladen…",
"voice_not_supported": "Stimme vom Browser nicht unterstützt",
"voices_none": "Keine Stimmen auf diesem Gerät verfügbar",
"voices_hint": "Verfügbare Stimmen hängen vom Betriebssystem und Browser ab. Auf macOS/iOS ist die Stimme Paola (Italienisch) verfügbar. Drücken Sie ↺ wenn die Liste nicht lädt.",
"url_missing": "⚠️ Endpunkt-URL fehlt.",
"test_sending": "⏳ Wird gesendet…",
"test_ok": "✅ Antwort {code} — prüfe ob der Lautsprecher gesprochen hat."
},
"language": {
"title": "🌐 Sprache",
@@ -658,7 +704,15 @@
"screensaver": {
"label": "Bildschirmschoner aktivieren",
"card_title": "🌙 Bildschirmschoner",
"card_hint": "Zeigt nach 5 Minuten Inaktivität eine Uhr mit nützlichen Fakten. Standardmäßig deaktiviert."
"card_hint": "Zeigt nach 5 Minuten Inaktivität eine Uhr mit nützlichen Fakten. Standardmäßig deaktiviert.",
"timeout_1": "1 Minute",
"timeout_2": "2 Minuten",
"timeout_5": "5 Minuten",
"timeout_10": "10 Minuten",
"timeout_15": "15 Minuten",
"timeout_30": "30 Minuten",
"timeout_60": "1 Stunde",
"start_after": "⏱️ Starten nach"
},
"scale": {
"title": "⚖️ Smart-Waage",
@@ -671,16 +725,84 @@
"test_btn": "🔗 Verbindung testen",
"download_btn": "📥 Android-Gateway herunterladen (APK)",
"download_hint": "Android-App als Brücke zwischen BLE-Waage und EverShelf.",
"download_sub": "Quellcode: evershelf-scale-gateway/ im Projektstamm"
"download_sub": "Quellcode: evershelf-scale-gateway/ im Projektstamm",
"live_weight": "Echtzeit-Gewicht",
"auto_reconnect": "🔁 Verbindung: automatisch",
"kiosk_title": "📡 BLE-Waage im Kiosk integriert",
"kiosk_hint": "Die Waage wird direkt vom internen BLE-Gateway des Kiosks verwaltet. Um ein neues Gerät zu koppeln, verwende den Konfigurationsassistenten.",
"kiosk_reconfigure": "🔄 BLE-Waage neu konfigurieren",
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Unterstützte BLE-Protokolle:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) &mdash; Gewicht, Fett, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generisch &mdash; automatische Heuristik für 100+ Modelle</li></ul>"
},
"kiosk": {
"hint": "Verwandeln Sie ein Android-Tablet in ein EverShelf-Panel mit integriertem BLE-Waagen-Gateway.",
"download_btn": "📥 EverShelf Kiosk herunterladen (APK)",
"download_sub": "Vollbild-Kioskmodus + integriertes Waagen-Gateway. Quellcode: evershelf-kiosk/"
"download_sub": "Vollbild-Kioskmodus + integriertes Waagen-Gateway. Quellcode: evershelf-kiosk/",
"native_title": "Kiosk-Konfiguration",
"native_hint": "Server-URL, BLE-Waage, Bildschirmschoner und Einrichtungsassistent.",
"native_btn": "Kiosk-Konfiguration öffnen",
"native_tap_hint": "Zahnrad oben rechts antippen",
"native_update_hint": "Kiosk-App aktualisieren, um diese Funktion zu nutzen",
"update_title": "Kiosk-Aktualisierung",
"check_updates_btn": "🔍 Nach Updates suchen",
"needs_update": "⚠️ Das installierte Kiosk unterstützt diese Funktion nicht. Aktualisiere die Kiosk-App, um sie zu aktivieren."
},
"saved": "✅ Konfiguration gespeichert!",
"saved_local": "✅ Konfiguration lokal gespeichert",
"saved_local_error": "⚠️ Lokal gespeichert, Serverfehler: {error}"
"saved_local_error": "⚠️ Lokal gespeichert, Serverfehler: {error}",
"theme": {
"title": "🌙 Erscheinungsbild",
"hint": "Wähle das Interface-Design.",
"label": "🌙 Design",
"off": "☀️ Hell",
"on": "🌙 Dunkel",
"auto": "🔄 Automatisch (Tageszeit)"
},
"zerowaste": {
"card_title": "♻️ Zero-Waste-Tipps",
"card_hint": "Zeige während des Kochens Tipps zur Wiederverwendung von Abfällen (Schalen, Kochwasser usw.). Standardmäßig deaktiviert.",
"label": "Tipps beim Kochen anzeigen"
},
"info": {
"tab": "Info",
"ai_title": "Gemini AI — Token-Nutzung",
"ai_hint": "Monatlicher Verbrauch und geschätzte Kosten für den aktuellen API-Schlüssel.",
"loading": "Laden…",
"total_tokens": "Token gesamt",
"est_cost": "Gesch. Kosten",
"input_tok": "Eingabe-Token",
"output_tok": "Ausgabe-Token",
"ai_calls": "Aufrufe",
"by_action": "Aufschlüsselung nach Funktion",
"by_model": "Aufschlüsselung nach Modell",
"pricing_note": "Gemini Referenzpreise: 2.5-flash $0.15/M in · $0.60/M out — 2.0-flash $0.10/M in · $0.40/M out.",
"system_title": "System",
"db_size": "Datenbank",
"log_size": "Protokolle",
"log_level": "Log-Level",
"ai_overview": "KI-Nutzungsübersicht, Inventar und Systemstatus",
"calls_unit": "Aufrufe",
"inv_title": "Inventar",
"inv_active": "Aktiv",
"inv_products": "Produkte gesamt",
"inv_expiring": "Ablaufend (7T)",
"inv_expired": "Abgelaufen",
"inv_finished": "Leer",
"act_title": "Monatliche Aktivität",
"act_tx_month": "Bewegungen",
"act_restock": "Einkäufe",
"act_use": "Verbrauch",
"act_new_products": "Neue Produkte",
"act_tx_year": "Jährl. Bewegungen",
"price_cache": "Preiscache",
"cache_entries": "Produkte",
"last_backup": "Letztes Backup",
"bring_days": "Token läuft in {n} Tagen ab",
"bring_expired": "Token abgelaufen",
"year_label": "Jahr {year}",
"currency_title": "Währung",
"currency_hint": "Die Währung, die für alle Kosten und Preise in der App verwendet wird."
},
"tab_general": "Allgemein"
},
"expiry": {
"today": "HEUTE",
@@ -717,7 +839,8 @@
"opened_suffix": "— Zu lange geöffnet!",
"opened_suffix_ok": "— Geöffnet (noch ok)",
"opened_suffix_warning": "— Geöffnet (erst prüfen)",
"days_compact": "{n}T"
"days_compact": "{n}T",
"badge_check_soon": "Bald prüfen"
},
"status": {
"ok": "OK",
@@ -751,6 +874,7 @@
"thrown_away": "🗑️ {name} weggeworfen!",
"thrown_away_partial": "🗑️ {qty} {unit} von {name} weggeworfen",
"finished_all": "📤 {name} aufgebraucht!",
"vacuum_sealed": "{name} als vakuumversiegelt gespeichert",
"product_finished_confirmed": "✅ Entfernt — wieder hinzufügen, wenn du nachkaufst",
"appliance_added": "Gerät hinzugefügt",
"item_added": "{name} hinzugefügt"
@@ -809,7 +933,10 @@
"select_items": "Wähle mindestens ein Produkt aus",
"server_offline": "Serververbindung unterbrochen",
"server_restored": "Serververbindung wiederhergestellt",
"server_retry": "Erneut versuchen"
"server_retry": "Erneut versuchen",
"unknown": "Unbekannter Fehler",
"prefix": "Fehler",
"no_inventory_entry": "Kein Inventareintrag gefunden"
},
"confirm": {
"remove_item": "Möchtest du dieses Produkt wirklich aus dem Bestand entfernen?",
@@ -825,7 +952,9 @@
"edit": {
"title": "{name} bearbeiten",
"unknown_hint": "Produktname und Informationen eingeben",
"label_name": "🏷️ Produktname"
"label_name": "🏷️ Produktname",
"choose_location_title": "Welchen Ort?",
"choose_location_hint": "Wähle den zu bearbeitenden Ort:"
},
"screensaver": {
"recipe_btn": "Rezepte",
@@ -922,7 +1051,8 @@
"thing_rest": "den Rest",
"stay_btn": "Nein, bleibt in {location}",
"moved_toast": "📦 Offene Packung bewegt nach {location}",
"vacuum_restore": "🫙 Vakuum wiederherstellen"
"vacuum_restore": "Vakuum wiederherstellen",
"vacuum_seal_rest": "Rest vakuumieren"
},
"nova": {
"1": "Unverarbeitet",
@@ -1090,11 +1220,65 @@
"title": "Über",
"version": "Version",
"report_bug": "Fehler melden",
"report_bug_hint": "Etwas funktioniert nicht? Öffne ein Issue auf GitHub.",
"report_bug_hint": "Etwas funktioniert nicht? Sende uns direkt aus der App eine Meldung.",
"report_bug_modal_title": "Fehler melden",
"report_type_bug": "Fehler",
"report_type_feature": "Funktion",
"report_type_question": "Frage",
"report_field_title": "Titel",
"report_field_title_ph": "Kurze Beschreibung des Problems",
"report_field_desc": "Beschreibung",
"report_field_desc_ph": "Problem detailliert beschreiben…",
"report_field_steps": "Schritte zum Reproduzieren (optional)",
"report_field_steps_ph": "1. Gehe zu…\n2. Tippe auf…\n3. Fehler erscheint…",
"report_auto_info": "Automatisch beigefügt: Version {version}, Sprache {lang}.",
"report_send_btn": "Bericht senden",
"report_bug_sending": "Wird gesendet…",
"report_bug_sent": "Bericht gesendet — danke!",
"report_bug_error": "Bericht konnte nicht gesendet werden. Verbindung prüfen.",
"changelog": "Changelog",
"github": "GitHub-Repository"
},
"export": {
"title": "Inventar exportieren",
"hint": "Lade das aktuelle Inventar als CSV herunter oder öffne die druckfertige Version (PDF).",
"btn_csv": "CSV herunterladen",
"btn_pdf": "PDF / Drucken",
"btn_title": "Exportieren"
},
"startup": {
"connecting": "Serververbindung wird hergestellt...",
"check_php_memory": "PHP-Speicher",
"check_php_timeout": "PHP-Timeout",
"check_php_upload": "PHP-Upload",
"check_data_dir": "Datenverzeichnis",
"check_rate_limits": "Rate-Limits-Verzeichnis",
"check_backups": "Backup-Verzeichnis",
"check_write_test": "Schreibtest",
"check_disk_space": "Speicherplatz",
"check_db_legacy": "Legacy-DB (dispensa.db)",
"check_db_connect": "Datenbankverbindung",
"check_db_tables": "Datenbanktabellen",
"check_db_integrity": "Datenbankintegrität",
"check_db_wal": "WAL-Modus",
"check_db_size": "Datenbankgröße",
"check_db_rows": "Inventardaten",
"check_env": ".env-Datei",
"check_gemini": "Gemini-AI-Schlüssel",
"check_bring_creds": "Bring!-Anmeldedaten",
"check_bring_token": "Bring!-Token",
"check_tts": "Text-to-Speech-URL",
"check_scale": "Waagen-Gateway",
"check_curl_ssl": "cURL-SSL",
"check_internet": "Internetverbindung",
"fresh_install": "Neuinstallation",
"warnings_found": "Warnungen",
"all_ok": "System OK",
"critical_error_short": "Kritischer Fehler",
"critical_error": "Kritischer Fehler: Die App kann nicht gestartet werden. Prüfe die Serverlogs.",
"critical_error_intro": "Die App kann aufgrund folgender Probleme nicht gestartet werden:",
"error_network": "Server nicht erreichbar.",
"error_network_detail": "Der Browser kann den PHP-Server nicht erreichen.\n\nMögliche Ursachen:\n• Apache/PHP-Server läuft nicht\n• Netzwerk- oder Firewall-Problem\n• Falsche App-URL\n\nBitte Server starten und erneut versuchen.",
"retry": "Erneut versuchen"
}
}
+208 -24
View File
@@ -9,7 +9,8 @@
"inventory": "Pantry",
"recipes": "Recipes",
"shopping": "Shopping",
"log": "Log"
"log": "Log",
"settings": "Settings"
},
"btn": {
"back": "← Back",
@@ -19,6 +20,8 @@
"add": "✅ Add",
"delete": "Delete",
"edit": "✏️ Edit",
"use": "Use",
"edit_item": "Edit",
"search": "🔍 Search",
"go": "✅ Go",
"toggle_password": "👁️ Show/Hide",
@@ -28,7 +31,12 @@
"restart": "↺ Restart",
"reset_default": "↺ Reset to default",
"save_info": "💾 Save information",
"retry": "🔄 Retry"
"retry": "🔄 Retry",
"yes_short": "Yes",
"no_short": "No"
},
"form": {
"select_placeholder": "-- Select --"
},
"locations": {
"dispensa": "Pantry",
@@ -63,7 +71,9 @@
"pieces": "Pieces",
"grams": "Grams",
"box": "Package",
"boxes": "Packages"
"boxes": "Packages",
"millilitres": "Millilitres",
"from": "of"
},
"shopping_sections": {
"frutta_verdura": "Fruits & Vegetables",
@@ -103,6 +113,8 @@
"banner_expired_action_finished": "I finished it!",
"banner_expired_action_throw": "I threw it away",
"banner_expired_action_edit": "Fix date",
"banner_expired_action_modify": "Edit",
"banner_expired_action_vacuum": "Put in vacuum seal",
"banner_anomaly_action_edit": "Fix inventory",
"banner_anomaly_action_dismiss": "Quantity is correct",
"banner_no_expiry_title": "Missing expiry: {name}",
@@ -207,7 +219,7 @@
"title": "What do you want to do?",
"add_btn": "📥 ADD",
"add_sub": "to pantry/fridge",
"use_btn": "📤 USE / CONSUME",
"use_btn": "USE",
"use_sub": "from pantry/fridge",
"have_title": "📦 Already in stock!",
"add_more_sub": "add more",
@@ -215,7 +227,7 @@
"throw_btn": "🗑️ DISCARD",
"throw_sub": "throw away",
"edit_sub": "expiry, location…",
"create_recipe_btn": "Create a recipe with this"
"create_recipe_btn": "Recipe"
},
"add": {
"title": "Add to Pantry",
@@ -240,7 +252,9 @@
"scan_expiry_title": "📷 Scan Expiry Date",
"product_added": "✅ {name} added!{qty}",
"suffix_freezer_vacuum": "(freezer + vacuum sealed)",
"history_badge_tip": "Average from {n} previous entries"
"history_badge_tip": "Average from {n} previous entries",
"vacuum_question": "Vacuum sealed?",
"vacuum_saved": "🔒 Vacuum sealed!"
},
"use": {
"title": "Use / Consume",
@@ -312,7 +326,13 @@
"edit_info": "✏️ Edit information",
"modify_details": "EDIT\nexpiry, location…",
"already_in_pantry": "📋 Already in pantry",
"no_barcode": "No barcode"
"no_barcode": "No barcode",
"unknown_product": "Unrecognized product",
"edit_name_brand": "Edit name/brand",
"weight_label": "Weight",
"origin_label": "Origin",
"labels_label": "Labels",
"select_variant": "Select the exact variant or use AI data:"
},
"products": {
"title": "📦 All Products",
@@ -341,6 +361,7 @@
"regenerate": "🔄 Generate another one",
"close_btn": "✅ Close",
"ingredients_title": "🧾 Ingredients",
"tools_title": "Equipment needed",
"steps_title": "👨‍🍳 Steps",
"no_steps": "No steps available",
"generate_error": "Generation error",
@@ -481,7 +502,8 @@
"undo_success": "↩ Operation undone for {name}",
"already_undone": "Operation already undone",
"too_old": "Cannot undo operations older than 24 hours",
"undo_error": "Error during undo"
"undo_error": "Error during undo",
"recipe_prefix": "Recipe"
},
"chat": {
"title": "Gemini Chef",
@@ -501,7 +523,8 @@
"transfer_to_recipes": "Transfer to Recipes",
"transferring": "Transferring...",
"transferred": "Added to Recipes!",
"open_recipe": "Open recipe"
"open_recipe": "Open recipe",
"quick_recipe_prompt": "Suggest a quick recipe FOR ONE PERSON using the products that expire first! Ignore freezer items, focus on fridge and pantry."
},
"cooking": {
"close": "Close",
@@ -512,13 +535,16 @@
"prev": "◀ Previous",
"next": "Next ▶",
"ingredient_used": "✔️ Deducted",
"ingredient_use_btn": "📦 Use",
"ingredient_use_btn": "Use",
"ingredient_deduct_title": "Deduct from pantry",
"timer_expired_tts": "Timer {label} expired!",
"timer_warning_tts": "Heads up! {label}: 10 seconds left!",
"recipe_done_tts": "Recipe complete! Enjoy your meal!",
"expires_chip": "exp. {date}",
"finish": "✅ Finish"
"finish": "✅ Finish",
"step_fallback": "Step {n}",
"zerowaste_label": "♻️ Scrap",
"zerowaste_tip_title": "Zero-waste tip"
},
"settings": {
"title": "⚙️ Settings",
@@ -571,8 +597,9 @@
"title": "📅 Weekly Meal Plan",
"hint": "Set the meal type for each day. It will be used as a guide in recipe generation.",
"enabled": "✅ Enable weekly meal plan",
"legend": "🌤️ = Lunch · 🌙 = Dinner · Tap a badge to change it.",
"types_title": "📋 Available types"
"legend": "🌤️ = Lunch &nbsp;·&nbsp; 🌙 = Dinner &nbsp;·&nbsp; Tap a badge to change it.",
"types_title": "📋 Available types",
"reset_btn": "↺ Restore defaults"
},
"appliances": {
"title": "🔌 Available Appliances",
@@ -626,12 +653,24 @@
"security": {
"title": "🔒 HTTPS Certificate",
"hint": "If the browser shows the error \"Your connection is not private\" (ERR_CERT_AUTHORITY_INVALID), you need to install the CA certificate on the device.",
"download_btn": "📥 Download CA Certificate"
"download_btn": "📥 Download CA Certificate",
"token_title": "🔑 Settings Token",
"token_label": "Access token",
"token_hint": "If `SETTINGS_TOKEN` is configured in the server's `.env`, enter the token here before saving settings. Leave empty if not configured.",
"token_placeholder": "(empty = no protection)",
"token_required_hint": "🔒 This server requires a token to save settings.",
"cert_instructions": "<strong>Instructions for Chrome (Android):</strong><br>1. Download the certificate above<br>2. Go to <em>Settings &rarr; Security &amp; Privacy &rarr; More security settings &rarr; Install from device storage</em><br>3. Select the downloaded <em>EverShelf_CA.crt</em> file<br>4. Choose \"CA\" and confirm<br>5. Restart Chrome<br><br><strong>Instructions for Chrome (PC):</strong><br>1. Download the certificate above<br>2. Go to <em>chrome://settings/certificates</em> (or Settings &rarr; Privacy and security &rarr; Security &rarr; Manage certificates)<br>3. Tab \"Authorities\" &rarr; Import &rarr; select the file<br>4. Check \"Trust this certificate for identifying websites\"<br>5. Restart Chrome"
},
"tts": {
"title": "🔊 Voice & TTS",
"hint": "Configure text-to-speech via any external REST API. Recipe steps and expired timers will be sent to the configured endpoint.",
"enabled": "✅ Enable TTS",
"engine_label": "⚙️ TTS Engine",
"engine_browser": "🔇 Browser (offline, no configuration required)",
"engine_server": "🌐 External server (Home Assistant, REST API...)",
"voice_label": "🗣️ Voice",
"rate_label": "⚡ Speed",
"pitch_label": "🎵 Pitch",
"url_label": "🌐 Endpoint URL",
"method_label": "📡 HTTP Method",
"auth_label": "🔐 Authentication",
@@ -647,7 +686,14 @@
"extra_fields_label": " Extra fields (JSON)",
"extra_fields_placeholder": "{\"entity_id\": \"media_player.living_room\"}",
"extra_fields_hint": "Additional fields to include in the payload, in JSON format. Leave empty if not needed.",
"test_btn": "🔊 Send Test Voice"
"test_btn": "🔊 Send Test Voice",
"voices_loading": "Loading voices…",
"voice_not_supported": "Voice not supported by this browser",
"voices_none": "No voices available on this device",
"voices_hint": "Available voices depend on the OS and browser. On macOS/iOS the Paola (Italian) voice is available. Press ↺ if the list does not load.",
"url_missing": "⚠️ Endpoint URL missing.",
"test_sending": "⏳ Sending…",
"test_ok": "✅ Response {code} — check that the speaker has spoken."
},
"language": {
"title": "🌐 Language",
@@ -658,7 +704,15 @@
"screensaver": {
"label": "Enable screensaver",
"card_title": "🌙 Screensaver",
"card_hint": "Shows a clock with useful facts after 5 minutes of inactivity. Disabled by default."
"card_hint": "Shows a clock with useful facts after 5 minutes of inactivity. Disabled by default.",
"timeout_1": "1 minute",
"timeout_2": "2 minutes",
"timeout_5": "5 minutes",
"timeout_10": "10 minutes",
"timeout_15": "15 minutes",
"timeout_30": "30 minutes",
"timeout_60": "1 hour",
"start_after": "⏱️ Start after"
},
"scale": {
"title": "⚖️ Smart Scale",
@@ -671,16 +725,84 @@
"test_btn": "🔗 Test connection",
"download_btn": "📥 Download Android Gateway (APK)",
"download_hint": "Android app that bridges your BLE scale and EverShelf.",
"download_sub": "Source: evershelf-scale-gateway/ in the project root"
"download_sub": "Source: evershelf-scale-gateway/ in the project root",
"live_weight": "real-time weight",
"auto_reconnect": "🔁 Reconnect: automatic",
"kiosk_title": "📡 BLE Scale integrated in Kiosk",
"kiosk_hint": "The scale is directly managed by the internal BLE Gateway of the kiosk. To pair a new device, use the configuration wizard.",
"kiosk_reconfigure": "🔄 Reconfigure BLE Scale",
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Supported BLE protocols:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) &mdash; weight, fat, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generic &mdash; automatic heuristic for 100+ models</li></ul>"
},
"kiosk": {
"hint": "Turn an Android tablet into an always-on EverShelf panel with built-in BLE scale gateway.",
"download_btn": "📥 Download EverShelf Kiosk (APK)",
"download_sub": "Full-screen kiosk mode + integrated scale gateway. Source: evershelf-kiosk/"
"download_sub": "Full-screen kiosk mode + integrated scale gateway. Source: evershelf-kiosk/",
"native_title": "Kiosk Configuration",
"native_hint": "Server URL, BLE scale, screensaver and setup wizard.",
"native_btn": "Open kiosk configuration",
"native_tap_hint": "Tap the gear button at the top right",
"native_update_hint": "Update the kiosk app to use this feature",
"update_title": "Kiosk Update",
"check_updates_btn": "🔍 Check for updates",
"needs_update": "⚠️ The installed kiosk does not support this feature. Update the kiosk app to enable it."
},
"saved": "✅ Configuration saved!",
"saved_local": "✅ Configuration saved locally",
"saved_local_error": "⚠️ Saved locally, server error: {error}"
"saved_local_error": "⚠️ Saved locally, server error: {error}",
"theme": {
"title": "🌙 Appearance",
"hint": "Choose the interface theme.",
"label": "🌙 Theme",
"off": "☀️ Light",
"on": "🌙 Dark",
"auto": "🔄 Automatic (time of day)"
},
"zerowaste": {
"card_title": "♻️ Zero-waste tips",
"card_hint": "During cooking, show tips on how to reuse scraps generated in each step (peels, cooking water, etc.). Disabled by default.",
"label": "Show tips during cooking"
},
"info": {
"tab": "Info",
"ai_title": "Gemini AI — Token Usage",
"ai_hint": "Monthly consumption and estimated cost for the current API key.",
"loading": "Loading…",
"total_tokens": "Total tokens",
"est_cost": "Est. cost",
"input_tok": "Input tokens",
"output_tok": "Output tokens",
"ai_calls": "Calls",
"by_action": "Breakdown by function",
"by_model": "Breakdown by model",
"pricing_note": "Gemini reference pricing: 2.5-flash $0.15/M in · $0.60/M out — 2.0-flash $0.10/M in · $0.40/M out.",
"system_title": "System",
"db_size": "Database",
"log_size": "Logs",
"log_level": "Log level",
"ai_overview": "AI usage overview, inventory and system status",
"calls_unit": "calls",
"inv_title": "Inventory",
"inv_active": "Active",
"inv_products": "Total products",
"inv_expiring": "Expiring (7d)",
"inv_expired": "Expired",
"inv_finished": "Finished",
"act_title": "Monthly activity",
"act_tx_month": "Movements",
"act_restock": "Restocks",
"act_use": "Usages",
"act_new_products": "New products",
"act_tx_year": "Yearly movements",
"price_cache": "Price cache",
"cache_entries": "products",
"last_backup": "Last backup",
"bring_days": "token expires in {n} days",
"bring_expired": "token expired",
"year_label": "Year {year}",
"currency_title": "Currency",
"currency_hint": "The currency used for all costs and prices in the app."
},
"tab_general": "General"
},
"expiry": {
"today": "TODAY",
@@ -717,7 +839,8 @@
"opened_suffix": "— Opened too long!",
"opened_suffix_ok": "— Opened (still ok)",
"opened_suffix_warning": "— Opened (check first)",
"days_compact": "{n}d"
"days_compact": "{n}d",
"badge_check_soon": "Check soon"
},
"status": {
"ok": "OK",
@@ -751,6 +874,7 @@
"thrown_away": "🗑️ {name} thrown away!",
"thrown_away_partial": "🗑️ Thrown away {qty} {unit} of {name}",
"finished_all": "📤 {name} finished!",
"vacuum_sealed": "{name} saved as vacuum sealed",
"product_finished_confirmed": "✅ Removed — add it again when you restock",
"appliance_added": "Appliance added",
"item_added": "{name} added"
@@ -809,7 +933,10 @@
"select_items": "Select at least one product",
"server_offline": "Server connection lost",
"server_restored": "Server connection restored",
"server_retry": "Retry"
"server_retry": "Retry",
"unknown": "Unknown error",
"prefix": "Error",
"no_inventory_entry": "No inventory entry found"
},
"confirm": {
"remove_item": "Do you really want to remove this product from inventory?",
@@ -825,7 +952,9 @@
"edit": {
"title": "Edit {name}",
"unknown_hint": "Enter the product name and information",
"label_name": "🏷️ Product name"
"label_name": "🏷️ Product name",
"choose_location_title": "Which location?",
"choose_location_hint": "Choose the location to edit:"
},
"screensaver": {
"recipe_btn": "Recipes",
@@ -922,7 +1051,8 @@
"thing_rest": "rest",
"stay_btn": "No, stay in {location}",
"moved_toast": "📦 Opened package moved to {location}",
"vacuum_restore": "🫙 Restore vacuum sealed"
"vacuum_restore": "Restore vacuum sealed",
"vacuum_seal_rest": "Vacuum seal the rest"
},
"nova": {
"1": "Unprocessed",
@@ -1090,11 +1220,65 @@
"title": "About",
"version": "Version",
"report_bug": "Report a Bug",
"report_bug_hint": "Something not working? Open an issue on GitHub.",
"report_bug_hint": "Something not working? Send us a report directly from the app.",
"report_bug_modal_title": "Report a Bug",
"report_type_bug": "Bug",
"report_type_feature": "Feature",
"report_type_question": "Question",
"report_field_title": "Title",
"report_field_title_ph": "Brief description of the issue",
"report_field_desc": "Description",
"report_field_desc_ph": "Describe the issue in detail…",
"report_field_steps": "Steps to reproduce (optional)",
"report_field_steps_ph": "1. Go to…\n2. Tap…\n3. See the error…",
"report_auto_info": "Automatically attached: version {version}, language {lang}.",
"report_send_btn": "Send report",
"report_bug_sending": "Sending…",
"report_bug_sent": "Report sent — thank you!",
"report_bug_error": "Could not send the report. Check your connection.",
"changelog": "Changelog",
"github": "GitHub Repository"
},
"export": {
"title": "Export inventory",
"hint": "Download the current inventory as CSV or open a print-ready version (PDF).",
"btn_csv": "Download CSV",
"btn_pdf": "PDF / Print",
"btn_title": "Export"
},
"startup": {
"connecting": "Connecting to server...",
"check_php_memory": "PHP memory",
"check_php_timeout": "PHP timeout",
"check_php_upload": "PHP upload",
"check_data_dir": "Data directory",
"check_rate_limits": "Rate limits dir",
"check_backups": "Backup dir",
"check_write_test": "Disk write test",
"check_disk_space": "Disk space",
"check_db_legacy": "Legacy DB (dispensa.db)",
"check_db_connect": "Database connection",
"check_db_tables": "Database tables",
"check_db_integrity": "Database integrity",
"check_db_wal": "WAL mode",
"check_db_size": "Database size",
"check_db_rows": "Inventory data",
"check_env": ".env file",
"check_gemini": "Gemini AI key",
"check_bring_creds": "Bring! credentials",
"check_bring_token": "Bring! token",
"check_tts": "Text-to-Speech URL",
"check_scale": "Scale gateway",
"check_curl_ssl": "cURL SSL",
"check_internet": "Internet connection",
"fresh_install": "fresh install",
"warnings_found": "warnings found",
"all_ok": "System OK",
"critical_error_short": "Critical error",
"critical_error": "Critical error: the app cannot start. Check your server logs.",
"critical_error_intro": "The app cannot start due to the following issues:",
"error_network": "Cannot reach the server.",
"error_network_detail": "The browser cannot reach the PHP server.\n\nPossible causes:\n• Apache/PHP server is not running\n• Network or firewall issue\n• Incorrect app URL\n\nMake sure the server is started and try again.",
"retry": "Retry"
}
}
+1235
View File
File diff suppressed because it is too large Load Diff
+1235
View File
File diff suppressed because it is too large Load Diff
+208 -24
View File
@@ -9,7 +9,8 @@
"inventory": "Dispensa",
"recipes": "Ricette",
"shopping": "Spesa",
"log": "Storico"
"log": "Storico",
"settings": "Config"
},
"btn": {
"back": "← Indietro",
@@ -19,6 +20,8 @@
"add": "✅ Aggiungi",
"delete": "Elimina",
"edit": "✏️ Modifica",
"use": "Usa",
"edit_item": "Modifica",
"search": "🔍 Cerca",
"go": "✅ Vai",
"toggle_password": "👁️ Mostra/Nascondi",
@@ -28,7 +31,12 @@
"restart": "↺ Ricomincia",
"reset_default": "↺ Ripristina default",
"save_info": "💾 Salva informazioni",
"retry": "🔄 Riprova"
"retry": "🔄 Riprova",
"yes_short": "Sì",
"no_short": "No"
},
"form": {
"select_placeholder": "-- Seleziona --"
},
"locations": {
"dispensa": "Dispensa",
@@ -63,7 +71,9 @@
"pieces": "Pezzi",
"grams": "Grammi",
"box": "Confezione",
"boxes": "Confezioni"
"boxes": "Confezioni",
"millilitres": "Millilitri",
"from": "da"
},
"shopping_sections": {
"frutta_verdura": "Frutta & Verdura",
@@ -103,6 +113,8 @@
"banner_expired_action_finished": "L'ho finito!",
"banner_expired_action_throw": "L'ho buttato",
"banner_expired_action_edit": "Correggi data",
"banner_expired_action_modify": "Modifica",
"banner_expired_action_vacuum": "Metti sottovuoto",
"banner_anomaly_action_edit": "Correggi inventario",
"banner_anomaly_action_dismiss": "La quantità è giusta",
"banner_no_expiry_title": "Scadenza mancante: {name}",
@@ -207,7 +219,7 @@
"title": "Cosa vuoi fare?",
"add_btn": "📥 AGGIUNGI",
"add_sub": "in dispensa/frigo",
"use_btn": "📤 USA / CONSUMA",
"use_btn": "USA",
"use_sub": "dalla dispensa/frigo",
"have_title": "📦 Ce l'hai già!",
"add_more_sub": "altra quantità",
@@ -215,7 +227,7 @@
"throw_btn": "🗑️ BUTTA",
"throw_sub": "butta il prodotto",
"edit_sub": "scadenza, luogo…",
"create_recipe_btn": "Crea una ricetta con questo"
"create_recipe_btn": "Ricetta"
},
"add": {
"title": "Aggiungi alla Dispensa",
@@ -240,7 +252,9 @@
"scan_expiry_title": "📷 Scansiona Data Scadenza",
"product_added": "✅ {name} aggiunto!{qty}",
"suffix_freezer_vacuum": "(freezer + sotto vuoto)",
"history_badge_tip": "Media da {n} inserimenti precedenti"
"history_badge_tip": "Media da {n} inserimenti precedenti",
"vacuum_question": "Messo sotto vuoto?",
"vacuum_saved": "🔒 Sotto vuoto registrato"
},
"use": {
"title": "Usa / Consuma",
@@ -312,7 +326,13 @@
"edit_info": "✏️ Modifica informazioni",
"modify_details": "MODIFICA\nscadenza, luogo…",
"already_in_pantry": "📋 Già in dispensa",
"no_barcode": "Senza barcode"
"no_barcode": "Senza barcode",
"unknown_product": "Prodotto non riconosciuto",
"edit_name_brand": "Modifica nome/marca",
"weight_label": "Peso",
"origin_label": "Origine",
"labels_label": "Etichette",
"select_variant": "Seleziona la variante esatta o usa i dati AI:"
},
"products": {
"title": "📦 Tutti i Prodotti",
@@ -341,6 +361,7 @@
"regenerate": "🔄 Generane un'altra",
"close_btn": "✅ Chiudi",
"ingredients_title": "🧾 Ingredienti",
"tools_title": "Strumenti necessari",
"steps_title": "👨‍🍳 Procedimento",
"no_steps": "Nessun procedimento disponibile",
"generate_error": "Errore nella generazione",
@@ -481,7 +502,8 @@
"undo_success": "↩ Operazione annullata per {name}",
"already_undone": "Operazione già annullata",
"too_old": "Non è possibile annullare operazioni più vecchie di 24 ore",
"undo_error": "Errore durante l'annullamento"
"undo_error": "Errore durante l'annullamento",
"recipe_prefix": "Ricetta"
},
"chat": {
"title": "Gemini Chef",
@@ -501,7 +523,8 @@
"transfer_to_recipes": "Trasferisci a Ricette",
"transferring": "Trasferimento in corso...",
"transferred": "Aggiunta alle Ricette!",
"open_recipe": "Apri la ricetta"
"open_recipe": "Apri la ricetta",
"quick_recipe_prompt": "Suggeriscimi una ricetta veloce PER UNA PERSONA usando i prodotti che scadono prima! Ignora i prodotti in freezer, concentrati su frigo e dispensa."
},
"cooking": {
"close": "Chiudi",
@@ -512,13 +535,16 @@
"prev": "◀ Precedente",
"next": "Successivo ▶",
"ingredient_used": "✔️ Scalato",
"ingredient_use_btn": "📦 Usa",
"ingredient_use_btn": "Usa",
"ingredient_deduct_title": "Scala dalla dispensa",
"timer_expired_tts": "Timer {label} scaduto!",
"timer_warning_tts": "Attenzione! {label}: mancano 10 secondi!",
"recipe_done_tts": "Ricetta completata! Buon appetito!",
"expires_chip": "scade {date}",
"finish": "✅ Fine"
"finish": "✅ Fine",
"step_fallback": "Passo {n}",
"zerowaste_label": "♻️ Scarto",
"zerowaste_tip_title": "Consiglio anti-spreco"
},
"settings": {
"title": "⚙️ Configurazione",
@@ -571,8 +597,9 @@
"title": "📅 Piano Pasti Settimanale",
"hint": "Imposta la tipologia di pasto per ogni giorno. Sarà usata come guida nella generazione delle ricette.",
"enabled": "✅ Attiva piano pasti settimanale",
"legend": "🌤️ = Pranzo · 🌙 = Cena · Tocca un badge per cambiarlo.",
"types_title": "📋 Tipologie disponibili"
"legend": "🌤️ = Pranzo &nbsp;·&nbsp; 🌙 = Cena &nbsp;·&nbsp; Tocca un badge per cambiarlo.",
"types_title": "📋 Tipologie disponibili",
"reset_btn": "↺ Ripristina default"
},
"appliances": {
"title": "🔌 Elettrodomestici Disponibili",
@@ -626,12 +653,24 @@
"security": {
"title": "🔒 Certificato HTTPS",
"hint": "Se il browser mostra l'errore \"La connessione non è privata\" (ERR_CERT_AUTHORITY_INVALID), devi installare il certificato CA nel dispositivo.",
"download_btn": "📥 Scarica Certificato CA"
"download_btn": "📥 Scarica Certificato CA",
"token_title": "🔑 Token Impostazioni",
"token_label": "Token di accesso",
"token_hint": "Se `SETTINGS_TOKEN` è configurato nel `.env` server, inserisci qui il token prima di salvare le impostazioni. Lascia vuoto se non configurato.",
"token_placeholder": "(vuoto = nessuna protezione)",
"token_required_hint": "🔒 Questo server richiede un token per salvare le impostazioni.",
"cert_instructions": "<strong>Istruzioni per Chrome (Android):</strong><br>1. Scarica il certificato qui sopra<br>2. Vai in <em>Impostazioni &rarr; Sicurezza e privacy &rarr; Altre impostazioni di sicurezza &rarr; Installa da archivio dispositivo</em><br>3. Seleziona il file <em>EverShelf_CA.crt</em> scaricato<br>4. Scegli \"CA\" e conferma<br>5. Riavvia Chrome<br><br><strong>Istruzioni per Chrome (PC):</strong><br>1. Scarica il certificato qui sopra<br>2. Vai in <em>chrome://settings/certificates</em> (o Impostazioni &rarr; Privacy e sicurezza &rarr; Sicurezza &rarr; Gestisci certificati)<br>3. Tab \"Autorità\" &rarr; Importa &rarr; seleziona il file<br>4. Spunta \"Considera attendibile per identificare siti web\"<br>5. Riavvia Chrome"
},
"tts": {
"title": "🔊 Voce & TTS",
"hint": "Configura la sintesi vocale tramite qualsiasi API REST esterna. I passi della ricetta e i timer scaduti verranno inviati all'endpoint configurato.",
"enabled": "✅ Attiva TTS",
"engine_label": "⚙️ Motore TTS",
"engine_browser": "🔇 Browser (offline, nessuna configurazione)",
"engine_server": "🌐 Server esterno (Home Assistant, API REST...)",
"voice_label": "🗣️ Voce",
"rate_label": "⚡ Velocità",
"pitch_label": "🎵 Tono",
"url_label": "🌐 URL Endpoint",
"method_label": "📡 Metodo HTTP",
"auth_label": "🔐 Autenticazione",
@@ -647,7 +686,14 @@
"extra_fields_label": " Campi extra (JSON)",
"extra_fields_placeholder": "{\"entity_id\": \"media_player.living_room\"}",
"extra_fields_hint": "Campi aggiuntivi da includere nel payload, in formato JSON. Lascia vuoto se non necessario.",
"test_btn": "🔊 Invia Test Vocale"
"test_btn": "🔊 Invia Test Vocale",
"voices_loading": "Caricamento voci…",
"voice_not_supported": "Voce non supportata dal browser",
"voices_none": "Nessuna voce disponibile su questo dispositivo",
"voices_hint": "Le voci disponibili dipendono dal sistema operativo e dal browser. Su macOS/iOS è disponibile la voce Paola (italiano). Premi ↺ se la lista non si carica.",
"url_missing": "⚠️ URL endpoint mancante.",
"test_sending": "⏳ Invio in corso…",
"test_ok": "✅ Risposta {code} — controlla che l'altoparlante abbia parlato."
},
"language": {
"title": "🌐 Lingua / Language",
@@ -658,7 +704,15 @@
"screensaver": {
"label": "Attiva salvaschermo",
"card_title": "🌙 Salvaschermo",
"card_hint": "Mostra un orologio con fatti utili dopo 5 minuti di inattività. Di default è disattivato."
"card_hint": "Mostra un orologio con fatti utili dopo 5 minuti di inattività. Di default è disattivato.",
"timeout_1": "1 minuto",
"timeout_2": "2 minuti",
"timeout_5": "5 minuti",
"timeout_10": "10 minuti",
"timeout_15": "15 minuti",
"timeout_30": "30 minuti",
"timeout_60": "1 ora",
"start_after": "⏱️ Avvia dopo"
},
"scale": {
"title": "⚖️ Bilancia Smart",
@@ -671,16 +725,84 @@
"test_btn": "🔗 Testa connessione",
"download_btn": "📥 Scarica Gateway Android (APK)",
"download_hint": "App Android che fa da ponte tra la bilancia BLE e questo sito.",
"download_sub": "Sorgente: evershelf-scale-gateway/ nella root del progetto"
"download_sub": "Sorgente: evershelf-scale-gateway/ nella root del progetto",
"live_weight": "peso in tempo reale",
"auto_reconnect": "🔁 Riconnessione: automatica",
"kiosk_title": "📡 Bilancia BLE integrata nel Kiosk",
"kiosk_hint": "La bilancia è gestita direttamente dal Gateway BLE interno al kiosk. Per abbinare un nuovo dispositivo usa il wizard di configurazione.",
"kiosk_reconfigure": "🔄 Riconfigura bilancia BLE",
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Protocolli BLE supportati:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) &mdash; peso, grasso, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generico &mdash; heuristica automatica su 100+ modelli</li></ul>"
},
"kiosk": {
"hint": "Trasforma un tablet Android in un pannello EverShelf sempre acceso, con bilancia BLE integrata.",
"download_btn": "📥 Scarica EverShelf Kiosk (APK)",
"download_sub": "Modalità kiosk full-screen + gateway bilancia integrato. Sorgente: evershelf-kiosk/"
"download_sub": "Modalità kiosk full-screen + gateway bilancia integrato. Sorgente: evershelf-kiosk/",
"native_title": "Configurazione Kiosk",
"native_hint": "URL server, bilancia BLE, salvaschermo e setup wizard.",
"native_btn": "Apri configurazione kiosk",
"native_tap_hint": "Tocca la rotella in alto a destra",
"native_update_hint": "Aggiorna l'app kiosk per usare questa funzione",
"update_title": "Aggiornamento Kiosk",
"check_updates_btn": "🔍 Cerca aggiornamenti",
"needs_update": "⚠️ Il kiosk installato non supporta questa funzione. Aggiorna l'app kiosk per abilitarla."
},
"saved": "✅ Configurazione salvata!",
"saved_local": "✅ Configurazione salvata localmente",
"saved_local_error": "⚠️ Salvato localmente, errore server: {error}"
"saved_local_error": "⚠️ Salvato localmente, errore server: {error}",
"theme": {
"title": "🌙 Tema / Aspetto",
"hint": "Scegli il tema dell interfaccia.",
"label": "🌙 Tema",
"off": "☀️ Chiaro",
"on": "🌙 Scuro",
"auto": "🔄 Automatico (orario)"
},
"zerowaste": {
"card_title": "♻️ Suggerimenti zero-waste",
"card_hint": "Durante la cottura, mostra consigli su come riutilizzare gli scarti prodotti in ogni passo (bucce, acqua di cottura, ecc.). Disattivo per impostazione predefinita.",
"label": "Mostra suggerimenti durante la cottura"
},
"info": {
"tab": "Info",
"ai_title": "Gemini AI — Utilizzo Token",
"ai_hint": "Consumo mensile e costo stimato per la chiave API corrente.",
"loading": "Caricamento…",
"total_tokens": "Token totali",
"est_cost": "Costo stimato",
"input_tok": "Token input",
"output_tok": "Token output",
"ai_calls": "Chiamate",
"by_action": "Dettaglio per funzione",
"by_model": "Dettaglio per modello",
"pricing_note": "Prezzi di riferimento Gemini: 2.5-flash $0.15/M in · $0.60/M out — 2.0-flash $0.10/M in · $0.40/M out.",
"system_title": "Sistema",
"db_size": "Database",
"log_size": "Log",
"log_level": "Livello log",
"ai_overview": "Prospetto utilizzo AI, inventario e stato del sistema",
"calls_unit": "call",
"inv_title": "Inventario",
"inv_active": "Attivi",
"inv_products": "Prodotti totali",
"inv_expiring": "In scadenza (7gg)",
"inv_expired": "Scaduti",
"inv_finished": "Finiti",
"act_title": "Attività del mese",
"act_tx_month": "Movimenti",
"act_restock": "Acquisti",
"act_use": "Consumi",
"act_new_products": "Nuovi prodotti",
"act_tx_year": "Movimenti anno",
"price_cache": "Cache prezzi",
"cache_entries": "prodotti",
"last_backup": "Ultimo backup",
"bring_days": "token scade tra {n} giorni",
"bring_expired": "token scaduto",
"year_label": "Anno {year}",
"currency_title": "Valuta",
"currency_hint": "La valuta usata per tutti i costi e i prezzi nell'app."
},
"tab_general": "Generali"
},
"expiry": {
"today": "OGGI",
@@ -717,7 +839,8 @@
"opened_suffix": "— Aperto da troppo tempo!",
"opened_suffix_ok": "— Aperto (ancora ok)",
"opened_suffix_warning": "— Aperto (controlla)",
"days_compact": "{n}gg"
"days_compact": "{n}gg",
"badge_check_soon": "Controlla presto"
},
"status": {
"ok": "OK",
@@ -751,6 +874,7 @@
"thrown_away": "🗑️ {name} buttato!",
"thrown_away_partial": "🗑️ Buttato {qty} {unit} di {name}",
"finished_all": "📤 {name} terminato!",
"vacuum_sealed": "{name} salvato come sottovuoto",
"product_finished_confirmed": "✅ Rimosso — riaggiungi quando ne ricompri",
"appliance_added": "Elettrodomestico aggiunto",
"item_added": "{name} aggiunto"
@@ -809,7 +933,10 @@
"select_items": "Seleziona almeno un prodotto",
"server_offline": "Connessione al server persa",
"server_restored": "Connessione al server ripristinata",
"server_retry": "Riprova"
"server_retry": "Riprova",
"unknown": "Errore sconosciuto",
"prefix": "Errore",
"no_inventory_entry": "Nessuna voce di inventario trovata"
},
"confirm": {
"remove_item": "Vuoi davvero rimuovere questo prodotto dall'inventario?",
@@ -825,7 +952,9 @@
"edit": {
"title": "Modifica {name}",
"unknown_hint": "Inserisci il nome e le informazioni del prodotto",
"label_name": "🏷️ Nome prodotto"
"label_name": "🏷️ Nome prodotto",
"choose_location_title": "Quale modifica?",
"choose_location_hint": "Scegli la posizione da modificare:"
},
"screensaver": {
"recipe_btn": "Ricette",
@@ -922,7 +1051,8 @@
"thing_rest": "il resto",
"stay_btn": "No, resta in {location}",
"moved_toast": "📦 Confezione aperta spostata in {location}",
"vacuum_restore": "🫙 Torna sotto vuoto"
"vacuum_restore": "Torna sotto vuoto",
"vacuum_seal_rest": "Metti sotto vuoto il resto"
},
"nova": {
"1": "Non trasformato",
@@ -1090,11 +1220,65 @@
"title": "Informazioni",
"version": "Versione",
"report_bug": "Segnala un problema",
"report_bug_hint": "Qualcosa non funziona? Apri una segnalazione su GitHub.",
"report_bug_hint": "Qualcosa non funziona? Inviaci una segnalazione direttamente dall'app.",
"report_bug_modal_title": "Segnala un problema",
"report_type_bug": "Bug",
"report_type_feature": "Funzionalità",
"report_type_question": "Domanda",
"report_field_title": "Titolo",
"report_field_title_ph": "Breve descrizione del problema",
"report_field_desc": "Descrizione",
"report_field_desc_ph": "Descrivi il problema in dettaglio…",
"report_field_steps": "Passi per riprodurlo (opzionale)",
"report_field_steps_ph": "1. Vai su…\n2. Tocca…\n3. Vedi l'errore…",
"report_auto_info": "Saranno allegati automaticamente: versione {version}, lingua {lang}.",
"report_send_btn": "Invia segnalazione",
"report_bug_sending": "Invio in corso…",
"report_bug_sent": "Segnalazione inviata — grazie!",
"report_bug_error": "Impossibile inviare la segnalazione. Controlla la connessione.",
"changelog": "Changelog",
"github": "Repository GitHub"
},
"export": {
"title": "Esporta inventario",
"hint": "Scarica l inventario corrente in CSV o apri la versione stampabile (PDF).",
"btn_csv": "Scarica CSV",
"btn_pdf": "PDF / Stampa",
"btn_title": "Esporta"
},
"startup": {
"connecting": "Connessione al server...",
"check_php_memory": "Memoria PHP",
"check_php_timeout": "Timeout PHP",
"check_php_upload": "Upload PHP",
"check_data_dir": "Cartella dati",
"check_rate_limits": "Dir rate limits",
"check_backups": "Dir backup",
"check_write_test": "Test scrittura disco",
"check_disk_space": "Spazio disco",
"check_db_legacy": "DB legacy (dispensa.db)",
"check_db_connect": "Connessione database",
"check_db_tables": "Tabelle database",
"check_db_integrity": "Integrità database",
"check_db_wal": "WAL mode",
"check_db_size": "Dimensione database",
"check_db_rows": "Dati inventario",
"check_env": "File .env",
"check_gemini": "Chiave Gemini AI",
"check_bring_creds": "Credenziali Bring!",
"check_bring_token": "Token Bring!",
"check_tts": "URL Text-to-Speech",
"check_scale": "Gateway bilancia",
"check_curl_ssl": "cURL SSL",
"check_internet": "Connessione internet",
"fresh_install": "nuovo impianto",
"warnings_found": "avvisi rilevati",
"all_ok": "Sistema OK",
"critical_error_short": "Errore critico",
"critical_error": "Errore critico: l'app non può avviarsi. Controlla i log del server.",
"critical_error_intro": "L'app non può avviarsi a causa dei seguenti problemi:",
"error_network": "Impossibile contattare il server.",
"error_network_detail": "Il browser non riesce a raggiungere il server PHP.\n\nPossibili cause:\n• Il server Apache/PHP non è in esecuzione\n• Problema di rete o firewall\n• URL dell'app non corretta\n\nControlla che il server sia avviato e riprova.",
"retry": "Riprova"
}
}