Compare commits

..

29 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
23 changed files with 2395 additions and 388 deletions
+1
View File
@@ -51,3 +51,4 @@ data/latest_release_cache.json
data/food_facts_cache.json
data/category_ai_cache.json
assets/img/logo/*_backup.*
logs/*.log
+13
View File
@@ -11,6 +11,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Recipe scraps tips** — During cooking steps, detect "waste" generated (peels, cores, bones, eggshells, coffee grounds, citrus zest, etc.) and surface AI-powered tips on how to reuse them (compost, natural cleaner, broth, candied peel, etc.). Could be shown as an optional collapsible hint card below the step that generates the scrap.
## [1.7.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
+34 -7
View File
@@ -38,10 +38,11 @@
## ✨ Features
> **New in v1.7.19Zero-waste cooking tips**
> During cooking, EverShelf shows a contextual ♻️ tip card for each step that generates reusable scraps — peels, cooking water, egg whites, cheese rinds, bread crusts and more.
> Tips are generated by Gemini *as part of the recipe* at zero extra API cost, shown inline in cooking mode, and dismissible per step.
> Enable the toggle in **Settings → Zero-waste tips** (default: off).
> **New in v1.7.23Global 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
- **Export inventory** — Download the full inventory as a UTF-8 CSV (Excel-compatible) or open a print-ready page to save as PDF; export button always visible in the inventory page header
@@ -50,7 +51,7 @@
- **Smart locations** — Track items across Pantry, Fridge, Freezer, and custom locations
- **Expiry tracking** — Automatic shelf-life estimation based on product type and storage
- **Opened product tracking** — Reduced shelf-life calculation when packages are opened; opened-product expiry is now also checked when building banner alerts (not just the dashboard section)
- **Vacuum-sealed support** — Extended expiry dates for vacuum-sealed items
- **Vacuum-sealed support** — Extended expiry dates for vacuum-sealed items; products sealed under vacuum are only flagged as expired after a configurable grace period past the printed date (`VACUUM_EXPIRY_EXTENSION_DAYS`, default 30 days, configurable in `.env`)
- **Anomaly detection** — Banner alerts for suspicious quantities and consumption predictions with inline correction; dismiss button now shows the current inventory quantity so the action is unambiguous ("Quantity is correct (2 pcs)")
### 🤖 AI-Powered (Google Gemini)
@@ -98,9 +99,15 @@
- **Quick-access buttons** — Recently used and most popular products shown on the inventory page for fast access
### 🌙 Appearance
- **Dark mode** — Three modes: Light, Dark, and Auto (follows the OS/browser setting); theme is applied before the first render to prevent a white flash on dark-mode systems; toggle in Settings → Appearance
- **Dark mode** — Three modes: Light, Dark, and Auto (time-based: dark from 20:00 to 07:00, light otherwise); applies immediately without page reload; auto mode re-evaluates every 5 minutes, so night/day transitions happen automatically even on always-on kiosk displays; theme is applied before the first render to prevent a white flash
- **Global settings tab** — A dedicated **⚙️ Generali** tab groups all system-wide settings (language, currency, theme, screensaver, zero-waste tips, export) at the top of the Settings panel
### 📱 Progressive Web App
### Database Maintenance
- **Automatic cleanup** — Recipes older than `RECIPE_RETENTION_DAYS` (default 7) and transactions older than `TRANSACTION_RETENTION_DAYS` (default 7) are deleted automatically on every cron cycle; SQLite `VACUUM` runs after each cleanup to keep the file compact
- **Manual cleanup** — Trigger immediately via `GET /api/?action=db_cleanup`
- **Compact by default** — Fresh installs stay small; large accumulated databases shrink back to a few hundred KB within one cron cycle
### 📱 Progressive Web App
- **Mobile-first design** — Optimized for phones, works on tablets and desktop
- **Installable** — Add to home screen for a native app experience
- **Multi-device** — All user data (shopping tags, pinned items, location preferences, scan history) is stored server-side in SQLite and shared across every device on the same instance; no data is siloed in a single browser's localStorage
@@ -192,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
+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";
+10 -3
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");
@@ -379,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;
@@ -449,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;
+533 -86
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;
}
}
+67 -34
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,75 +87,91 @@ 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: 160px;
height: 150px;
width: auto;
object-fit: contain;
filter: drop-shadow(0 4px 16px rgba(74,222,128,0.2));
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.35);
font-size: 0.72rem;
color: rgba(255,255,255,0.22);
font-size: 0.68rem;
font-family: monospace;
letter-spacing: 0.5px;
margin-top: -8px;
letter-spacing: 0.6px;
margin-top: -16px;
}
/* ── Startup progress bar ───────────────────────────────────────────── */
/* ── Startup progress section ────────────────────────────────────────── */
.preloader-progress-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
width: 250px;
max-width: 88vw;
animation: zwFadeIn 0.2s ease;
gap: 14px;
width: min(92vw, 520px);
animation: zwFadeIn 0.25s ease;
}
.preloader-bar-track {
width: 100%;
height: 6px;
background: rgba(255,255,255,0.12);
height: 3px;
background: rgba(255,255,255,0.08);
border-radius: 99px;
overflow: hidden;
overflow: visible;
}
.preloader-bar {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #4ade80, #22c55e);
border-radius: 99px;
transition: width 0.18s ease, background 0.3s ease;
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); }
.preloader-bar.bar-warn { background: linear-gradient(90deg, #fbbf24, #f59e0b); }
.preloader-check-label {
color: rgba(255,255,255,0.60);
font-size: 0.74rem;
font-family: monospace;
text-align: center;
.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%;
min-height: 1.1em;
letter-spacing: 0.01em;
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: 310px;
max-width: min(92vw, 600px);
width: 100%;
animation: zwFadeIn 0.3s ease;
}
@@ -4175,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;
@@ -7019,6 +7044,14 @@ 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 {
+285 -25
View File
@@ -1052,7 +1052,8 @@ if (!_SUPPORTED_LANGS[_currentLang]) _currentLang = 'en';
try {
const s = JSON.parse(localStorage.getItem('evershelf_settings') || '{}');
const mode = s.dark_mode || 'auto';
const dark = mode === 'on' || (mode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches);
const h = new Date().getHours();
const dark = mode === 'on' || (mode === 'auto' && (h >= 20 || h < 7));
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
} catch(e) {}
})();
@@ -1176,7 +1177,9 @@ function _applyTheme() {
} else if (mode === 'off') {
isDark = false;
} else {
isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
// auto: dark from 20:00 to 07:00 (time-based, not system preference)
const h = new Date().getHours();
isDark = h >= 20 || h < 7;
}
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
}
@@ -1189,10 +1192,10 @@ function _setThemeMode(mode) {
}
// Listen to system theme changes (for 'auto' mode)
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
const s = getSettings();
if ((s.dark_mode || 'auto') === 'auto') _applyTheme();
});
// Re-evaluate auto theme every 5 minutes (catches 20:00 dark / 07:00 light transitions)
setInterval(() => {
if ((getSettings().dark_mode || 'auto') === 'auto') _applyTheme();
}, 5 * 60 * 1000);
// ===== EXPORT INVENTORY =====
function exportInventory(format) {
@@ -1226,7 +1229,8 @@ function _showExportModal() {
🖨 ${t('export.btn_pdf')}
</button>
</div>`;
openModal(html);
document.getElementById('modal-content').innerHTML = html;
document.getElementById('modal-overlay').style.display = 'flex';
}
const LOCATIONS = {
@@ -1601,7 +1605,7 @@ function estimateExpiryDays(product, location) {
else if (/yogurt/.test(name)) days = 21;
else if (/mozzarella|burrata|stracciatella/.test(name)) days = 5;
else if (/formaggio\s+(fresco|ricotta|mascarpone|stracchino|crescenza)/.test(name)) days = 10;
else if (/parmigiano|grana|pecorino|provolone/.test(name)) days = 60;
else if (/parmigiano|grana|pecorino|provolone|asiago|fontina|emmental|gruyere|scamorza|groviera/.test(name)) days = 60;
else if (/burro/.test(name)) days = 60;
else if (/panna/.test(name)) days = 14;
else if (/prosciutto\s+cotto|mortadella|wurstel/.test(name)) days = 7;
@@ -1740,8 +1744,10 @@ function estimateOpenedExpiryDays(product, location) {
if (/\b(yogurt|yaourt|yoghurt)\b/.test(name)) return 5;
if (/mozzarella|burrata|stracciatella/.test(name)) return 3;
if (/philadelphia|spalmabile/.test(name)) 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 (/parmigiano|grana|pecorino|provolone|asiago|fontina|emmental|gruyere|scamorza|groviera/.test(name)) return 28;
if (/formaggio.*(fresco|ricotta|mascarpone|stracchino|crescenza)/.test(name)) return 5;
if (/parmigiano|grana|pecorino|provolone/.test(name)) return 21;
if (/formaggio/.test(name)) return 10;
if (/\bburro\b/.test(name)) return 30;
if (/\bpanna\b/.test(name)) return 4;
@@ -2200,6 +2206,175 @@ function _applySyncedSettings(serverSettings) {
}
}
let _infoTabTimer = null;
/**
* Load the Info tab: Gemini token usage + cost, log size, DB size, log level.
* Called on tab click; auto-refreshes every 30s while the tab is open.
*/
async function _loadInfoTab() {
// Cancel any previous auto-refresh
if (_infoTabTimer) { clearInterval(_infoTabTimer); _infoTabTimer = null; }
await _renderInfoTab();
// Auto-refresh every 30s while Info tab is visible
_infoTabTimer = setInterval(_renderInfoTab, 30_000);
}
async function _renderInfoTab() {
const aiEl = document.getElementById('info-ai-content');
const sysEl = document.getElementById('info-system-content');
if (!aiEl && !sysEl) return;
try {
const d = await api('gemini_usage');
const s = getSettings();
// ── Locale & helpers ─────────────────────────────────────────────────
const langMap = {it:'it-IT', en:'en-US', de:'de-DE', fr:'fr-FR', es:'es-ES'};
const locale = langMap[s.language] || langMap[navigator.language?.slice(0,2)] || 'it-IT';
const [yr, mo] = (d.month || '').split('-');
const monthLabel = new Intl.DateTimeFormat(locale, {month:'long', year:'numeric'})
.format(new Date(parseInt(yr), parseInt(mo)-1, 1));
// Cost → user currency
const toCurr = (usd) => {
if (!usd) return '—';
const c = s.price_currency || 'EUR';
let v = usd, sym = '$';
if (c === 'EUR') { v = usd * 0.92; sym = '€'; }
else if (c === 'GBP') { v = usd * 0.79; sym = '£'; }
else if (c === 'CHF') { v = usd * 0.90; sym = 'CHF '; }
else if (c === 'CAD') { v = usd * 1.36; sym = 'CA$'; }
else if (c === 'AUD') { v = usd * 1.54; sym = 'A$'; }
else if (c === 'BRL') { v = usd * 5.20; sym = 'R$'; }
else if (c === 'JPY') { v = usd * 155; sym = '¥'; }
else if (c === 'SEK') { v = usd * 10.4; sym = 'kr'; }
else if (c === 'NOK') { v = usd * 10.6; sym = 'kr'; }
else if (c === 'DKK') { v = usd * 6.85; sym = 'kr'; }
else if (c === 'PLN') { v = usd * 3.98; sym = 'zł'; }
const decimals = (c === 'JPY') ? 1 : 4;
return sym + v.toFixed(decimals);
};
const fmtTok = n => n >= 1_000_000 ? (n/1_000_000).toFixed(2)+'M'
: n >= 1_000 ? Math.round(n/1_000)+'K' : String(n||0);
const fmtBytes = b => b > 1048576 ? (b/1048576).toFixed(1)+' MB'
: b > 1024 ? Math.round(b/1024)+' KB' : (b||0)+' B';
const fmtDate = ts => ts ? new Intl.DateTimeFormat(locale, {day:'2-digit', month:'short', hour:'2-digit', minute:'2-digit'}).format(new Date(ts*1000)) : '—';
const pill = (val, label, color='') =>
`<div style="background:var(--bg-secondary);border:1px solid var(--border-color,#e2e8f0);border-radius:10px;padding:8px 14px;min-width:70px;text-align:center${color ? ';border-color:'+color : ''}">
<div style="font-size:1.1rem;font-weight:700;color:${color||'var(--text-primary,#1e293b)'}">${val}</div>
<div style="font-size:0.7rem;color:var(--text-secondary,#64748b);margin-top:2px">${label}</div>
</div>`;
const sectionHeader = (label) =>
`<div style="font-size:0.78rem;font-weight:600;color:var(--text-secondary);margin-bottom:8px;text-transform:uppercase;letter-spacing:.04em">${label}</div>`;
// ── AI Usage card ────────────────────────────────────────────────────
if (aiEl) {
const ms = d.month_stats || {};
const ys = d.year_stats || {};
const hintEl = aiEl.closest('.settings-card')?.querySelector('.info-ai-subtitle');
if (hintEl) hintEl.textContent = t('settings.info.ai_overview');
const msIn = ms.input_tokens || 0;
const msOut = ms.output_tokens || 0;
const ysIn = ys.input_tokens || 0;
const ysOut = ys.output_tokens || 0;
// Month section
const actionRows = Object.entries(ms.by_action || {})
.sort((a,b) => b[1]-a[1]).slice(0, 8)
.map(([k,v]) => `<tr><td style="padding:3px 12px 3px 0;color:var(--text-secondary);font-size:0.82rem">${k}</td><td style="font-variant-numeric:tabular-nums;font-size:0.82rem"><strong>${v}</strong> ${t('settings.info.calls_unit')}</td></tr>`).join('');
const modelRows = Object.entries(ms.by_model || {})
.map(([m,mv]) => `<tr><td style="padding:3px 12px 3px 0;color:var(--text-secondary);font-size:0.82rem">${m}</td><td style="font-variant-numeric:tabular-nums;font-size:0.82rem"><strong>${fmtTok((mv.in||0)+(mv.out||0))}</strong></td></tr>`).join('');
const monthHtml = `
<div style="background:var(--bg-secondary);border-radius:10px;padding:12px;margin-bottom:10px">
${sectionHeader(monthLabel)}
<div style="display:flex;gap:8px;flex-wrap:wrap">
${pill(ms.calls || 0, t('settings.info.ai_calls'))}
${pill('~'+fmtTok(msIn+msOut), t('settings.info.total_tokens'))}
${pill('~'+toCurr(ms.cost_usd), t('settings.info.est_cost'), '#15803d')}
</div>
${actionRows ? `<details style="margin-top:8px"><summary style="font-size:0.82rem;cursor:pointer;color:var(--text-secondary)">${t('settings.info.by_action')}</summary><table style="margin-top:6px;border-collapse:collapse">${actionRows}</table></details>` : ''}
${modelRows ? `<details style="margin-top:4px"><summary style="font-size:0.82rem;cursor:pointer;color:var(--text-secondary)">${t('settings.info.by_model')}</summary><table style="margin-top:6px;border-collapse:collapse">${modelRows}</table></details>` : ''}
</div>`;
// Year section
const yearHtml = `
<div style="background:var(--bg-secondary);border-radius:10px;padding:12px;margin-bottom:10px">
${sectionHeader(t('settings.info.year_label').replace('{year}', d.year))}
<div style="display:flex;gap:8px;flex-wrap:wrap">
${pill('~'+(ys.calls || 0), t('settings.info.ai_calls'))}
${pill('~'+fmtTok(ysIn+ysOut), t('settings.info.total_tokens'))}
${pill('~'+toCurr(ys.cost_usd), t('settings.info.est_cost'), '#15803d')}
</div>
</div>`;
aiEl.innerHTML = monthHtml + yearHtml
+ `<p class="settings-hint" style="margin-top:4px">${t('settings.info.pricing_note')}</p>`;
}
// ── Inventory card ───────────────────────────────────────────────────
const invEl = document.getElementById('info-inv-content');
if (invEl && d.db) {
const db = d.db;
invEl.innerHTML = `
<div style="display:flex;gap:8px;flex-wrap:wrap">
${pill(db.inventory_active, t('settings.info.inv_active'))}
${pill(db.products_total, t('settings.info.inv_products'))}
${pill(db.expiring_soon, t('settings.info.inv_expiring'), db.expiring_soon > 0 ? '#d97706' : '')}
${pill(db.expired, t('settings.info.inv_expired'), db.expired > 0 ? '#dc2626' : '')}
${pill(db.finished, t('settings.info.inv_finished'))}
</div>`;
}
// ── Activity card ────────────────────────────────────────────────────
const actEl = document.getElementById('info-act-content');
if (actEl && d.db) {
const db = d.db;
actEl.innerHTML = `
<div style="display:flex;gap:8px;flex-wrap:wrap">
${pill(db.tx_month, t('settings.info.act_tx_month'))}
${pill(db.restock_month, t('settings.info.act_restock'))}
${pill(db.use_month, t('settings.info.act_use'))}
${pill(db.products_month, t('settings.info.act_new_products'))}
${pill(db.tx_year, t('settings.info.act_tx_year'))}
</div>`;
}
// ── System card ──────────────────────────────────────────────────────
if (sysEl) {
const db = d.db || {};
const lvlColors = {DEBUG:'#1e40af//#dbeafe', INFO:'#15803d//#dcfce7', WARN:'#854d0e//#fef9c3', ERROR:'#991b1b//#fee2e2'};
const [lvlFg, lvlBg] = (lvlColors[d.log_level] || '#64748b//#f1f5f9').split('//');
sysEl.innerHTML = `
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px">
${pill(fmtBytes(db.bytes), t('settings.info.db_size'))}
${pill(fmtBytes(d.log_bytes), t('settings.info.log_size'))}
${pill(`<span style="background:${lvlBg};color:${lvlFg};padding:2px 6px;border-radius:5px;font-size:0.78rem">${d.log_level||'INFO'}</span>`, t('settings.info.log_level'))}
</div>
<table style="border-collapse:collapse;width:100%;font-size:0.85rem">
<tr style="border-top:1px solid var(--border-color,#e2e8f0)">
<td style="padding:7px 0;color:var(--text-secondary)">${t('settings.info.price_cache')}</td>
<td style="padding:7px 0;font-weight:600;text-align:right">${(d.caches?.price||0)} ${t('settings.info.cache_entries')}</td>
</tr>
<tr style="border-top:1px solid var(--border-color,#e2e8f0)">
<td style="padding:7px 0;color:var(--text-secondary)">${t('settings.info.last_backup')}</td>
<td style="padding:7px 0;font-weight:600;text-align:right">${d.last_backup_ts ? fmtDate(d.last_backup_ts)+' · '+fmtBytes(d.last_backup_bytes) : '—'}</td>
</tr>
</table>`;
}
} catch(e) {
['info-ai-content','info-inv-content','info-act-content','info-system-content'].forEach(id => {
const el = document.getElementById(id);
if (el) el.innerHTML = `<p class="settings-hint">${t('error.generic')}</p>`;
});
}
}
/**
* Populate the About section with the current app version from the server.
*/
@@ -2941,6 +3116,8 @@ async function saveSettings() {
scale_gateway_url: s.scale_gateway_url,
meal_plan_enabled: s.meal_plan_enabled,
screensaver_enabled: s.screensaver_enabled,
screensaver_timeout: s.screensaver_timeout || 5,
zerowaste_tips_enabled: s.zerowaste_tips_enabled,
tts_enabled: s.tts_enabled,
tts_url: s.tts_url,
tts_token: s.tts_token,
@@ -2958,6 +3135,9 @@ async function saveSettings() {
price_country: s.price_country,
price_currency: s.price_currency,
price_update_months: s.price_update_months,
recipe_retention_days: s.recipe_retention_days || 7,
transaction_retention_days: s.transaction_retention_days || 7,
vacuum_expiry_extension_days: s.vacuum_expiry_extension_days || 30,
}, tokenHeader);
const statusEl = document.getElementById('settings-status');
if (result.success) {
@@ -2999,6 +3179,11 @@ async function saveSettings() {
}
function switchSettingsTab(btn, tabId) {
// Stop info-tab auto-refresh when leaving that tab
if (tabId !== 'tab-info' && _infoTabTimer) {
clearInterval(_infoTabTimer);
_infoTabTimer = null;
}
document.querySelectorAll('.settings-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.settings-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
@@ -4352,15 +4537,22 @@ function renderBannerItem() {
detailEl.innerHTML = `${baseDetail} <span class="banner-safety-tip banner-safety-${safety.level}">${safety.icon} ${safety.tip}</span>`;
let btns = '';
btns += `<button class="btn-banner btn-banner-finish" onclick="bannerFinishAll()">${t('dashboard.banner_expired_action_finished')}</button>`;
if (safety.level !== 'danger') {
if (!isOpenedExpiry && safety.level !== 'danger') {
btns += `<button class="btn-banner btn-banner-use" onclick="bannerQuickUse()">${t('dashboard.banner_expired_action_use')}</button>`;
}
btns += `<button class="btn-banner btn-banner-throw" onclick="bannerThrowAway()">${t('dashboard.banner_expired_action_throw')}</button>`;
btns += `<button class="btn-banner btn-banner-edit" onclick="editBannerExpiry()">${t('dashboard.banner_expired_action_edit')}</button>`;
if (safety.level === 'danger') {
// "Modifica" — opens full edit modal (includes date correction)
btns += `<button class="btn-banner btn-banner-edit2" onclick="editInventoryItem(${item.id})">${t('dashboard.banner_expired_action_modify')}</button>`;
if (isOpenedExpiry && !item.vacuum_sealed) {
// Offer to re-seal with vacuum — extends shelf life
btns += `<button class="btn-banner btn-banner-vacuum" onclick="bannerMarkVacuum()">${t('dashboard.banner_expired_action_vacuum')}</button>`;
}
if (!isOpenedExpiry && safety.level === 'danger') {
btns += `<button class="btn-banner btn-banner-use btn-banner-use-danger" onclick="bannerQuickUse()">${t('dashboard.banner_expired_action_use')}</button>`;
}
btns += `<button class="btn-banner btn-banner-ok" onclick="dismissBannerExpired()">${t('dashboard.banner_review_dismiss')}</button>`;
if (!isOpenedExpiry) {
btns += `<button class="btn-banner btn-banner-ok" onclick="dismissBannerExpired()">${t('dashboard.banner_review_dismiss')}</button>`;
}
actionsEl.innerHTML = btns;
} else if (entry.type === 'review') {
@@ -4650,6 +4842,43 @@ function bannerThrowAway() {
dismissBannerItem();
}
async function bannerMarkVacuum() {
const entry = _bannerQueue[_bannerIndex];
if (!entry || entry.type !== 'expired') return;
const item = entry.data;
if (item.vacuum_sealed) return; // already sealed
// Calculate new expiry: opened_at + opened_shelf_life_days_with_vacuum
let newExpiry = null;
if (item.opened_at) {
// estimateOpenedExpiryDays returns days without vacuum; add 50% for vacuum sealed
const baseDays = estimateOpenedExpiryDays(
{ name: item.name, category: item.category || '' },
item.location
);
const vacuumDays = Math.round(baseDays * 1.5);
const d = new Date(item.opened_at);
d.setDate(d.getDate() + vacuumDays);
newExpiry = d.toISOString().slice(0, 10);
}
const body = { id: item.id, vacuum_sealed: 1 };
if (newExpiry) body.expiry_date = newExpiry;
try {
const res = await api('inventory_update', {}, 'POST', body);
if (res.success || res.ok) {
showToast(t('toast.vacuum_sealed', { name: item.name }), 'success');
dismissBannerItem();
loadDashboard();
} else {
showToast(res.error || t('error.generic'), 'error');
}
} catch(e) {
showToast(t('error.connection'), 'error');
}
}
function bannerFinishAll() {
const entry = _bannerQueue[_bannerIndex];
if (!entry) return;
@@ -8547,7 +8776,7 @@ function showMoveAfterUseModal(product, fromLoc, remaining, openedId, openedVacu
const vacuumRow = `
<label style="display:flex;align-items:center;gap:8px;margin-top:12px;cursor:pointer">
<input type="checkbox" id="move-vacuum-check" ${wasVacuum ? 'checked' : ''}>
<span>${t('move.vacuum_seal_rest')}${wasVacuum ? ' ' + t('move.was_sealed') : ''}</span>
<span>${wasVacuum ? t('move.vacuum_restore') : t('move.vacuum_seal_rest')}</span>
</label>`;
document.getElementById('modal-content').innerHTML = `
<div class="modal-header">
@@ -12206,7 +12435,7 @@ function showRecipeMoveModal(productId, fromLoc, remaining, openedId, wasVacuum)
<p style="margin-bottom:12px">${t('move.question_short').replace('{thing}', openedId ? t('move.thing_opened') : t('move.thing_rest'))}</p>
<div class="location-selector">${locButtons}</div>
${vacuumRow}
<button type="button" id="btn-move-stay" class="btn btn-secondary full-width move-countdown-btn" style="margin-top:12px" onclick="clearMoveModalTimer();closeModal()">No, resta in ${LOCATIONS[fromLoc]?.label || fromLoc}</button>
<button type="button" id="btn-move-stay" class="btn btn-secondary full-width move-countdown-btn" style="margin-top:12px" onclick="clearMoveModalTimer();closeModal()">${t('move.stay_btn').replace('{location}', LOCATIONS[fromLoc]?.label || fromLoc)}</button>
</div>
`;
document.getElementById('modal-overlay').style.display = 'flex';
@@ -12314,7 +12543,7 @@ function renderRecipe(r) {
const loc = (ing.location || 'dispensa').replace(/'/g, "\\'");
const alreadyUsed = ing.used === true;
html += `<li class="recipe-ingredient${alreadyUsed ? ' recipe-ing-used' : ''}" id="recipe-ing-${idx}">`;
html += `<span class="recipe-ing-text"><strong>${ing.name}</strong>${ing.brand ? ' <em>(' + ing.brand + ')</em>' : ''}: ${ing.qty}`;
html += `<span class="recipe-ing-text"><strong class="recipe-ing-name" onclick="openIngredientDetail(${ing.product_id}, '${loc}')" title="${t('action.edit') || 'Modifica'}">${ing.name}</strong>${ing.brand ? ' <em>(' + ing.brand + ')</em>' : ''}: ${ing.qty}`;
// Detail line: location + expiry
let details = [];
const ingredientLocLabels = Object.fromEntries(Object.entries(LOCATIONS).map(([k,v]) => [k, `${v.icon} ${v.label}`]));
@@ -13692,6 +13921,21 @@ async function chatTransferToRecipes(btn, replyText) {
}
}
async function openIngredientDetail(productId, location) {
try {
const res = await api('inventory_list');
const items = res.inventory || res;
// Find by product_id + location; fallback to any row with that product_id
let item = items.find(i => i.product_id === productId && i.location === location);
if (!item) item = items.find(i => i.product_id === productId);
if (!item) { showToast(t('error.not_found'), 'error'); return; }
currentInventory = items;
editInventoryItem(item.id);
} catch(e) {
showToast(t('error.connection'), 'error');
}
}
async function generateRecipeForIngredient(ingredientName) {
if (!_requireGemini()) return;
document.getElementById('recipe-overlay').style.display = 'flex';
@@ -14897,7 +15141,7 @@ async function _runStartupCheck() {
if (spinnerEl) spinnerEl.style.display = 'none';
wrapEl.style.display = '';
// Helper: set progress bar + label
// Helper: set progress bar + crossfade status text
let _curPct = 0;
const setProgress = (pct, label, state) => {
_curPct = pct;
@@ -14905,14 +15149,31 @@ async function _runStartupCheck() {
barEl.style.width = pct + '%';
barEl.className = 'preloader-bar' + (state === 'error' ? ' bar-error' : state === 'warn' ? ' bar-warn' : '');
}
if (labelEl) labelEl.textContent = label || '';
if (!label) return;
const ticker = document.getElementById('check-ticker');
if (!ticker) return;
const sc = state === 'error' ? 'state-error' : state === 'warn' ? 'state-warn' : 'state-ok';
// Strip emoji from label — colors convey the state
const cleanLabel = label.replace(/[\u{1F000}-\u{1FFFF}\u{2600}-\u{27BF}\u{2700}-\u{27BF}✅❌⚠️🔄]/gu, '').trim().replace(/^[-–—\s]+/, '');
let el = ticker.querySelector('.preloader-status-text');
if (!el) {
el = document.createElement('div');
el.className = 'preloader-status-text';
ticker.appendChild(el);
}
// Direct update — checks fire every 40ms, any fade would hide most labels
el.className = `preloader-status-text ${sc}`;
el.textContent = cleanLabel;
};
// Phase 1: animate 0→15% while fetching (so it never looks stuck)
setProgress(0, tl('connecting', 'Connessione al server...'));
let _fetchDone = false;
const slowAnim = setInterval(() => {
if (!_fetchDone && _curPct < 13) setProgress(_curPct + 1, labelEl?.textContent);
if (!_fetchDone && _curPct < 13) {
_curPct++;
if (barEl) barEl.style.width = _curPct + '%';
}
}, 100);
// Make the request
@@ -14930,7 +15191,7 @@ async function _runStartupCheck() {
tl('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.'),
errorEl, retryBtn
);
setProgress(100, `${tl('error_network', 'Server non raggiungibile')}`, 'error');
setProgress(100, tl('error_network', 'Server non raggiungibile'), 'error');
return false;
}
clearInterval(slowAnim);
@@ -15001,8 +15262,7 @@ async function _runStartupCheck() {
if (!isOk && c.error) lbl += `${c.error}`;
if (!isOk && c.missing?.length) lbl += ` — mancanti: ${c.missing.join(', ')}`;
const icon = isOk ? '' : isOpt ? '⚠️' : '❌';
setProgress(pct, `${icon} ${lbl}`);
setProgress(pct, lbl, isOk ? 'ok' : isOpt ? 'warn' : 'error');
if (!isOk && !isFresh) {
(isOpt ? warnings : errors).push({ def, c });
@@ -15013,7 +15273,7 @@ async function _runStartupCheck() {
// ── Errors → red bar + blocking popup ────────────────────────────────────
if (errors.length > 0) {
setProgress(100, `${tl('critical_error_short', 'Errore critico')}`, 'error');
setProgress(100, tl('critical_error_short', 'Errore critico'), 'error');
await new Promise(r => setTimeout(r, 300));
const errLines = errors.map(e => {
const hint = e.c.hint || (e.c.error ? e.c.error : null);
@@ -15029,7 +15289,7 @@ async function _runStartupCheck() {
// ── Warnings → amber bar + warning popup auto-close 5s ───────────────────
if (warnings.length > 0) {
setProgress(100, `⚠️ ${warnings.length} ${tl('warnings_found', 'avvisi')}`, 'warn');
setProgress(100, `${warnings.length} ${tl('warnings_found', 'avvisi')}`, 'warn');
await new Promise(r => setTimeout(r, 200));
// Build warning popup (auto-close 5s)
@@ -15041,7 +15301,7 @@ async function _runStartupCheck() {
// Hide warning popup
warningsEl.style.display = 'none';
} else {
setProgress(100, `${tl('all_ok', 'Sistema OK')}`);
setProgress(100, tl('all_ok', 'Sistema OK'), 'ok');
await new Promise(r => setTimeout(r, 600));
}
+9
View File
@@ -0,0 +1,9 @@
{
"2026-05": {
"input_tokens": 4438300,
"output_tokens": 1286760,
"calls": 8374,
"by_action": {},
"by_model": {}
}
}
@@ -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
@@ -114,6 +118,11 @@ class SetupActivity : AppCompatActivity() {
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
@@ -134,6 +143,9 @@ class SetupActivity : AppCompatActivity() {
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
@@ -184,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)
}
}
@@ -221,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)
@@ -273,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() }
@@ -375,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 {
@@ -398,14 +425,34 @@ class SetupActivity : AppCompatActivity() {
findViewById<MaterialButton>(R.id.btnScreensaverBack).setOnClickListener { showStep(4) }
findViewById<MaterialButton>(R.id.btnScreensaverNext).setOnClickListener {
prefs.edit()
.putBoolean(KEY_SCREENSAVER, setupSwitchScreensaver.isChecked)
.putBoolean(KEY_PRICE_ENABLED, setupSwitchPrices.isChecked)
.putBoolean(KEY_MEAL_PLAN, setupSwitchMealPlan.isChecked)
.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() }
}
@@ -421,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
@@ -442,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()
@@ -478,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)
@@ -489,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()
@@ -837,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()
@@ -850,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)
}
@@ -863,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
@@ -887,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
@@ -914,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
@@ -936,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())
}
}
@@ -998,19 +1054,23 @@ class SetupActivity : AppCompatActivity() {
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 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))
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()
}
@@ -1026,6 +1086,9 @@ class SetupActivity : AppCompatActivity() {
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")
@@ -1035,6 +1098,9 @@ class SetupActivity : AppCompatActivity() {
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 {
@@ -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>
@@ -1241,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"
@@ -1254,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" />
@@ -1262,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"
@@ -1282,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"
@@ -1291,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"
@@ -1310,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" />
@@ -1331,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,52 +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>
<!-- Features step (step 5) -->
<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_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">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>
<!-- 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: abilitato</string>
<string name="summary_screensaver_off">Screensaver: disabilitato</string>
<string name="summary_prices_on">Prezzi lista spesa: abilitati</string>
<string name="summary_mealplan_on">Piano pasti: abilitato</string>
<string name="summary_zerowaste_on">Suggerimenti zero-waste: abilitati</string>
<string name="summary_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>
+124 -85
View File
@@ -59,12 +59,12 @@
<div class="preloader-bar-track">
<div id="preloader-bar" class="preloader-bar"></div>
</div>
<div id="preloader-check-label" class="preloader-check-label">&nbsp;</div>
<div 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.22</span>
<span class="app-preloader-version" id="preloader-version">v1.7.23</span>
</div>
</div>
@@ -77,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.22</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>
@@ -831,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>
@@ -839,12 +840,108 @@
<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>
@@ -903,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">
@@ -1261,77 +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>
</div>
<div class="form-group" id="screensaver-timeout-row" style="margin-top:10px">
<label for="setting-screensaver-timeout" style="font-size:0.85rem;font-weight:600;color:var(--text-secondary)" data-i18n="settings.screensaver.start_after">⏱️ Avvia dopo</label>
<select id="setting-screensaver-timeout" class="form-control" style="margin-top:6px;max-width:200px">
<option value="1" data-i18n="settings.screensaver.timeout_1">1 minuto</option>
<option value="2" data-i18n="settings.screensaver.timeout_2">2 minuti</option>
<option value="5" selected data-i18n="settings.screensaver.timeout_5">5 minuti</option>
<option value="10" data-i18n="settings.screensaver.timeout_10">10 minuti</option>
<option value="15" data-i18n="settings.screensaver.timeout_15">15 minuti</option>
<option value="30" data-i18n="settings.screensaver.timeout_30">30 minuti</option>
<option value="60" data-i18n="settings.screensaver.timeout_60">1 ora</option>
</select>
<h4 data-i18n="settings.info.inv_title">Inventario</h4>
<div id="info-inv-content" style="margin-top:10px">
<p class="settings-hint" data-i18n="settings.info.loading">Caricamento…</p>
</div>
</div>
<!-- Activity card -->
<div class="settings-card">
<h4 data-i18n="settings.theme.title">🌙 Tema / Aspetto</h4>
<p class="settings-hint" data-i18n="settings.theme.hint">Scegli il tema dell'interfaccia.</p>
<div class="form-group">
<label data-i18n="settings.theme.label">🌙 Tema</label>
<select id="setting-dark-mode" class="form-input" onchange="_setThemeMode(this.value)">
<option value="off" data-i18n="settings.theme.off">☀️ Chiaro</option>
<option value="auto" selected data-i18n="settings.theme.auto">🔄 Automatico (sistema)</option>
<option value="on" data-i18n="settings.theme.on">🌙 Scuro</option>
</select>
<h4 data-i18n="settings.info.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.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>
<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>
@@ -1613,6 +1652,6 @@
</div>
</div>
<script src="assets/js/app.js?v=20260517a"></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.22",
"version": "1.7.23",
"start_url": "/evershelf/",
"display": "standalone",
"background_color": "#f0f4e8",
+50 -6
View File
@@ -113,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}",
@@ -217,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",
@@ -533,7 +535,7 @@
"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!",
@@ -753,13 +755,54 @@
"label": "🌙 Design",
"off": "☀️ Hell",
"on": "🌙 Dunkel",
"auto": "🔄 Automatisch (System)"
"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",
@@ -831,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"
@@ -1007,8 +1051,8 @@
"thing_rest": "den Rest",
"stay_btn": "Nein, bleibt in {location}",
"moved_toast": "📦 Offene Packung bewegt nach {location}",
"vacuum_restore": "🫙 Vakuum wiederherstellen",
"vacuum_seal_rest": "🔒 Rest vakuumieren"
"vacuum_restore": "Vakuum wiederherstellen",
"vacuum_seal_rest": "Rest vakuumieren"
},
"nova": {
"1": "Unverarbeitet",
+50 -6
View File
@@ -113,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}",
@@ -217,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",
@@ -533,7 +535,7 @@
"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!",
@@ -753,13 +755,54 @@
"label": "🌙 Theme",
"off": "☀️ Light",
"on": "🌙 Dark",
"auto": "🔄 Auto (system)"
"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",
@@ -831,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"
@@ -1007,8 +1051,8 @@
"thing_rest": "rest",
"stay_btn": "No, stay in {location}",
"moved_toast": "📦 Opened package moved to {location}",
"vacuum_restore": "🫙 Restore vacuum sealed",
"vacuum_seal_rest": "🔒 Vacuum seal the rest"
"vacuum_restore": "Restore vacuum sealed",
"vacuum_seal_rest": "Vacuum seal the rest"
},
"nova": {
"1": "Unprocessed",
+50 -6
View File
@@ -113,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}",
@@ -217,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à",
@@ -533,7 +535,7 @@
"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!",
@@ -753,13 +755,54 @@
"label": "🌙 Tema",
"off": "☀️ Chiaro",
"on": "🌙 Scuro",
"auto": "🔄 Automatico (sistema)"
"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",
@@ -831,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"
@@ -1007,8 +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_seal_rest": "🔒 Metti sotto vuoto il resto"
"vacuum_restore": "Torna sotto vuoto",
"vacuum_seal_rest": "Metti sotto vuoto il resto"
},
"nova": {
"1": "Non trasformato",