Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f72dc1fe54 |
@@ -20,7 +20,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
|
||||
@@ -50,4 +50,3 @@ data/error_reports.log
|
||||
data/latest_release_cache.json
|
||||
data/food_facts_cache.json
|
||||
data/category_ai_cache.json
|
||||
assets/img/logo/*_backup.*
|
||||
|
||||
@@ -11,65 +11,6 @@ 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.22] - 2026-05-17
|
||||
|
||||
### Fixed
|
||||
- **DB name corrected** — `health_check` now looks for `evershelf.db` (was wrongly looking for `dispensa.db`). Auto-migration included: if `evershelf.db` is missing but `dispensa.db` exists, it is renamed automatically on startup.
|
||||
- **Removed legacy `data/dispensa.db`** — the old database file has been deleted; only `evershelf.db` is used.
|
||||
- **Conditional checks** — Bring!, TTS, Scale and Internet checks only run when the respective feature is enabled in `.env` (no more false ❌/⚠️ for unconfigured features).
|
||||
- **Backups check** — no longer checks if `data/backups/` is writable by www-data (cron writes as root). Now checks that backup files actually exist and the most recent one is recent.
|
||||
- **Bring! token check** — reads `data/bring_token.json` file instead of looking for a non-existent `BRING_ACCESS_TOKEN` env var.
|
||||
|
||||
### Changed
|
||||
- **Warning popup with 5s countdown** — when non-critical checks fail at startup, a styled popup appears showing each warning with its label and a plain-language hint explaining the problem. A countdown bar auto-closes the popup after 5 seconds, then the app starts normally.
|
||||
- **Error blocking popup** — when critical checks fail, a clear blocking panel shows with title "Errore critico", each failed check listed with its explanation hint, and a Retry button. The app does not start.
|
||||
- **`db_legacy` check added** — warns (optional) if the old `dispensa.db` file is still present alongside `evershelf.db`.
|
||||
- **32 total checks** — added `db_legacy`, `tts_url`, `scale_gateway` to the check set (conditional).
|
||||
- **Hint messages** — every check now has an Italian-language `hint` field explaining what is wrong and how to fix it.
|
||||
|
||||
## [1.7.21] - 2026-05-20
|
||||
|
||||
### Changed
|
||||
- **Startup health check** — Complete redesign from a banner checklist to a **real-time progress bar**. The bar fills smoothly as each of 29 diagnostic checks runs, with the current check name shown below in real time. Warnings (⚠️) are displayed as amber badges that remain visible for 2 seconds before the app proceeds. Critical failures turn the bar red and show a detailed error block with a Retry button.
|
||||
- **29 comprehensive checks**: PHP version, 8 PHP extensions (pdo_sqlite, curl, json, mbstring, openssl, fileinfo, zip, intl), PHP memory/timeout/upload config, data directory, rate_limits dir, backups dir, disk write test, free disk space, SQLite connection, required tables, integrity (PRAGMA quick_check), WAL mode, DB size, inventory row count, .env file, Gemini AI key, Bring! credentials, Bring! token, cURL SSL, internet reachability.
|
||||
- Warnings now clearly visible: each non-critical failure shows as a named amber badge (e.g. "⚠️ Bring! token") that cannot be missed.
|
||||
|
||||
## [1.7.20] - 2026-05-20
|
||||
|
||||
### Added
|
||||
- **Startup health check** — During the splash screen, the app now runs a comprehensive server-side diagnostic before loading: PHP version, required extensions (pdo_sqlite, curl, mbstring, json), `data/` directory writability, SQLite database connection and table integrity, `.env` file presence, Gemini AI key and Bring! token. Results are displayed as an animated checklist (✅ / ⚠️ / ❌). Critical failures (DB, extensions, data dir) block the app with a clear error message and a "Retry" button — the app never starts silently broken. Non-critical warnings (missing Gemini key, Bring! token) are shown as amber items but do not block startup.
|
||||
- New `?action=health_check` PHP endpoint (early-exit, no rate-limit, no auth).
|
||||
- New translation keys `startup.*` in all 5 languages (IT, EN, DE, FR, ES).
|
||||
|
||||
## [1.7.19] - 2026-05-19
|
||||
|
||||
### Added
|
||||
- **Zero-waste tips during cooking** — When cooking mode is active, a ♻️ card appears below each step that generates reusable scraps (peels, cooking water, egg whites, cheese rinds, bread crusts, vegetable tops, etc.). Gemini generates the tips as part of the recipe JSON at no extra API cost. Tips are dismissible per-step and reset on recipe restart. Opt-in toggle in Settings → Zero-waste tips (default OFF). Resolves [#76](https://github.com/dadaloop82/EverShelf/issues/76).
|
||||
- New translation keys `cooking.zerowaste_*` and `settings.zerowaste.*` in all 5 languages (IT, EN, DE, FR, ES).
|
||||
|
||||
## [1.7.18] - 2026-05-19
|
||||
|
||||
### Added
|
||||
- **Dark mode** — New theme selector in Settings (Appearance card): **Off (Light)**, **On (Dark)**, **Auto (follows system)**. Applied immediately on page load to prevent white flash. Resolves [#78](https://github.com/dadaloop82/EverShelf/issues/78).
|
||||
- **Export inventory** — New 📤 button in inventory page header opens a modal to download the inventory as **CSV** (UTF-8 with BOM, Excel-compatible) or open a **print-ready HTML page** (auto-triggers print dialog for PDF). Export card also available in Settings tab. Resolves [#64](https://github.com/dadaloop82/EverShelf/issues/64).
|
||||
- `translations/de.json`: fixed missing `log.recipe_prefix` key.
|
||||
|
||||
## [1.7.17] - 2026-05-19
|
||||
|
||||
### Added
|
||||
- **French translation (🇫🇷 Français)** — Complete `translations/fr.json` with all 1049 translation keys. Resolves [#77](https://github.com/dadaloop82/EverShelf/issues/77).
|
||||
- **Spanish translation (🇪🇸 Español)** — Complete `translations/es.json` with all 1049 translation keys. Resolves [#77](https://github.com/dadaloop82/EverShelf/issues/77).
|
||||
- Language selector in Settings now shows all 5 languages: 🇮🇹 Italiano, 🇬🇧 English, 🇩🇪 Deutsch, 🇫🇷 Français, 🇪🇸 Español.
|
||||
- Default fallback language changed from Italian to English (for users with unsupported browser locale).
|
||||
- Setup wizard "Done" screen and navigation buttons localised for French and Spanish.
|
||||
|
||||
## [1.7.16] - 2026-05-17
|
||||
|
||||
### Added
|
||||
- **Barcode scan history** — Last 20 scanned products are stored server-side (SQLite `app_settings`) and shown as chips in the scan page (`#scan-recents-chips`). Tapping a chip selects the product directly — no need to scan again. Resolves [#68](https://github.com/dadaloop82/EverShelf/issues/68).
|
||||
- **Full server-side user-data centralisation** — All user preferences previously siloed in `localStorage` per-device are now synced to the server via `app_settings_save` and loaded back at startup via `app_settings_get`. Affected data: shopping tags, pinned Bring! items, location preferences (use/move), auto-added Bring! entries, Bring! purchased blocklist, no-expiry dismissed products. Data is now shared across all devices (desktop, phone, kiosk, Android app).
|
||||
- **One-time localStorage migration** — On first load, any data found in the old localStorage keys (`shopping_tags`, `_userPinnedBring`, `_prefUseLoc`, `_prefMoveLoc`, `_autoAddedBring`, `_bringPurchasedBlocklist`, `_noExpiryDismissed`, `evershelf_scan_recents`) is automatically migrated to the server and the local keys are removed.
|
||||
|
||||
## [1.7.15] - 2026-05-16
|
||||
|
||||
### Added
|
||||
@@ -79,8 +20,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **`pz`/`conf` unit labels translated** — "pz" now shows as "pcs" in English and "Stk" in German; "conf" shows as "pkg" / "Pkg". All `unitLabels` objects in JS now use `t('units.pz')` / `t('units.conf')`.
|
||||
|
||||
### Fixed
|
||||
- **Camera button (📷) opened kiosk SettingsActivity on Android** — The native `btnSettings` ImageButton in the kiosk layout was positioned `top|end` with `alpha=0.12` (nearly invisible), sitting directly on top of the HTML scan button in the webapp header. Every tap on the 📷 button was intercepted by the native View and opened `SettingsActivity`. Fixed: moved `btnSettings` to `bottom|end` (above the bottom nav bar, `marginBottom=80dp`) and increased `alpha` to `0.28` so it is clearly separate from the header. Kiosk versionCode bumped to 16.
|
||||
- **Camera button (📷) opened settings on Android Chrome/Brave** — `pointerleave` fired before `pointerup` when finger drifted slightly, cancelling the long-press timer and leaving the browser to dispatch a synthetic `click` that bubbled to an unintended handler. Fixed: added `setPointerCapture` (prevents `pointerleave` during touch) and `preventDefault` (blocks synthetic click); replaced `pointerleave` with `pointercancel` handler. Added `touch-action: manipulation` to `.header-scan-btn` CSS.
|
||||
- **Logo white background on splash screen** — Re-processed both `logo.png` and `logo_icon.png` with fuzz 35% alpha extraction, removing the white background that was visible against the dark splash background (`#0f172a`).
|
||||
- **Recipe button label** — Shortened to "Ricetta" / "Recipe" / "Rezept" for compact display in the inventory quick-action modal.
|
||||
- **Quantity decimal precision** — `qtyNum` in recipe/cooking ingredient buttons and `conf` fallback display in inventory cards now limited to 1 decimal place (was showing 7+ decimal places from raw AI output, e.g. `0.25353223 conf`).
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
[](https://www.php.net/)
|
||||
[](https://www.sqlite.org/)
|
||||
[](Dockerfile)
|
||||
[](translations/)
|
||||
[](CHANGELOG.md)
|
||||
[](translations/)
|
||||
[](CHANGELOG.md)
|
||||
[](https://github.com/dadaloop82/EverShelf/stargazers)
|
||||
[](https://github.com/dadaloop82/EverShelf/commits/main)
|
||||
[](https://github.com/dadaloop82/EverShelf/graphs/contributors)
|
||||
@@ -38,14 +38,8 @@
|
||||
|
||||
## ✨ Features
|
||||
|
||||
> ♻️ **New in v1.7.19 — Zero-waste cooking tips**
|
||||
> During cooking, EverShelf shows a contextual ♻️ tip card for each step that generates reusable scraps — peels, cooking water, egg whites, cheese rinds, bread crusts and more.
|
||||
> Tips are generated by Gemini *as part of the recipe* at zero extra API cost, shown inline in cooking mode, and dismissible per step.
|
||||
> Enable the toggle in **Settings → Zero-waste tips** (default: off).
|
||||
|
||||
### 📦 Inventory Management
|
||||
- **Export inventory** — Download the full inventory as a UTF-8 CSV (Excel-compatible) or open a print-ready page to save as PDF; export button always visible in the inventory page header
|
||||
- **Barcode scanning** — Scan products with your phone camera using QuaggaJS; last 20 scanned products saved as tappable chips so you can re-select them without rescanning
|
||||
- **Barcode scanning** — Scan products with your phone camera using QuaggaJS
|
||||
- **AI identification** — Take a photo and let Google Gemini identify the product, with suggestions from your existing inventory; gracefully shows a friendly message when AI quota is exhausted instead of a raw API error
|
||||
- **Smart locations** — Track items across Pantry, Fridge, Freezer, and custom locations
|
||||
- **Expiry tracking** — Automatic shelf-life estimation based on product type and storage
|
||||
@@ -74,7 +68,6 @@
|
||||
- **Catalog coverage** — All product types resolve to a German Bring! catalog key for icon and category display in the Bring! app
|
||||
|
||||
### 🍳 Cooking Mode
|
||||
- **♻️ Zero-waste tips** — For each cooking step that generates reusable scraps (peels, cooking water, egg whites, cheese rinds, bread crusts, vegetable tops, etc.), a dismissible ♻️ tip card appears with a practical reuse idea; tips are generated by Gemini as part of the recipe at no extra API cost; opt-in toggle in Settings (default OFF)
|
||||
- **Step-by-step guidance** — Follow recipes with a hands-free cooking interface
|
||||
- **Text-to-Speech** — Voice readout of recipe steps; supports browser Web Speech API, native Android TTS (kiosk), or a custom REST endpoint (Home Assistant, etc.); retries voice loading for up to 10 seconds with a fallback refresh button; TTS activates automatically without requiring the global TTS setting to be enabled
|
||||
- **Auto-read on navigate** — Each step is read aloud automatically when you tap Next or Previous; the first step is read when entering cooking mode
|
||||
@@ -97,13 +90,10 @@
|
||||
- **Swipe navigation** — Touch swipe or tap arrows/dots to browse banner notifications
|
||||
- **Quick-access buttons** — Recently used and most popular products shown on the inventory page for fast access
|
||||
|
||||
### 🌙 Appearance
|
||||
- **Dark mode** — Three modes: Light, Dark, and Auto (follows the OS/browser setting); theme is applied before the first render to prevent a white flash on dark-mode systems; toggle in Settings → Appearance
|
||||
|
||||
### 📱 Progressive Web App
|
||||
- **Mobile-first design** — Optimized for phones, works on tablets and desktop
|
||||
- **Installable** — Add to home screen for a native app experience
|
||||
- **Multi-device** — All user data (shopping tags, pinned items, location preferences, scan history) is stored server-side in SQLite and shared across every device on the same instance; no data is siloed in a single browser's localStorage
|
||||
- **Multi-device** — Settings and data sync across devices on the same server
|
||||
|
||||
### ⚖️ Smart Scale Integration (Add-on)
|
||||
- **Bluetooth gateway** — Connects a BLE smart scale to EverShelf via local WebSocket
|
||||
@@ -375,8 +365,6 @@ The app supports multiple languages via JSON translation files in the `translati
|
||||
| 🇮🇹 Italian (it) | ✅ Complete (base) |
|
||||
| 🇬🇧 English (en) | ✅ Complete |
|
||||
| 🇩🇪 German (de) | ✅ Complete |
|
||||
| 🇫🇷 French (fr) | ✅ Complete |
|
||||
| 🇪🇸 Spanish (es) | ✅ Complete |
|
||||
|
||||
**Want to add your language?** See the [Translation Guide](CONTRIBUTING.md#-adding-translations) — just copy `translations/it.json`, translate the values, and submit a PR!
|
||||
|
||||
|
||||
+1
-387
@@ -106,258 +106,6 @@ if (($_GET['action'] ?? '') === 'ping') {
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Health check — startup diagnostic (no rate-limit, no auth required) ──────
|
||||
if (($_GET['action'] ?? '') === 'health_check') {
|
||||
$checks = [];
|
||||
|
||||
// ── Helper: read .env values without triggering app init ─────────────────
|
||||
$envVals = loadEnv(); // already cached by loadEnv()
|
||||
$envGet = fn($k) => $envVals[$k] ?? '';
|
||||
|
||||
// ── 1. PHP version ────────────────────────────────────────────────────────
|
||||
$checks['php_version'] = [
|
||||
'ok' => version_compare(PHP_VERSION, '8.0.0', '>='),
|
||||
'value' => PHP_VERSION,
|
||||
];
|
||||
|
||||
// ── 2. Critical PHP extensions ────────────────────────────────────────────
|
||||
foreach (['pdo_sqlite', 'curl', 'json', 'mbstring'] as $ext) {
|
||||
$checks['ext_' . $ext] = ['ok' => extension_loaded($ext)];
|
||||
}
|
||||
|
||||
// ── 3. Optional PHP extensions ────────────────────────────────────────────
|
||||
foreach (['openssl', 'fileinfo', 'zip', 'intl'] as $ext) {
|
||||
$checks['ext_' . $ext] = ['ok' => extension_loaded($ext), 'optional' => true];
|
||||
}
|
||||
|
||||
// ── 4. PHP runtime configuration ─────────────────────────────────────────
|
||||
$memRaw = ini_get('memory_limit');
|
||||
$memBytes = (function ($v) {
|
||||
$v = trim($v); if ($v === '-1') return PHP_INT_MAX;
|
||||
$u = strtolower(substr($v, -1)); $n = (int)$v;
|
||||
return match($u) { 'g' => $n*1073741824, 'm' => $n*1048576, 'k' => $n*1024, default => $n };
|
||||
})($memRaw);
|
||||
$checks['php_memory'] = ['ok' => $memBytes >= 64*1048576, 'value' => $memRaw, 'optional' => true];
|
||||
$maxExec = (int) ini_get('max_execution_time');
|
||||
$checks['php_max_exec'] = ['ok' => $maxExec === 0 || $maxExec >= 30, 'value' => $maxExec === 0 ? '∞' : $maxExec.'s', 'optional' => true];
|
||||
$checks['php_upload'] = ['ok' => true, 'value' => ini_get('upload_max_filesize'), 'optional' => true];
|
||||
|
||||
// ── 5. data/ directory ────────────────────────────────────────────────────
|
||||
$dataDir = __DIR__ . '/../data';
|
||||
if (!is_dir($dataDir)) @mkdir($dataDir, 0775, true);
|
||||
$dataDirOk = is_dir($dataDir) && is_writable($dataDir);
|
||||
$checks['data_dir'] = ['ok' => $dataDirOk, 'path' => realpath($dataDir) ?: $dataDir];
|
||||
|
||||
// data/rate_limits/
|
||||
$rlDir = $dataDir . '/rate_limits';
|
||||
if (!is_dir($rlDir) && $dataDirOk) @mkdir($rlDir, 0775, true);
|
||||
$checks['data_rate_limits'] = ['ok' => is_dir($rlDir) && is_writable($rlDir), 'optional' => true];
|
||||
|
||||
// data/backups/ — written by cron as root; just verify dir exists and has recent files
|
||||
$bkDir = $dataDir . '/backups';
|
||||
$bkDirExists = is_dir($bkDir);
|
||||
$bkFiles = $bkDirExists ? array_filter(scandir($bkDir), fn($f) => str_ends_with($f, '.db')) : [];
|
||||
$lastBkTime = $bkDirExists && $bkFiles
|
||||
? max(array_map(fn($f) => filemtime($bkDir.'/'.$f), $bkFiles))
|
||||
: 0;
|
||||
$bkRecent = $lastBkTime > 0 && (time() - $lastBkTime) < 86400*2; // within 2 days
|
||||
$bkCount = count($bkFiles);
|
||||
$checks['data_backups'] = [
|
||||
'ok' => $bkDirExists && $bkCount > 0,
|
||||
'optional' => true,
|
||||
'value' => $bkDirExists ? ($bkCount . ' backup' . ($bkRecent ? ', ultimo recente' : ', ultimo vecchio')) : null,
|
||||
'hint' => $bkDirExists ? ($bkCount === 0 ? 'Nessun backup trovato — cron configurato?' : (!$bkRecent ? 'Ultimo backup datato — cron in esecuzione?' : null)) : 'Cartella backup mancante',
|
||||
];
|
||||
|
||||
// ── 6. Actual file-write test ─────────────────────────────────────────────
|
||||
$testFile = $dataDir . '/_hc_' . getmypid() . '.tmp';
|
||||
$writeOk = $dataDirOk && (@file_put_contents($testFile, 'hc') !== false);
|
||||
if ($writeOk) @unlink($testFile);
|
||||
$checks['data_write_test'] = ['ok' => $writeOk];
|
||||
|
||||
// ── 7. Free disk space ────────────────────────────────────────────────────
|
||||
$freeBytes = $dataDirOk ? @disk_free_space($dataDir) : false;
|
||||
$freeMB = $freeBytes !== false ? round($freeBytes/1048576) : null;
|
||||
$checks['disk_space'] = [
|
||||
'ok' => $freeBytes === false || $freeBytes > 50*1048576,
|
||||
'value' => $freeMB !== null ? $freeMB.' MB liberi' : null,
|
||||
'optional' => true,
|
||||
'hint' => $freeBytes !== false && $freeBytes <= 50*1048576 ? 'Meno di 50 MB liberi — libera spazio sul disco' : null,
|
||||
];
|
||||
|
||||
// ── 8. SQLite database ────────────────────────────────────────────────────
|
||||
// Correct DB name is evershelf.db; detect legacy dispensa.db and suggest migration
|
||||
$dbPath = $dataDir . '/evershelf.db';
|
||||
$legacyDb = $dataDir . '/dispensa.db';
|
||||
$hasLegacy = file_exists($legacyDb);
|
||||
$isFresh = !file_exists($dbPath) && $dataDirOk;
|
||||
|
||||
// Auto-migrate: if evershelf.db missing but dispensa.db exists, rename it
|
||||
if ($isFresh && $hasLegacy && is_writable($legacyDb)) {
|
||||
if (@rename($legacyDb, $dbPath)) {
|
||||
$hasLegacy = false;
|
||||
$isFresh = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy DB still present alongside evershelf.db → warn
|
||||
$checks['db_legacy'] = [
|
||||
'ok' => !$hasLegacy,
|
||||
'optional' => true,
|
||||
'hint' => $hasLegacy ? 'Trovato vecchio dispensa.db — il file è ormai obsoleto, puoi eliminarlo manualmente' : null,
|
||||
];
|
||||
|
||||
if ($isFresh) {
|
||||
$checks['db_connect'] = ['ok' => true, 'fresh' => true, 'value' => 'nuovo impianto'];
|
||||
$checks['db_tables'] = ['ok' => true, 'fresh' => true];
|
||||
$checks['db_integrity'] = ['ok' => true, 'fresh' => true];
|
||||
$checks['db_wal'] = ['ok' => true, 'fresh' => true, 'optional' => true];
|
||||
$checks['db_size'] = ['ok' => true, 'value' => '0 KB', 'optional' => true];
|
||||
$checks['db_row_count'] = ['ok' => true, 'value' => '0 prodotti', 'optional' => true];
|
||||
} else {
|
||||
$pdo = null; $dbConnOk = false;
|
||||
try {
|
||||
$pdo = new PDO('sqlite:' . $dbPath, null, null, [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
]);
|
||||
$pdo->query('SELECT 1');
|
||||
$dbConnOk = true;
|
||||
$checks['db_connect'] = ['ok' => true, 'value' => basename($dbPath)];
|
||||
} catch (\Throwable $e) {
|
||||
$checks['db_connect'] = ['ok' => false, 'error' => $e->getMessage(),
|
||||
'hint' => 'Impossibile aprire il database — verifica permessi su data/evershelf.db'];
|
||||
}
|
||||
|
||||
if ($dbConnOk && $pdo) {
|
||||
// Required tables
|
||||
$tables = $pdo->query("SELECT name FROM sqlite_master WHERE type='table'")->fetchAll(PDO::FETCH_COLUMN);
|
||||
$required = ['inventory', 'products', 'transactions'];
|
||||
$missing = array_values(array_diff($required, $tables));
|
||||
$checks['db_tables'] = [
|
||||
'ok' => empty($missing),
|
||||
'missing' => $missing,
|
||||
'hint' => !empty($missing) ? 'Tabelle mancanti: ' . implode(', ', $missing) . ' — esegui una chiamata API per auto-inizializzare il DB' : null,
|
||||
];
|
||||
|
||||
// Integrity
|
||||
$integ = $pdo->query("PRAGMA quick_check")->fetchColumn();
|
||||
$checks['db_integrity'] = [
|
||||
'ok' => $integ === 'ok',
|
||||
'value' => $integ !== 'ok' ? $integ : null,
|
||||
'hint' => $integ !== 'ok' ? 'Database corrotto: ' . $integ . ' — ripristina da un backup in data/backups/' : null,
|
||||
];
|
||||
|
||||
// WAL
|
||||
$wal = $pdo->query("PRAGMA journal_mode")->fetchColumn();
|
||||
$checks['db_wal'] = ['ok' => $wal === 'wal', 'value' => $wal, 'optional' => true,
|
||||
'hint' => $wal !== 'wal' ? 'Modalità journal non ottimale — sarà corretta automaticamente al primo avvio' : null];
|
||||
|
||||
// Size & rows
|
||||
$checks['db_size'] = ['ok' => true, 'value' => round(filesize($dbPath)/1024).' KB', 'optional' => true];
|
||||
$cnt = $pdo->query("SELECT COUNT(*) FROM inventory WHERE quantity > 0")->fetchColumn();
|
||||
$checks['db_row_count'] = ['ok' => true, 'value' => $cnt.' prodotti in inventario', 'optional' => true];
|
||||
} else {
|
||||
foreach (['db_tables', 'db_integrity'] as $k)
|
||||
$checks[$k] = ['ok' => false, 'hint' => 'Impossibile verificare — connessione DB fallita'];
|
||||
foreach (['db_wal', 'db_size', 'db_row_count'] as $k)
|
||||
$checks[$k] = ['ok' => false, 'optional' => true];
|
||||
}
|
||||
}
|
||||
|
||||
// ── 9. .env file ──────────────────────────────────────────────────────────
|
||||
$envExists = file_exists(__DIR__ . '/../.env');
|
||||
$checks['env_file'] = [
|
||||
'ok' => $envExists,
|
||||
'optional' => true,
|
||||
'hint' => !$envExists ? 'File .env mancante — copia .env.example in .env e configura i valori' : null,
|
||||
];
|
||||
|
||||
// ── 10. Gemini AI — solo se GEMINI_API_KEY è impostata ───────────────────
|
||||
$geminiKey = $envGet('GEMINI_API_KEY');
|
||||
if (!empty($geminiKey)) {
|
||||
$checks['gemini_key'] = ['ok' => strlen($geminiKey) > 20, 'optional' => true,
|
||||
'hint' => strlen($geminiKey) <= 20 ? 'Chiave Gemini AI sembra troppo corta — verifica il valore in .env' : null];
|
||||
} else {
|
||||
$checks['gemini_key'] = ['ok' => false, 'optional' => true,
|
||||
'hint' => 'GEMINI_API_KEY non configurata — le funzioni AI non saranno disponibili'];
|
||||
}
|
||||
|
||||
// ── 11. Bring! — solo se EMAIL+PASSWORD sono impostate ───────────────────
|
||||
$bringEmail = $envGet('BRING_EMAIL');
|
||||
$bringPassword = $envGet('BRING_PASSWORD');
|
||||
$bringEnabled = !empty($bringEmail) && !empty($bringPassword);
|
||||
if ($bringEnabled) {
|
||||
$checks['bring_credentials'] = ['ok' => true, 'optional' => true];
|
||||
// Token: stored in data/bring_token.json (not in .env)
|
||||
$bringTokenFile = $dataDir . '/bring_token.json';
|
||||
$bringTokenOk = false;
|
||||
$bringTokenHint = null;
|
||||
if (file_exists($bringTokenFile)) {
|
||||
$bringData = @json_decode(@file_get_contents($bringTokenFile), true);
|
||||
$bringTokenOk = !empty($bringData['access_token'] ?? ($bringData['accessToken'] ?? ''));
|
||||
if (!$bringTokenOk) $bringTokenHint = 'Token Bring! presente ma non valido — verrà rinnovato automaticamente al prossimo accesso';
|
||||
} else {
|
||||
$bringTokenHint = 'Token Bring! non ancora generato — verrà creato al primo accesso alla lista spesa';
|
||||
}
|
||||
$checks['bring_token'] = ['ok' => $bringTokenOk, 'optional' => true, 'hint' => $bringTokenHint];
|
||||
}
|
||||
// If Bring! not configured, skip entirely (no check at all)
|
||||
|
||||
// ── 12. TTS — solo se TTS_ENABLED ────────────────────────────────────────
|
||||
if ($envGet('TTS_ENABLED') === 'true') {
|
||||
$ttsUrl = $envGet('TTS_URL');
|
||||
$checks['tts_url'] = [
|
||||
'ok' => !empty($ttsUrl),
|
||||
'optional' => true,
|
||||
'hint' => empty($ttsUrl) ? 'TTS_ENABLED=true ma TTS_URL non configurata' : null,
|
||||
];
|
||||
}
|
||||
|
||||
// ── 13. Scale gateway — solo se SCALE_ENABLED ────────────────────────────
|
||||
if ($envGet('SCALE_ENABLED') === 'true') {
|
||||
$scaleUrl = $envGet('SCALE_GATEWAY_URL');
|
||||
$checks['scale_gateway'] = [
|
||||
'ok' => !empty($scaleUrl),
|
||||
'optional' => true,
|
||||
'hint' => empty($scaleUrl) ? 'SCALE_ENABLED=true ma SCALE_GATEWAY_URL non configurata' : null,
|
||||
];
|
||||
}
|
||||
|
||||
// ── 14. cURL SSL ──────────────────────────────────────────────────────────
|
||||
if (function_exists('curl_version')) {
|
||||
$cv = curl_version();
|
||||
$checks['curl_ssl'] = ['ok' => !empty($cv['ssl_version']), 'value' => $cv['ssl_version'] ?? null, 'optional' => true,
|
||||
'hint' => empty($cv['ssl_version']) ? 'cURL senza supporto SSL — le chiamate HTTPS potrebbero fallire' : null];
|
||||
} else {
|
||||
$checks['curl_ssl'] = ['ok' => false, 'optional' => true, 'hint' => 'cURL non disponibile'];
|
||||
}
|
||||
|
||||
// ── 15. Internet — raggiungibilità API Gemini (solo se Gemini configurato) ─
|
||||
if (!empty($geminiKey) && extension_loaded('curl')) {
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [CURLOPT_URL => 'https://generativelanguage.googleapis.com/', CURLOPT_NOBODY => true,
|
||||
CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => 4, CURLOPT_CONNECTTIMEOUT => 3,
|
||||
CURLOPT_RETURNTRANSFER => true, CURLOPT_SSL_VERIFYPEER => false]);
|
||||
curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlErrNo = curl_errno($ch);
|
||||
curl_close($ch);
|
||||
$internetOk = $httpCode > 0 || $curlErrNo === 0;
|
||||
$checks['internet'] = ['ok' => $internetOk, 'optional' => true,
|
||||
'hint' => !$internetOk ? 'Impossibile raggiungere i server Gemini — le funzioni AI non funzioneranno senza connessione internet' : null];
|
||||
}
|
||||
|
||||
// ── Compute overall result ────────────────────────────────────────────────
|
||||
$criticalKeys = ['php_version', 'ext_pdo_sqlite', 'ext_curl', 'ext_json', 'ext_mbstring',
|
||||
'data_dir', 'data_write_test', 'db_connect', 'db_tables', 'db_integrity'];
|
||||
$allOk = array_reduce($criticalKeys, fn($c, $k) => $c && ($checks[$k]['ok'] ?? false), true);
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['ok' => $allOk, 'checks' => $checks, 'fresh' => $isFresh], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ===== RATE LIMITING =====
|
||||
/**
|
||||
* Simple file-based rate limiter.
|
||||
@@ -726,10 +474,6 @@ try {
|
||||
guessCategoryFromAI();
|
||||
break;
|
||||
|
||||
case 'export_inventory':
|
||||
exportInventory($db);
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => 'Unknown action: ' . $action]);
|
||||
@@ -741,107 +485,6 @@ try {
|
||||
}
|
||||
endif; // end !CRON_MODE
|
||||
|
||||
// ===== EXPORT INVENTORY =====
|
||||
function exportInventory(PDO $db): void {
|
||||
$format = strtolower($_GET['format'] ?? 'csv');
|
||||
|
||||
$stmt = $db->query("
|
||||
SELECT p.name, p.brand, p.category, i.location, i.quantity, p.unit,
|
||||
i.expiry_date, i.added_at, i.opened_at,
|
||||
COALESCE(i.vacuum_sealed, 0) as vacuum_sealed,
|
||||
p.barcode, p.notes
|
||||
FROM inventory i
|
||||
JOIN products p ON i.product_id = p.id
|
||||
WHERE i.quantity > 0
|
||||
ORDER BY p.name ASC
|
||||
");
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$date = date('Y-m-d');
|
||||
|
||||
if ($format === 'html') {
|
||||
// Print-ready HTML for browser PDF
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
$rows_html = '';
|
||||
foreach ($rows as $r) {
|
||||
$loc_icon = ['dispensa'=>'🗄️','frigo'=>'🧊','freezer'=>'❄️','altro'=>'📦'][$r['location']] ?? '📦';
|
||||
$expiry = $r['expiry_date'] ? htmlspecialchars($r['expiry_date']) : '—';
|
||||
$brand = $r['brand'] ? htmlspecialchars($r['brand']) : '';
|
||||
$rows_html .= '<tr>'
|
||||
. '<td>' . htmlspecialchars($r['name']) . ($brand ? '<br><small>' . $brand . '</small>' : '') . '</td>'
|
||||
. '<td>' . htmlspecialchars(ucfirst($r['category'] ?? '')) . '</td>'
|
||||
. '<td>' . $loc_icon . ' ' . htmlspecialchars(ucfirst($r['location'])) . '</td>'
|
||||
. '<td style="text-align:right">' . htmlspecialchars($r['quantity']) . ' ' . htmlspecialchars($r['unit'] ?? 'pz') . '</td>'
|
||||
. '<td>' . $expiry . '</td>'
|
||||
. '<td>' . ($r['opened_at'] ? '📭 ' . htmlspecialchars($r['opened_at']) : '') . '</td>'
|
||||
. '</tr>';
|
||||
}
|
||||
$count = count($rows);
|
||||
echo <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>EverShelf — Inventory Export {$date}</title>
|
||||
<style>
|
||||
body{font-family:Arial,sans-serif;font-size:12px;margin:24px;color:#1a1a1a}
|
||||
h1{font-size:18px;margin-bottom:4px}
|
||||
.subtitle{color:#6b7280;font-size:11px;margin-bottom:16px}
|
||||
table{width:100%;border-collapse:collapse}
|
||||
th{background:#2d5016;color:#fff;padding:7px 10px;text-align:left;font-size:11px}
|
||||
td{padding:6px 10px;border-bottom:1px solid #e5e7eb;vertical-align:top}
|
||||
tr:nth-child(even) td{background:#f8fafc}
|
||||
small{color:#6b7280}
|
||||
@media print{
|
||||
body{margin:12px}
|
||||
button{display:none}
|
||||
@page{margin:15mm}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<button onclick="window.print()" style="margin-bottom:16px;padding:8px 16px;background:#2d5016;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:13px">🖨️ Print / Save as PDF</button>
|
||||
<h1>🏠 EverShelf — Inventory</h1>
|
||||
<div class="subtitle">Exported: {$date} · {$count} items</div>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Name / Brand</th><th>Category</th><th>Location</th><th>Qty</th><th>Expiry</th><th>Opened</th>
|
||||
</tr></thead>
|
||||
<tbody>{$rows_html}</tbody>
|
||||
</table>
|
||||
<script>window.onload=function(){window.print();}</script>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
exit;
|
||||
}
|
||||
|
||||
// Default: CSV download
|
||||
header('Content-Type: text/csv; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename="evershelf-inventory-' . $date . '.csv"');
|
||||
// UTF-8 BOM for Excel compatibility
|
||||
echo "\xEF\xBB\xBF";
|
||||
$out = fopen('php://output', 'w');
|
||||
fputcsv($out, ['Name','Brand','Category','Location','Quantity','Unit','Expiry Date','Added','Opened At','Vacuum Sealed','Barcode','Notes']);
|
||||
foreach ($rows as $r) {
|
||||
fputcsv($out, [
|
||||
$r['name'],
|
||||
$r['brand'] ?? '',
|
||||
$r['category'] ?? '',
|
||||
$r['location'],
|
||||
$r['quantity'],
|
||||
$r['unit'] ?? 'pz',
|
||||
$r['expiry_date'] ?? '',
|
||||
$r['added_at'] ?? '',
|
||||
$r['opened_at'] ?? '',
|
||||
$r['vacuum_sealed'] ? 'Yes' : 'No',
|
||||
$r['barcode'] ?? '',
|
||||
$r['notes'] ?? '',
|
||||
]);
|
||||
}
|
||||
fclose($out);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ===== TTS PROXY =====
|
||||
function ttsProxy() {
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
@@ -2652,7 +2295,6 @@ function getServerSettings(): void {
|
||||
'meal_plan_enabled' => env('MEAL_PLAN_ENABLED', 'false') === 'true',
|
||||
'screensaver_enabled' => env('SCREENSAVER_ENABLED', 'false') === 'true',
|
||||
'screensaver_timeout' => (int)env('SCREENSAVER_TIMEOUT', '5'),
|
||||
'zerowaste_tips_enabled' => env('ZEROWASTE_TIPS_ENABLED', 'false') === 'true',
|
||||
'price_enabled' => env('PRICE_ENABLED', 'false') === 'true',
|
||||
'price_country' => env('PRICE_COUNTRY', 'Italia'),
|
||||
'price_currency' => env('PRICE_CURRENCY', 'EUR'),
|
||||
@@ -2710,7 +2352,6 @@ function saveSettings(): void {
|
||||
'meal_plan_enabled' => 'MEAL_PLAN_ENABLED',
|
||||
'screensaver_enabled' => 'SCREENSAVER_ENABLED',
|
||||
'price_enabled' => 'PRICE_ENABLED',
|
||||
'zerowaste_tips_enabled' => 'ZEROWASTE_TIPS_ENABLED',
|
||||
];
|
||||
// Integer keys
|
||||
$intMap = [
|
||||
@@ -4712,14 +4353,13 @@ REGOLE:
|
||||
6. Includi nella lista ingredienti TUTTI quelli citati nei passi (tranne acqua/sale/pepe/olio).
|
||||
7. Language rule: {$recipeLangName} only for all textual fields (`title`, `tags`, `expiry_note`, `ingredients.qty`, `steps`, `nutrition_note`, `tools_needed`). Keep `meal` unchanged.
|
||||
8. `tools_needed`: array of kitchen tools/appliances actually required by this recipe (e.g. ["Forno","Frullatore"]). Use the same language as all other text fields. Empty array [] if only stovetop/knife/pan needed.
|
||||
9. `zero_waste_tips`: array of zero-waste tips for steps that generate reusable scraps (peels, leftover cooking water, egg whites, cheese rinds, bread crusts, vegetable tops, etc.). Each entry: {"step": 0-based_step_index, "scrap": "scrap name", "tip": "short practical reuse tip (max 20 words)"}. Use the same language as other text fields. Empty array [] if no reusable scraps are generated.
|
||||
|
||||
DISPENSA:
|
||||
$ingredientsText
|
||||
|
||||
Rispondi SOLO JSON valido (no markdown):
|
||||
{$promptLanguageRule}
|
||||
{"title":"…","meal":"$mealType","persons":$persons,"prep_time":"…","cook_time":"…","tags":["…"],"expiry_note":"…","tools_needed":["…"],"ingredients":[{"name":"…","qty":"200 g","qty_number":200,"from_pantry":true}],"steps":["{$promptStepExample}"],"nutrition_note":"…","zero_waste_tips":[{"step":0,"scrap":"…","tip":"…"}]}
|
||||
{"title":"…","meal":"$mealType","persons":$persons,"prep_time":"…","cook_time":"…","tags":["…"],"expiry_note":"…","tools_needed":["…"],"ingredients":[{"name":"…","qty":"200 g","qty_number":200,"from_pantry":true}],"steps":["{$promptStepExample}"],"nutrition_note":"…"}
|
||||
PROMPT;
|
||||
|
||||
$genConfig = [
|
||||
@@ -8436,32 +8076,6 @@ function _calcEstimatedTotal(float $pricePerUnit, string $priceUnitLabel, float
|
||||
}
|
||||
}
|
||||
|
||||
// ── conf/pz with known package weight vs weight-labeled AI price ──────────
|
||||
// E.g. unit='conf', defQty=170g, AI priced 'pacco 500g' @ €3.20
|
||||
// → need ceil(7×170 / 500) = 3 packs × €3.20 = €9.60, not 7×€3.20 = €22.40
|
||||
if (in_array(strtolower($unit), ['conf', 'pz']) && $defQty > 0 && !empty($pkgUnit)) {
|
||||
$pkgL = strtolower($pkgUnit);
|
||||
$isWt = in_array($pkgL, ['g', 'kg']);
|
||||
$isVol = in_array($pkgL, ['ml', 'l', 'lt']);
|
||||
if (($isWt || $isVol) &&
|
||||
preg_match('/\b(\d+(?:[.,]\d+)?)\s*(g|kg|ml|l|lt)\b/i', $priceUnitLabel, $m)) {
|
||||
$rawVal = (float) str_replace(',', '.', $m[1]);
|
||||
$rawUnit = strtolower($m[2]);
|
||||
$labelIsWt = in_array($rawUnit, ['g', 'kg']);
|
||||
$labelIsVol = in_array($rawUnit, ['ml', 'l', 'lt']);
|
||||
if (($isWt && $labelIsWt) || ($isVol && $labelIsVol)) {
|
||||
// Convert to base units (g or ml)
|
||||
$defBase = $pkgL === 'kg' ? $defQty * 1000.0 : $defQty;
|
||||
$labelBase = match($rawUnit) { 'kg','l','lt' => $rawVal * 1000.0, default => $rawVal };
|
||||
if ($labelBase > 0) {
|
||||
$totalBase = $qty * $defBase;
|
||||
$packs = (int) max(1, ceil($totalBase / $labelBase));
|
||||
return round($pricePerUnit * $packs, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$buyQty = max(1.0, $qty);
|
||||
return round($pricePerUnit * $buyQty, 2);
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 9.7 MiB |
+1
-495
@@ -116,168 +116,6 @@ body {
|
||||
letter-spacing: 0.5px;
|
||||
margin-top: -8px;
|
||||
}
|
||||
/* ── Startup progress bar ───────────────────────────────────────────── */
|
||||
.preloader-progress-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 250px;
|
||||
max-width: 88vw;
|
||||
animation: zwFadeIn 0.2s ease;
|
||||
}
|
||||
.preloader-bar-track {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: rgba(255,255,255,0.12);
|
||||
border-radius: 99px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.preloader-bar {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: linear-gradient(90deg, #4ade80, #22c55e);
|
||||
border-radius: 99px;
|
||||
transition: width 0.18s ease, background 0.3s ease;
|
||||
}
|
||||
.preloader-bar.bar-error { background: linear-gradient(90deg, #f87171, #ef4444); }
|
||||
.preloader-bar.bar-warn { background: linear-gradient(90deg, #fbbf24, #f59e0b); }
|
||||
.preloader-check-label {
|
||||
color: rgba(255,255,255,0.60);
|
||||
font-size: 0.74rem;
|
||||
font-family: monospace;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
min-height: 1.1em;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.preloader-warnings {
|
||||
max-width: 310px;
|
||||
width: 100%;
|
||||
animation: zwFadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
/* ── Warning popup (auto-close 5s) ─────────────────────────── */
|
||||
.startup-popup {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
.startup-popup-warn {
|
||||
background: rgba(30,20,0,0.85);
|
||||
border: 1px solid rgba(251,191,36,0.45);
|
||||
}
|
||||
.startup-popup-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 9px 14px 7px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: #fcd34d;
|
||||
gap: 8px;
|
||||
}
|
||||
.startup-popup-countdown {
|
||||
background: rgba(251,191,36,0.2);
|
||||
color: #fcd34d;
|
||||
border-radius: 50%;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.startup-popup-body {
|
||||
padding: 2px 14px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.startup-warn-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
.startup-warn-icon {
|
||||
font-size: 1rem;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.startup-warn-body {
|
||||
font-size: 0.78rem;
|
||||
color: #d4c08a;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.startup-warn-body strong {
|
||||
display: block;
|
||||
color: #fcd34d;
|
||||
font-size: 0.82rem;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.startup-warn-body p {
|
||||
margin: 0;
|
||||
color: #c8a954;
|
||||
}
|
||||
.startup-popup-bar-wrap {
|
||||
height: 3px;
|
||||
background: rgba(251,191,36,0.15);
|
||||
}
|
||||
.startup-popup-bar {
|
||||
height: 3px;
|
||||
background: #fbbf24;
|
||||
width: 100%;
|
||||
will-change: width;
|
||||
}
|
||||
|
||||
/* ── Error popup (blocking) ─────────────────────────────────── */
|
||||
/* Keep .preloader-warn-badge for backward compat */
|
||||
.preloader-warn-badge {
|
||||
background: rgba(251,191,36,0.15);
|
||||
color: #fcd34d;
|
||||
border: 1px solid rgba(251,191,36,0.35);
|
||||
border-radius: 99px;
|
||||
padding: 3px 10px;
|
||||
font-size: 0.71rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.preloader-error-msg {
|
||||
color: #fca5a5;
|
||||
background: rgba(239,68,68,0.18);
|
||||
border: 1px solid rgba(239,68,68,0.4);
|
||||
border-radius: 12px;
|
||||
padding: 14px 18px;
|
||||
font-size: 0.80rem;
|
||||
text-align: left;
|
||||
max-width: 300px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-line;
|
||||
animation: zwFadeIn 0.3s ease;
|
||||
}
|
||||
.preloader-error-msg strong {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
color: #f87171;
|
||||
margin-bottom: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
.preloader-retry-btn {
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 9px 22px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
animation: zwFadeIn 0.3s ease;
|
||||
}
|
||||
.preloader-retry-btn:active { opacity: 0.8; }
|
||||
.header-logo-icon {
|
||||
height: 28px;
|
||||
width: auto;
|
||||
@@ -441,7 +279,6 @@ body {
|
||||
height: 48px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.18);
|
||||
animation: pulse-scan 2s ease-in-out infinite;
|
||||
touch-action: manipulation; /* prevent 300ms delay and double-tap zoom on mobile */
|
||||
}
|
||||
.header-scan-btn:active {
|
||||
background: rgba(255,255,255,0.45);
|
||||
@@ -5913,6 +5750,7 @@ body.cooking-mode-active .app-header {
|
||||
opacity: 0.75;
|
||||
}
|
||||
.alert-item-spoiled .alert-item-name {
|
||||
text-decoration: line-through;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
@@ -7019,335 +6857,3 @@ body.cooking-mode-active .app-header {
|
||||
color: #9ca3af;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
/* ===== PAGE HEADER ACTION BUTTON (export etc.) ===== */
|
||||
.page-header-action-btn {
|
||||
margin-left: auto;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 12px;
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
color: var(--primary);
|
||||
}
|
||||
.page-header-action-btn:active {
|
||||
transform: scale(0.95);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* ===== ZERO-WASTE TIP CARD (cooking mode) ===== */
|
||||
.cooking-zerowaste-tip {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
background: rgba(16, 185, 129, 0.10);
|
||||
border: 1.5px solid rgba(16, 185, 129, 0.35);
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
margin: 10px 16px 0;
|
||||
position: relative;
|
||||
animation: zwFadeIn 0.3s ease;
|
||||
flex-direction: column;
|
||||
}
|
||||
@keyframes zwFadeIn {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.cooking-zerowaste-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #059669;
|
||||
}
|
||||
.cooking-zerowaste-scrap {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: #065f46;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.cooking-zerowaste-text {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text);
|
||||
margin: 4px 0 0;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.cooking-zerowaste-close {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 10px;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
padding: 2px 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
.cooking-zerowaste-close:hover { color: #374151; }
|
||||
[data-theme="dark"] .cooking-zerowaste-tip {
|
||||
background: rgba(16, 185, 129, 0.08);
|
||||
border-color: rgba(16, 185, 129, 0.25);
|
||||
}
|
||||
[data-theme="dark"] .cooking-zerowaste-scrap { color: #6ee7b7; }
|
||||
[data-theme="dark"] .cooking-zerowaste-label { color: #34d399; }
|
||||
[data-theme="dark"] .cooking-zerowaste-close { color: #9ca3af; }
|
||||
|
||||
/* ===== DARK MODE ===== */
|
||||
[data-theme="dark"] {
|
||||
--bg: #0f172a;
|
||||
--bg-card: #1e293b;
|
||||
--bg-dark: #020617;
|
||||
--text: #e2e8f0;
|
||||
--text-light: #94a3b8;
|
||||
--text-muted: #64748b;
|
||||
--text-secondary: #94a3b8;
|
||||
--border: #334155;
|
||||
--shadow: 0 2px 8px rgba(0,0,0,0.45);
|
||||
--shadow-lg: 0 4px 16px rgba(0,0,0,0.6);
|
||||
color-scheme: dark;
|
||||
}
|
||||
[data-theme="dark"] body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
/* Bottom nav */
|
||||
[data-theme="dark"] .bottom-nav {
|
||||
box-shadow: 0 -2px 10px rgba(0,0,0,0.4);
|
||||
}
|
||||
/* Location tabs */
|
||||
[data-theme="dark"] .tab {
|
||||
background: var(--bg-card);
|
||||
color: var(--text-light);
|
||||
}
|
||||
[data-theme="dark"] .tab.active {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
/* Location selector (add/use modal) */
|
||||
[data-theme="dark"] .location-option {
|
||||
background: var(--bg-card);
|
||||
border-color: var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
[data-theme="dark"] .location-option.selected {
|
||||
border-color: var(--primary);
|
||||
background: rgba(45,80,22,0.3);
|
||||
}
|
||||
/* Inputs & selects */
|
||||
[data-theme="dark"] .form-input,
|
||||
[data-theme="dark"] .form-control,
|
||||
[data-theme="dark"] input[type="text"],
|
||||
[data-theme="dark"] input[type="email"],
|
||||
[data-theme="dark"] input[type="password"],
|
||||
[data-theme="dark"] input[type="number"],
|
||||
[data-theme="dark"] textarea,
|
||||
[data-theme="dark"] select {
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
[data-theme="dark"] input::placeholder,
|
||||
[data-theme="dark"] textarea::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
/* Buttons */
|
||||
[data-theme="dark"] .btn-secondary,
|
||||
[data-theme="dark"] .btn-outline,
|
||||
[data-theme="dark"] .back-btn,
|
||||
[data-theme="dark"] .page-header-action-btn {
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
[data-theme="dark"] .btn-outline {
|
||||
color: var(--primary-light);
|
||||
border-color: var(--primary-light);
|
||||
}
|
||||
/* Inventory items */
|
||||
[data-theme="dark"] .inventory-item {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
[data-theme="dark"] .inv-location-badge {
|
||||
background: rgba(45,80,22,0.35);
|
||||
color: #86efac;
|
||||
}
|
||||
/* Shopping items */
|
||||
[data-theme="dark"] .shopping-item {
|
||||
background: var(--bg-card) !important;
|
||||
}
|
||||
[data-theme="dark"] .shopping-item-tag-menu-container {
|
||||
background: var(--bg-card);
|
||||
border-color: var(--border);
|
||||
}
|
||||
[data-theme="dark"] .shopping-item-tag-btn {
|
||||
background: #1e293b;
|
||||
color: var(--text-light);
|
||||
border-color: var(--border);
|
||||
}
|
||||
[data-theme="dark"] .badge-local-tag {
|
||||
background: #0c2a4e;
|
||||
color: #7dd3fc;
|
||||
}
|
||||
[data-theme="dark"] .badge-freq-med {
|
||||
background: #2e1a4a;
|
||||
color: #c4b5fd;
|
||||
}
|
||||
[data-theme="dark"] .badge-freq-low {
|
||||
background: #1e293b;
|
||||
color: #94a3b8;
|
||||
}
|
||||
/* Settings rows */
|
||||
[data-theme="dark"] .settings-row {
|
||||
border-color: var(--border);
|
||||
}
|
||||
[data-theme="dark"] .settings-label {
|
||||
color: var(--text);
|
||||
}
|
||||
[data-theme="dark"] .settings-hint {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
/* Toggle switch */
|
||||
[data-theme="dark"] .toggle-slider {
|
||||
background: #334155;
|
||||
}
|
||||
/* Search bar */
|
||||
[data-theme="dark"] .search-bar input {
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
/* Action modal location selector */
|
||||
[data-theme="dark"] .action-location-btn {
|
||||
background: var(--bg-card);
|
||||
border-color: var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
/* Scan page */
|
||||
[data-theme="dark"] .scan-input-row {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
[data-theme="dark"] .scan-result-item {
|
||||
background: var(--bg-card);
|
||||
border-color: var(--border);
|
||||
}
|
||||
/* Quick access chips */
|
||||
[data-theme="dark"] .quick-access-chip {
|
||||
background: var(--bg-card);
|
||||
border-color: var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
/* Scan recents */
|
||||
[data-theme="dark"] .scan-recent-chip {
|
||||
background: var(--bg-card);
|
||||
border-color: var(--border);
|
||||
color: var(--text-light);
|
||||
}
|
||||
/* Alert banners */
|
||||
[data-theme="dark"] .alert-banner {
|
||||
background: #1e293b;
|
||||
border-color: #334155;
|
||||
}
|
||||
[data-theme="dark"] .alert-banner.banner-expiring {
|
||||
background: #1c1300;
|
||||
border-color: #78350f;
|
||||
}
|
||||
[data-theme="dark"] .alert-banner.banner-expired {
|
||||
background: #1f0808;
|
||||
border-color: #7f1d1d;
|
||||
}
|
||||
[data-theme="dark"] .alert-banner.banner-finished {
|
||||
background: #0f1f0f;
|
||||
border-color: #166534;
|
||||
}
|
||||
[data-theme="dark"] .alert-banner.banner-anomaly {
|
||||
background: #1a1a2e;
|
||||
border-color: #4c1d95;
|
||||
}
|
||||
/* Recipe dialog */
|
||||
[data-theme="dark"] .recipe-dialog-content {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
[data-theme="dark"] .recipe-option-btn {
|
||||
background: var(--bg-card);
|
||||
border-color: var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
[data-theme="dark"] .recipe-option-btn.active {
|
||||
background: rgba(45,80,22,0.4);
|
||||
border-color: var(--primary-light);
|
||||
color: var(--primary-light);
|
||||
}
|
||||
/* Log rows */
|
||||
[data-theme="dark"] .log-item {
|
||||
background: var(--bg-card);
|
||||
border-color: var(--border);
|
||||
}
|
||||
/* Dashboard stat cards */
|
||||
[data-theme="dark"] .stat-card {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
/* Screensaver */
|
||||
[data-theme="dark"] .screensaver-overlay {
|
||||
background: #020617;
|
||||
}
|
||||
/* Charts / nutrition */
|
||||
[data-theme="dark"] .nutrition-chart-bg {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
/* AW badges */
|
||||
[data-theme="dark"] .aw-badge-rate { background: #2e1a4a; color: #c4b5fd; border-color: #6d28d9; }
|
||||
[data-theme="dark"] .aw-badge-money { background: #1c1300; color: #fde047; border-color: #78350f; }
|
||||
[data-theme="dark"] .aw-badge-meals { background: #0f1f0f; color: #4ade80; border-color: #166534; }
|
||||
[data-theme="dark"] .aw-badge-co2 { background: #0c1f3a; color: #7dd3fc; border-color: #1e3a5f; }
|
||||
[data-theme="dark"] .aw-badge-wasted{ background: #1f0808; color: #fca5a5; border-color: #7f1d1d; }
|
||||
[data-theme="dark"] .aw-badge-better{ background: #0f1f0f; color: #4ade80; border-color: #166534; }
|
||||
/* Chat */
|
||||
[data-theme="dark"] .chat-input {
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
[data-theme="dark"] .chat-message.user {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
[data-theme="dark"] .chat-message.bot {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
/* Smart shopping forecast */
|
||||
[data-theme="dark"] .smart-item {
|
||||
background: var(--bg-card);
|
||||
border-color: var(--border);
|
||||
}
|
||||
[data-theme="dark"] .smart-filter-btn {
|
||||
background: var(--bg-card);
|
||||
color: var(--text-light);
|
||||
border-color: var(--border);
|
||||
}
|
||||
[data-theme="dark"] .smart-filter-btn.active {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
/* Offline banner */
|
||||
[data-theme="dark"] #offline-banner {
|
||||
background: #450a0a;
|
||||
border-color: #7f1d1d;
|
||||
}
|
||||
/* Setup wizard */
|
||||
[data-theme="dark"] .setup-content {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
[data-theme="dark"] .setup-lang-btn {
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
[data-theme="dark"] .setup-lang-btn.selected {
|
||||
background: rgba(45,80,22,0.4);
|
||||
border-color: var(--primary-light);
|
||||
color: var(--primary-light);
|
||||
}
|
||||
/* @media prefers-color-scheme: auto handled in JS */
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 144 KiB |
+108
-532
@@ -1043,19 +1043,9 @@ async function discoverScaleGateway() {
|
||||
// ===== i18n TRANSLATION SYSTEM =====
|
||||
let _i18nStrings = null; // current language translations (flat)
|
||||
let _i18nFallback = null; // Italian fallback (flat)
|
||||
let _currentLang = localStorage.getItem('evershelf_lang') || navigator.language?.slice(0, 2) || 'en';
|
||||
const _SUPPORTED_LANGS = { it: 'Italiano', en: 'English', de: 'Deutsch', fr: 'Français', es: 'Español' };
|
||||
if (!_SUPPORTED_LANGS[_currentLang]) _currentLang = 'en';
|
||||
|
||||
// Apply theme IMMEDIATELY to prevent flash of unstyled content
|
||||
(function _earlyTheme() {
|
||||
try {
|
||||
const s = JSON.parse(localStorage.getItem('evershelf_settings') || '{}');
|
||||
const mode = s.dark_mode || 'auto';
|
||||
const dark = mode === 'on' || (mode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
|
||||
} catch(e) {}
|
||||
})();
|
||||
let _currentLang = localStorage.getItem('evershelf_lang') || navigator.language?.slice(0, 2) || 'it';
|
||||
const _SUPPORTED_LANGS = { it: 'Italiano', en: 'English', de: 'Deutsch' };
|
||||
if (!_SUPPORTED_LANGS[_currentLang]) _currentLang = 'it';
|
||||
|
||||
// Flatten nested JSON: { a: { b: "x" } } → { "a.b": "x" }
|
||||
function _flattenI18n(obj, prefix = '') {
|
||||
@@ -1166,69 +1156,6 @@ function changeLanguage(lang) {
|
||||
location.reload();
|
||||
}
|
||||
|
||||
// ===== DARK MODE =====
|
||||
function _applyTheme() {
|
||||
const s = getSettings();
|
||||
const mode = s.dark_mode || 'auto';
|
||||
let isDark;
|
||||
if (mode === 'on') {
|
||||
isDark = true;
|
||||
} else if (mode === 'off') {
|
||||
isDark = false;
|
||||
} else {
|
||||
isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
|
||||
}
|
||||
|
||||
function _setThemeMode(mode) {
|
||||
const s = getSettings();
|
||||
s.dark_mode = mode;
|
||||
saveSettingsToStorage(s);
|
||||
_applyTheme();
|
||||
}
|
||||
|
||||
// Listen to system theme changes (for 'auto' mode)
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||
const s = getSettings();
|
||||
if ((s.dark_mode || 'auto') === 'auto') _applyTheme();
|
||||
});
|
||||
|
||||
// ===== EXPORT INVENTORY =====
|
||||
function exportInventory(format) {
|
||||
const url = `api/index.php?action=export_inventory&format=${encodeURIComponent(format)}&_t=${Date.now()}`;
|
||||
if (format === 'csv') {
|
||||
// Direct download via <a> trick
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `evershelf-inventory-${new Date().toISOString().slice(0,10)}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
} else {
|
||||
// Open print-ready HTML in new tab
|
||||
window.open(url, '_blank', 'noopener');
|
||||
}
|
||||
}
|
||||
|
||||
function _showExportModal() {
|
||||
const html = `
|
||||
<div class="modal-header">
|
||||
<h3>📤 ${t('export.title')}</h3>
|
||||
<button class="modal-close" onclick="closeModal()">✕</button>
|
||||
</div>
|
||||
<div style="padding:16px;display:flex;flex-direction:column;gap:12px">
|
||||
<p style="color:var(--text-light);font-size:0.9rem">${t('export.hint')}</p>
|
||||
<button class="btn btn-primary full-width" onclick="exportInventory('csv');closeModal()">
|
||||
📊 ${t('export.btn_csv')}
|
||||
</button>
|
||||
<button class="btn btn-outline full-width" onclick="exportInventory('html');closeModal()">
|
||||
🖨️ ${t('export.btn_pdf')}
|
||||
</button>
|
||||
</div>`;
|
||||
openModal(html);
|
||||
}
|
||||
|
||||
const LOCATIONS = {
|
||||
'dispensa': { icon: '🗄️', label: t('locations.dispensa') },
|
||||
'frigo': { icon: '🧊', label: t('locations.frigo') },
|
||||
@@ -1906,20 +1833,24 @@ function switchScanTab(tab) {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== SCAN HISTORY (server-synced via app_settings key "scan_history") =====
|
||||
const _SCAN_HISTORY_MAX = 20;
|
||||
// ===== SCAN RECENTS (localStorage) =====
|
||||
const _SCAN_RECENTS_KEY = 'evershelf_scan_recents';
|
||||
const _SCAN_RECENTS_MAX = 6;
|
||||
|
||||
function _getScanRecents() {
|
||||
try { return JSON.parse(localStorage.getItem(_SCAN_RECENTS_KEY) || '[]'); } catch(_) { return []; }
|
||||
}
|
||||
|
||||
function addToScanRecents(product) {
|
||||
if (!product || !product.id) return;
|
||||
let list = (_scanHistoryCache || []).filter(r => r.id !== product.id);
|
||||
list.unshift({ id: product.id, barcode: product.barcode || '', name: product.name, brand: product.brand || '', category: product.category || '', ts: Date.now() });
|
||||
if (list.length > _SCAN_HISTORY_MAX) list = list.slice(0, _SCAN_HISTORY_MAX);
|
||||
_scanHistoryCache = list;
|
||||
_saveToServer('scan_history', list);
|
||||
let list = _getScanRecents().filter(r => r.id !== product.id);
|
||||
list.unshift({ id: product.id, name: product.name, brand: product.brand || '', category: product.category || '' });
|
||||
if (list.length > _SCAN_RECENTS_MAX) list = list.slice(0, _SCAN_RECENTS_MAX);
|
||||
try { localStorage.setItem(_SCAN_RECENTS_KEY, JSON.stringify(list)); } catch(_) {}
|
||||
}
|
||||
|
||||
function updateScanRecents() {
|
||||
const list = (_scanHistoryCache || []).slice(0, 6);
|
||||
const list = _getScanRecents();
|
||||
const wrap = document.getElementById('scan-recents');
|
||||
const chips = document.getElementById('scan-recents-chips');
|
||||
if (!wrap || !chips) return;
|
||||
@@ -2107,64 +2038,27 @@ async function syncSettingsFromDB() {
|
||||
// Primary: load from server .env (only when not already done via _applySyncedSettings)
|
||||
const serverSettings = await api('get_settings');
|
||||
_applySyncedSettings(serverSettings);
|
||||
// Load all server-persisted user data from SQLite app_settings
|
||||
// Also load review_confirmed, meal_plan, tts_voice from DB (cross-device shared)
|
||||
const res = await api('app_settings_get');
|
||||
if (res.success && res.settings) {
|
||||
const srv = res.settings;
|
||||
|
||||
if (srv.review_confirmed) _reviewConfirmedCache = srv.review_confirmed;
|
||||
|
||||
if (res.settings.review_confirmed) {
|
||||
_reviewConfirmedCache = res.settings.review_confirmed;
|
||||
}
|
||||
// meal_plan is stored in SQLite app_settings so all devices stay in sync
|
||||
if (srv.meal_plan) {
|
||||
if (res.settings.meal_plan) {
|
||||
const s = getSettings();
|
||||
s.meal_plan = srv.meal_plan;
|
||||
s.meal_plan = res.settings.meal_plan;
|
||||
_settingsCache = s;
|
||||
localStorage.setItem('evershelf_settings', JSON.stringify(s));
|
||||
if (document.getElementById('meal-plan-grid')) renderMealPlanEditor();
|
||||
}
|
||||
// tts_voice preference (best-effort cross-device — falls back if voice unavailable)
|
||||
if (srv.tts_voice) {
|
||||
if (res.settings.tts_voice) {
|
||||
const s = getSettings();
|
||||
if (!s.tts_voice) { s.tts_voice = srv.tts_voice; _settingsCache = s; localStorage.setItem('evershelf_settings', JSON.stringify(s)); }
|
||||
}
|
||||
|
||||
// ── User data previously stored in localStorage, now server-synced ──
|
||||
if (srv.scan_history) _scanHistoryCache = srv.scan_history;
|
||||
if (srv.shopping_tags) _shoppingTagsCache = srv.shopping_tags;
|
||||
if (srv.pinned_bring) _pinnedBringCache = srv.pinned_bring;
|
||||
if (srv.pref_use_loc) _prefUseLocCache = srv.pref_use_loc;
|
||||
if (srv.pref_move_loc) _prefMoveLocCache = srv.pref_move_loc;
|
||||
if (srv.auto_added_bring) _autoAddedBringCache = srv.auto_added_bring;
|
||||
if (srv.bring_blocklist) _bringBlocklistCache = srv.bring_blocklist;
|
||||
if (srv.no_expiry_dismissed) _noExpiryDismissedCache = srv.no_expiry_dismissed;
|
||||
|
||||
// ── One-time migration: if server has nothing yet, seed from old localStorage ──
|
||||
if (!srv.shopping_tags) {
|
||||
try { const v = localStorage.getItem('shopping_tags'); if (v) { _shoppingTagsCache = JSON.parse(v); _saveToServer('shopping_tags', _shoppingTagsCache); localStorage.removeItem('shopping_tags'); } } catch(_) {}
|
||||
}
|
||||
if (!srv.pinned_bring) {
|
||||
try { const v = localStorage.getItem('_userPinnedBring'); if (v) { _pinnedBringCache = JSON.parse(v); _saveToServer('pinned_bring', _pinnedBringCache); localStorage.removeItem('_userPinnedBring'); } } catch(_) {}
|
||||
}
|
||||
if (!srv.pref_use_loc) {
|
||||
try { const v = localStorage.getItem('_prefUseLoc'); if (v) { _prefUseLocCache = JSON.parse(v); _saveToServer('pref_use_loc', _prefUseLocCache); localStorage.removeItem('_prefUseLoc'); } } catch(_) {}
|
||||
}
|
||||
if (!srv.pref_move_loc) {
|
||||
try { const v = localStorage.getItem('_prefMoveLoc'); if (v) { _prefMoveLocCache = JSON.parse(v); _saveToServer('pref_move_loc', _prefMoveLocCache); localStorage.removeItem('_prefMoveLoc'); } } catch(_) {}
|
||||
}
|
||||
if (!srv.auto_added_bring) {
|
||||
try { const v = localStorage.getItem('_autoAddedBring'); if (v) { _autoAddedBringCache = JSON.parse(v); _saveToServer('auto_added_bring', _autoAddedBringCache); localStorage.removeItem('_autoAddedBring'); } } catch(_) {}
|
||||
}
|
||||
if (!srv.bring_blocklist) {
|
||||
try { const v = localStorage.getItem('_bringPurchasedBlocklist'); if (v) { _bringBlocklistCache = JSON.parse(v); _saveToServer('bring_blocklist', _bringBlocklistCache); localStorage.removeItem('_bringPurchasedBlocklist'); } } catch(_) {}
|
||||
}
|
||||
if (!srv.no_expiry_dismissed) {
|
||||
try { const v = localStorage.getItem('_noExpiryDismissed'); if (v) { _noExpiryDismissedCache = JSON.parse(v); _saveToServer('no_expiry_dismissed', _noExpiryDismissedCache); localStorage.removeItem('_noExpiryDismissed'); } } catch(_) {}
|
||||
}
|
||||
if (!srv.scan_history) {
|
||||
try { const v = localStorage.getItem('evershelf_scan_recents'); if (v) { _scanHistoryCache = JSON.parse(v); _saveToServer('scan_history', _scanHistoryCache); localStorage.removeItem('evershelf_scan_recents'); } } catch(_) {}
|
||||
if (!s.tts_voice) { s.tts_voice = res.settings.tts_voice; _settingsCache = s; localStorage.setItem('evershelf_settings', JSON.stringify(s)); }
|
||||
}
|
||||
}
|
||||
} catch(e) { /* offline — in-memory caches stay at their defaults */ }
|
||||
} catch(e) { /* offline, use local */ }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2185,8 +2079,7 @@ function _applySyncedSettings(serverSettings) {
|
||||
'tts_method','tts_auth_type','tts_content_type','tts_payload_key',
|
||||
'tts_engine','tts_rate','tts_pitch','tts_auth_header_name','tts_auth_header_value','tts_extra_fields',
|
||||
'screensaver_enabled','screensaver_timeout',
|
||||
'price_enabled','price_country','price_currency','price_update_months',
|
||||
'zerowaste_tips_enabled'];
|
||||
'price_enabled','price_country','price_currency','price_update_months'];
|
||||
let changed = false;
|
||||
for (const key of serverKeys) {
|
||||
if (serverSettings[key] !== undefined && serverSettings[key] !== null && serverSettings[key] !== '') {
|
||||
@@ -2536,13 +2429,6 @@ async function loadSettingsUI() {
|
||||
if (nativePanel) nativePanel.style.display = '';
|
||||
}
|
||||
|
||||
// Dark mode setting
|
||||
const dmEl = document.getElementById('setting-dark-mode');
|
||||
if (dmEl) dmEl.value = s.dark_mode || 'auto';
|
||||
// Zero-waste tips setting
|
||||
const zwEl = document.getElementById('setting-zerowaste-tips');
|
||||
if (zwEl) zwEl.checked = s.zerowaste_tips_enabled === true;
|
||||
|
||||
// Populate About section version
|
||||
_loadAboutSection();
|
||||
}
|
||||
@@ -2865,12 +2751,6 @@ async function saveSettings() {
|
||||
if (ssEl) s.screensaver_enabled = ssEl.checked;
|
||||
const ssTimeoutEl = document.getElementById('setting-screensaver-timeout');
|
||||
if (ssTimeoutEl) s.screensaver_timeout = parseInt(ssTimeoutEl.value, 10) || 5;
|
||||
// Dark mode
|
||||
const dmSaveEl = document.getElementById('setting-dark-mode');
|
||||
if (dmSaveEl) { s.dark_mode = dmSaveEl.value; _applyTheme(); }
|
||||
// Zero-waste tips
|
||||
const zwSaveEl = document.getElementById('setting-zerowaste-tips');
|
||||
if (zwSaveEl) s.zerowaste_tips_enabled = zwSaveEl.checked;
|
||||
// Meal plan enabled toggle
|
||||
const mpEnabledEl = document.getElementById('setting-meal-plan-enabled');
|
||||
if (mpEnabledEl) s.meal_plan_enabled = mpEnabledEl.checked;
|
||||
@@ -4006,20 +3886,6 @@ function getReviewConfirmed() {
|
||||
return _reviewConfirmedCache || {};
|
||||
}
|
||||
let _reviewConfirmedCache = {};
|
||||
// ===== SERVER-SYNCED APP DATA CACHES =====
|
||||
// Loaded at startup from app_settings (SQLite). Reads are synchronous (from cache).
|
||||
// Writes update cache + fire-and-forget to server via app_settings_save.
|
||||
let _shoppingTagsCache = {};
|
||||
let _pinnedBringCache = {};
|
||||
let _prefUseLocCache = {};
|
||||
let _prefMoveLocCache = {};
|
||||
let _autoAddedBringCache = {};
|
||||
let _bringBlocklistCache = {};
|
||||
let _noExpiryDismissedCache = {};
|
||||
let _scanHistoryCache = [];
|
||||
function _saveToServer(key, value) {
|
||||
api('app_settings_save', {}, 'POST', { settings: { [key]: value } }).catch(() => {});
|
||||
}
|
||||
|
||||
function setReviewConfirmed(inventoryId) {
|
||||
const c = getReviewConfirmed();
|
||||
@@ -4030,14 +3896,13 @@ function setReviewConfirmed(inventoryId) {
|
||||
|
||||
/** Return map of product IDs the user has marked as "no expiry needed". */
|
||||
function _getNoExpiryDismissed() {
|
||||
return _noExpiryDismissedCache || {};
|
||||
try { return JSON.parse(localStorage.getItem('_noExpiryDismissed') || '{}'); } catch { return {}; }
|
||||
}
|
||||
/** Permanently mark a product as "no expiry needed" for this browser. */
|
||||
function _dismissNoExpiry(productId) {
|
||||
const m = Object.assign({}, _noExpiryDismissedCache || {});
|
||||
const m = _getNoExpiryDismissed();
|
||||
m[String(productId)] = Date.now();
|
||||
_noExpiryDismissedCache = m;
|
||||
_saveToServer('no_expiry_dismissed', m);
|
||||
localStorage.setItem('_noExpiryDismissed', JSON.stringify(m));
|
||||
}
|
||||
|
||||
// === ALERT BANNER SYSTEM (replaces old review table) ===
|
||||
@@ -4061,12 +3926,11 @@ async function loadBannerAlerts() {
|
||||
if (!banner) { _bannerLoading = false; console.warn('[Banner] #alert-banner not found'); return; }
|
||||
|
||||
try {
|
||||
const [invData, predData, anomalyData, finishedData, statsData] = await Promise.all([
|
||||
const [invData, predData, anomalyData, finishedData] = await Promise.all([
|
||||
api('inventory_list'),
|
||||
api('consumption_predictions').catch(err => { console.warn('[Banner] predictions fetch failed:', err); return { predictions: [] }; }),
|
||||
api('inventory_anomalies').catch(err => { console.warn('[Banner] anomalies fetch failed:', err); return { anomalies: [] }; }),
|
||||
api('inventory_finished_items').catch(err => { console.warn('[Banner] finished_items fetch failed:', err); return { finished: [] }; }),
|
||||
api('stats').catch(() => ({ opened: [] })),
|
||||
]);
|
||||
const items = invData.inventory || [];
|
||||
const confirmed = getReviewConfirmed();
|
||||
@@ -4107,18 +3971,6 @@ async function loadBannerAlerts() {
|
||||
_queuedItemIds.add(item.id);
|
||||
});
|
||||
|
||||
// 1b. Opened items the SERVER considers not edible (is_edible=false from stats).
|
||||
// The client-side getExpiredSafety check above uses conservative thresholds (e.g.
|
||||
// conserve are 'ok' for 30 days past), but the server uses product-specific AI shelf
|
||||
// life. Trust the server: any opened item with is_edible=false that isn't already
|
||||
// queued goes into the banner as expired.
|
||||
const openedNotEdible = (statsData.opened || []).filter(oi => !oi.is_edible && !_queuedItemIds.has(oi.id) && !confirmed['exp_' + oi.id]);
|
||||
openedNotEdible.forEach(oi => {
|
||||
const daysOI = Math.abs(oi.days_to_expiry ?? 0);
|
||||
_bannerQueue.push({ type: 'expired', data: { ...oi, days_expired: daysOI } });
|
||||
_queuedItemIds.add(oi.id);
|
||||
});
|
||||
|
||||
// 2. Suspicious quantities ("expiring soon" shown only in dashboard sections, not in banner)
|
||||
// Group items by product identity to detect sibling entries in other locations.
|
||||
// A "low quantity" alert is suppressed when other stock of the same product exists
|
||||
@@ -8136,28 +7988,33 @@ function selectUseLocation(btn, loc) {
|
||||
// ── PREFERRED USE LOCATION ───────────────────────────────────────────────
|
||||
// After 3+ consistent choices from the same location for a product,
|
||||
// auto-selects it and hides the location picker (user can still tap "cambia").
|
||||
const _PREF_LOC_KEY = '_prefUseLoc';
|
||||
const _PREF_LOC_NEEDED = 2; // choices needed to confirm a preference
|
||||
|
||||
// ── PREFERRED MOVE-AFTER-USE LOCATION ────────────────────────────────────
|
||||
// Tracks where the user puts the remainder after using a product.
|
||||
// After _PREF_MOVE_NEEDED consistent choices, the modal is skipped entirely.
|
||||
const _PREF_MOVE_KEY = '_prefMoveLoc';
|
||||
const _PREF_MOVE_NEEDED = 2;
|
||||
let _pendingMoveCtx = null; // { productId, fromLoc, openedId } — set before showing modal
|
||||
|
||||
function _getMoveLocHistory(productId, fromLoc) {
|
||||
const all = _prefMoveLocCache || {};
|
||||
return all[`${productId}|${fromLoc}`] || [];
|
||||
try {
|
||||
const all = JSON.parse(localStorage.getItem(_PREF_MOVE_KEY) || '{}');
|
||||
return all[`${productId}|${fromLoc}`] || [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
function _recordMoveLocChoice(productId, fromLoc, toLoc) {
|
||||
const all = Object.assign({}, _prefMoveLocCache || {});
|
||||
const key = `${productId}|${fromLoc}`;
|
||||
const hist = (all[key] || []).slice();
|
||||
hist.push(toLoc);
|
||||
if (hist.length > 8) hist.splice(0, hist.length - 8);
|
||||
all[key] = hist;
|
||||
_prefMoveLocCache = all;
|
||||
_saveToServer('pref_move_loc', all);
|
||||
try {
|
||||
const all = JSON.parse(localStorage.getItem(_PREF_MOVE_KEY) || '{}');
|
||||
const key = `${productId}|${fromLoc}`;
|
||||
const hist = all[key] || [];
|
||||
hist.push(toLoc);
|
||||
if (hist.length > 8) hist.splice(0, hist.length - 8);
|
||||
all[key] = hist;
|
||||
localStorage.setItem(_PREF_MOVE_KEY, JSON.stringify(all));
|
||||
} catch { }
|
||||
}
|
||||
|
||||
function _getPreferredMoveLoc(productId, fromLoc) {
|
||||
@@ -8171,19 +8028,22 @@ function _getPreferredMoveLoc(productId, fromLoc) {
|
||||
}
|
||||
|
||||
function _getPrefLocHistory(productId) {
|
||||
const all = _prefUseLocCache || {};
|
||||
return all[String(productId)] || [];
|
||||
try {
|
||||
const all = JSON.parse(localStorage.getItem(_PREF_LOC_KEY) || '{}');
|
||||
return all[String(productId)] || [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
function _recordUseLocationChoice(productId, loc) {
|
||||
const all = Object.assign({}, _prefUseLocCache || {});
|
||||
const key = String(productId);
|
||||
const hist = (all[key] || []).slice();
|
||||
hist.push(loc);
|
||||
if (hist.length > 8) hist.splice(0, hist.length - 8);
|
||||
all[key] = hist;
|
||||
_prefUseLocCache = all;
|
||||
_saveToServer('pref_use_loc', all);
|
||||
try {
|
||||
const all = JSON.parse(localStorage.getItem(_PREF_LOC_KEY) || '{}');
|
||||
const key = String(productId);
|
||||
const hist = all[key] || [];
|
||||
hist.push(loc);
|
||||
if (hist.length > 8) hist.splice(0, hist.length - 8); // keep last 8
|
||||
all[key] = hist;
|
||||
localStorage.setItem(_PREF_LOC_KEY, JSON.stringify(all));
|
||||
} catch { }
|
||||
}
|
||||
|
||||
function _getPreferredUseLocation(productId) {
|
||||
@@ -8459,10 +8319,9 @@ async function addLowStockToBring() {
|
||||
const data = await api('bring_add', {}, 'POST', payload);
|
||||
if (data.success && data.added > 0) {
|
||||
// Pin as user-added so cleanup never auto-removes it
|
||||
const pinned = Object.assign({}, _pinnedBringCache || {});
|
||||
const pinned = JSON.parse(localStorage.getItem('_userPinnedBring') || '{}');
|
||||
pinned[bringName.toLowerCase()] = Date.now();
|
||||
_pinnedBringCache = pinned;
|
||||
_saveToServer('pinned_bring', pinned);
|
||||
localStorage.setItem('_userPinnedBring', JSON.stringify(pinned));
|
||||
showToast(t('shopping.added_to_bring').replace('{n}', data.added), 'success');
|
||||
} else if (data.success && data.skipped > 0) {
|
||||
showToast(t('shopping.already_in_list_short'), 'info');
|
||||
@@ -9437,26 +9296,27 @@ function updateShoppingTabCounts() {
|
||||
document.getElementById('shopping-tabs')?.style.setProperty('display', 'flex');
|
||||
}
|
||||
|
||||
// ===== LOCAL SHOPPING TAGS (server-synced) =====
|
||||
// ===== LOCAL SHOPPING TAGS =====
|
||||
function getShoppingTags(itemName) {
|
||||
const tags = _shoppingTagsCache || {};
|
||||
return tags[itemName.toLowerCase()] || [];
|
||||
try {
|
||||
const tags = JSON.parse(localStorage.getItem('shopping_tags') || '{}');
|
||||
return tags[itemName.toLowerCase()] || [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
function toggleShoppingTag(itemIdx, tag) {
|
||||
const item = shoppingItems[itemIdx];
|
||||
if (!item) return;
|
||||
const key = item.name.toLowerCase();
|
||||
try {
|
||||
const key = item.name.toLowerCase();
|
||||
const tags = Object.assign({}, _shoppingTagsCache || {});
|
||||
const existing = (tags[key] || []).slice();
|
||||
const tags = JSON.parse(localStorage.getItem('shopping_tags') || '{}');
|
||||
const existing = tags[key] || [];
|
||||
const pos = existing.indexOf(tag);
|
||||
if (pos >= 0) existing.splice(pos, 1);
|
||||
else existing.push(tag);
|
||||
if (existing.length) tags[key] = existing;
|
||||
else delete tags[key];
|
||||
_shoppingTagsCache = tags;
|
||||
_saveToServer('shopping_tags', tags);
|
||||
localStorage.setItem('shopping_tags', JSON.stringify(tags));
|
||||
|
||||
// Sync urgente/presto tag to Bring specification so it's visible in the Bring app
|
||||
if (tag === 'urgente' && shoppingListUUID) {
|
||||
@@ -9518,57 +9378,54 @@ function _urgencyToSpec(urgency, brand) {
|
||||
* function only ever removes those, never manually-added ones.
|
||||
*/
|
||||
function _getAutoAddedBring() {
|
||||
const map = Object.assign({}, _autoAddedBringCache || {});
|
||||
const now = Date.now();
|
||||
let changed = false;
|
||||
for (const k of Object.keys(map)) {
|
||||
if (now - map[k] > 30 * 24 * 60 * 60 * 1000) { delete map[k]; changed = true; }
|
||||
}
|
||||
if (changed) {
|
||||
_autoAddedBringCache = map;
|
||||
_saveToServer('auto_added_bring', map);
|
||||
}
|
||||
return map;
|
||||
try {
|
||||
const raw = localStorage.getItem('_autoAddedBring');
|
||||
const map = raw ? JSON.parse(raw) : {};
|
||||
const now = Date.now();
|
||||
let changed = false;
|
||||
for (const k of Object.keys(map)) {
|
||||
if (now - map[k] > 30 * 24 * 60 * 60 * 1000) { delete map[k]; changed = true; }
|
||||
}
|
||||
if (changed) localStorage.setItem('_autoAddedBring', JSON.stringify(map));
|
||||
return map;
|
||||
} catch(e) { return {}; }
|
||||
}
|
||||
function _markAutoAddedBring(names) {
|
||||
const map = _getAutoAddedBring();
|
||||
const now = Date.now();
|
||||
for (const n of names) map[n.toLowerCase()] = now;
|
||||
_autoAddedBringCache = map;
|
||||
_saveToServer('auto_added_bring', map);
|
||||
localStorage.setItem('_autoAddedBring', JSON.stringify(map));
|
||||
}
|
||||
function _unmarkAutoAddedBring(names) {
|
||||
const map = _getAutoAddedBring();
|
||||
for (const n of names) delete map[n.toLowerCase()];
|
||||
_autoAddedBringCache = map;
|
||||
_saveToServer('auto_added_bring', map);
|
||||
localStorage.setItem('_autoAddedBring', JSON.stringify(map));
|
||||
}
|
||||
|
||||
// ===== BRING! PURCHASED BLOCKLIST (server-synced) =====
|
||||
// ===== BRING! PURCHASED BLOCKLIST =====
|
||||
// When an item disappears from Bring (user bought it), we block auto-re-add for 4h.
|
||||
const _BRING_PURCHASED_TTL = 4 * 60 * 60 * 1000; // 4 hours
|
||||
|
||||
function _getBringPurchasedBlocklist() {
|
||||
const map = Object.assign({}, _bringBlocklistCache || {});
|
||||
const now = Date.now();
|
||||
// Prune expired entries
|
||||
let changed = false;
|
||||
for (const key of Object.keys(map)) {
|
||||
if (now - map[key] > _BRING_PURCHASED_TTL) { delete map[key]; changed = true; }
|
||||
}
|
||||
if (changed) {
|
||||
_bringBlocklistCache = map;
|
||||
_saveToServer('bring_blocklist', map);
|
||||
}
|
||||
return map;
|
||||
try {
|
||||
const raw = localStorage.getItem('_bringPurchasedBlocklist');
|
||||
const map = raw ? JSON.parse(raw) : {};
|
||||
const now = Date.now();
|
||||
// Prune expired entries
|
||||
let changed = false;
|
||||
for (const key of Object.keys(map)) {
|
||||
if (now - map[key] > _BRING_PURCHASED_TTL) { delete map[key]; changed = true; }
|
||||
}
|
||||
if (changed) localStorage.setItem('_bringPurchasedBlocklist', JSON.stringify(map));
|
||||
return map;
|
||||
} catch(e) { return {}; }
|
||||
}
|
||||
|
||||
function _markBringPurchased(names) {
|
||||
const map = _getBringPurchasedBlocklist();
|
||||
const now = Date.now();
|
||||
for (const n of names) map[n.toLowerCase()] = now;
|
||||
_bringBlocklistCache = map;
|
||||
_saveToServer('bring_blocklist', map);
|
||||
localStorage.setItem('_bringPurchasedBlocklist', JSON.stringify(map));
|
||||
}
|
||||
|
||||
function _isBringPurchased(name, urgency) {
|
||||
@@ -9631,10 +9488,10 @@ async function forceSyncBring() {
|
||||
if (btn) { btn.disabled = true; btn.textContent = `⏳ ${t('shopping.syncing')}`; }
|
||||
// Clear auto-add/cleanup guards so the next run is unconditional.
|
||||
// Do NOT clear _userPinnedBring — items the user manually added must stay protected.
|
||||
_bringBlocklistCache = {}; _saveToServer('bring_blocklist', {});
|
||||
localStorage.removeItem('_bringPurchasedBlocklist');
|
||||
localStorage.removeItem('_autoAddedCriticalTs');
|
||||
localStorage.removeItem('_bringCleanupTs');
|
||||
_autoAddedBringCache = {}; _saveToServer('auto_added_bring', {});
|
||||
localStorage.removeItem('_autoAddedBring');
|
||||
logOperation('force_sync_bring', {});
|
||||
// Reload everything from scratch
|
||||
await loadShoppingList();
|
||||
@@ -9884,10 +9741,10 @@ async function fetchAllPrices(forceRefresh = false) {
|
||||
if (btn) { btn.disabled = true; btn.textContent = `⏳ ${t('shopping.syncing')}`; }
|
||||
// Clear auto-add/cleanup guards so the next run is unconditional.
|
||||
// Do NOT clear _userPinnedBring — items the user manually added must stay protected.
|
||||
_bringBlocklistCache = {}; _saveToServer('bring_blocklist', {});
|
||||
localStorage.removeItem('_bringPurchasedBlocklist');
|
||||
localStorage.removeItem('_autoAddedCriticalTs');
|
||||
localStorage.removeItem('_bringCleanupTs');
|
||||
_autoAddedBringCache = {}; _saveToServer('auto_added_bring', {});
|
||||
localStorage.removeItem('_autoAddedBring');
|
||||
logOperation('force_sync_bring', {});
|
||||
// Reload everything from scratch
|
||||
await loadShoppingList();
|
||||
@@ -10443,11 +10300,10 @@ async function addSmartToBring() {
|
||||
showToast(msg, result.added > 0 ? 'success' : 'info');
|
||||
// Mark all manually-added items as user-pinned so cleanupObsoleteBringItems never removes them
|
||||
if (result.added > 0) {
|
||||
const pinned = Object.assign({}, _pinnedBringCache || {});
|
||||
const pinned = JSON.parse(localStorage.getItem('_userPinnedBring') || '{}');
|
||||
const now = Date.now();
|
||||
for (const it of itemsToAdd) pinned[it.name.toLowerCase()] = now;
|
||||
_pinnedBringCache = pinned;
|
||||
_saveToServer('pinned_bring', pinned);
|
||||
localStorage.setItem('_userPinnedBring', JSON.stringify(pinned));
|
||||
}
|
||||
// Reload to refresh badges
|
||||
loadShoppingList();
|
||||
@@ -10500,12 +10356,12 @@ async function loadShoppingCount() {
|
||||
*/
|
||||
function _syncTagsFromBringSpec() {
|
||||
try {
|
||||
const tags = Object.assign({}, _shoppingTagsCache || {});
|
||||
const tags = JSON.parse(localStorage.getItem('shopping_tags') || '{}');
|
||||
let changed = false;
|
||||
for (const item of shoppingItems) {
|
||||
const key = item.name.toLowerCase();
|
||||
const spec = (item.specification || '').toLowerCase();
|
||||
const existing = (tags[key] || []).slice();
|
||||
const existing = tags[key] || [];
|
||||
const hasUrgente = existing.includes('urgente');
|
||||
const smartMatch = _matchBringToSmart(item.name, smartShoppingItems);
|
||||
const smartIsCritical = smartMatch && (smartMatch.urgency === 'critical' || smartMatch.urgency === 'high');
|
||||
@@ -10520,10 +10376,7 @@ function _syncTagsFromBringSpec() {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
_shoppingTagsCache = tags;
|
||||
_saveToServer('shopping_tags', tags);
|
||||
}
|
||||
if (changed) localStorage.setItem('shopping_tags', JSON.stringify(tags));
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
@@ -12436,7 +12289,6 @@ function startCookingMode() {
|
||||
_cookingRecipe = JSON.parse(JSON.stringify(recipe));
|
||||
_cookingStep = 0;
|
||||
_cookingVisited = new Set();
|
||||
_dismissedZeroWasteTips = new Set();
|
||||
clearAllCookingTimers();
|
||||
}
|
||||
_cookingTTS = true;
|
||||
@@ -12486,7 +12338,6 @@ function restartCookingMode() {
|
||||
_cookingStep = 0;
|
||||
_cookingWheelLastDelta = 0;
|
||||
_cookingVisited = new Set();
|
||||
_dismissedZeroWasteTips = new Set();
|
||||
clearAllCookingTimers();
|
||||
renderCookingStep();
|
||||
}
|
||||
@@ -12708,42 +12559,10 @@ function renderCookingStep() {
|
||||
// Timer: detect duration in step text and show suggestion
|
||||
setupCookingTimerSuggestion(cleanStep);
|
||||
|
||||
// Zero-waste tip for this step
|
||||
_renderZeroWasteTip(_cookingStep);
|
||||
|
||||
// TTS: auto-speak is handled by navigateCookingStep() and startCookingMode() callers.
|
||||
// Use replayCookingTTS() to re-read the current step manually ("Rileggi" button).
|
||||
}
|
||||
|
||||
// ===== ZERO-WASTE TIPS =====
|
||||
let _dismissedZeroWasteTips = new Set(); // dismissed tip indices for this cooking session
|
||||
|
||||
function _renderZeroWasteTip(stepIdx) {
|
||||
const tipEl = document.getElementById('cooking-zerowaste-tip');
|
||||
if (!tipEl) return;
|
||||
// Check setting
|
||||
const s = getSettings();
|
||||
if (!s.zerowaste_tips_enabled) { tipEl.style.display = 'none'; return; }
|
||||
// Already dismissed for this step in this session
|
||||
if (_dismissedZeroWasteTips.has(stepIdx)) { tipEl.style.display = 'none'; return; }
|
||||
// Find tip for current step
|
||||
const tips = (_cookingRecipe && _cookingRecipe.zero_waste_tips) || [];
|
||||
const tip = tips.find(t => t.step === stepIdx);
|
||||
if (!tip) { tipEl.style.display = 'none'; return; }
|
||||
// Populate and show
|
||||
const scrapEl = document.getElementById('cooking-zerowaste-scrap');
|
||||
const textEl = document.getElementById('cooking-zerowaste-text');
|
||||
if (scrapEl) scrapEl.textContent = tip.scrap || '';
|
||||
if (textEl) textEl.textContent = tip.tip || '';
|
||||
tipEl.style.display = 'flex';
|
||||
}
|
||||
|
||||
function _dismissZeroWasteTip() {
|
||||
_dismissedZeroWasteTips.add(_cookingStep);
|
||||
const tipEl = document.getElementById('cooking-zerowaste-tip');
|
||||
if (tipEl) tipEl.style.display = 'none';
|
||||
}
|
||||
|
||||
function _buildTtsRequest(text, s) {
|
||||
const url = s.tts_url || '';
|
||||
const method = s.tts_method || 'POST';
|
||||
@@ -14457,8 +14276,6 @@ function initSpesaMode() {
|
||||
if (!btn) return;
|
||||
|
||||
btn.addEventListener('pointerdown', (e) => {
|
||||
e.preventDefault(); // prevent browser-generated synthetic click + 300ms delay
|
||||
btn.setPointerCapture(e.pointerId); // ensure pointerup always fires on this element even if finger drifts
|
||||
_longPressTimer = setTimeout(() => {
|
||||
_longPressTimer = null;
|
||||
startSpesaMode();
|
||||
@@ -14472,14 +14289,12 @@ function initSpesaMode() {
|
||||
showPage('scan');
|
||||
}
|
||||
});
|
||||
btn.addEventListener('pointercancel', () => {
|
||||
// OS cancelled gesture (e.g. home swipe) — discard timer, do nothing
|
||||
btn.addEventListener('pointerleave', () => {
|
||||
if (_longPressTimer) {
|
||||
clearTimeout(_longPressTimer);
|
||||
_longPressTimer = null;
|
||||
}
|
||||
});
|
||||
// Note: no pointerleave handler needed — setPointerCapture prevents it from firing during touch
|
||||
}
|
||||
|
||||
function startSpesaMode() {
|
||||
@@ -14687,11 +14502,9 @@ function _setupSteps() {
|
||||
`
|
||||
},
|
||||
{
|
||||
title: '✅ ' + (_currentLang === 'it' ? 'Tutto pronto!' : _currentLang === 'de' ? 'Alles bereit!' : _currentLang === 'fr' ? 'Tout est prêt !' : _currentLang === 'es' ? '¡Todo listo!' : 'All set!'),
|
||||
title: '✅ ' + (_currentLang === 'it' ? 'Tutto pronto!' : _currentLang === 'de' ? 'Alles bereit!' : 'All set!'),
|
||||
desc: _currentLang === 'it' ? 'La configurazione è completata. Puoi sempre modificare queste impostazioni dalla pagina Configurazione.'
|
||||
: _currentLang === 'de' ? 'Die Konfiguration ist abgeschlossen. Du kannst diese Einstellungen jederzeit ändern.'
|
||||
: _currentLang === 'fr' ? 'La configuration est terminée. Vous pouvez toujours modifier ces paramètres depuis la page Paramètres.'
|
||||
: _currentLang === 'es' ? 'La configuración está completa. Puedes cambiar estos ajustes desde la página Ajustes.'
|
||||
: 'Setup is complete. You can always change these settings from the Settings page.',
|
||||
render: () => {
|
||||
let summary = '<div style="text-align:center;font-size:2.5rem;margin:12px 0">🎉</div>';
|
||||
@@ -14741,9 +14554,9 @@ function _renderSetupStep() {
|
||||
prevBtn.textContent = t('btn.back');
|
||||
|
||||
if (_setupStep === totalPending - 1) {
|
||||
nextBtn.textContent = _currentLang === 'it' ? '🚀 Inizia!' : _currentLang === 'de' ? '🚀 Los geht\'s!' : _currentLang === 'fr' ? '🚀 Allons-y !' : _currentLang === 'es' ? '🚀 ¡Empezar!' : '🚀 Start!';
|
||||
nextBtn.textContent = _currentLang === 'it' ? '🚀 Inizia!' : _currentLang === 'de' ? '🚀 Los geht\'s!' : '🚀 Start!';
|
||||
} else {
|
||||
nextBtn.textContent = _currentLang === 'it' ? 'Avanti →' : _currentLang === 'de' ? 'Weiter →' : _currentLang === 'fr' ? 'Suivant →' : _currentLang === 'es' ? 'Siguiente →' : 'Next →';
|
||||
nextBtn.textContent = _currentLang === 'it' ? 'Avanti →' : _currentLang === 'de' ? 'Weiter →' : 'Next →';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14874,249 +14687,12 @@ function _heartbeatRetry() {
|
||||
_runHeartbeat();
|
||||
}
|
||||
|
||||
// ── Startup / Splash health check ────────────────────────────────────────────
|
||||
/**
|
||||
* Run a comprehensive server-side diagnostic during the splash screen.
|
||||
* Shows a real-time progress bar + current check label.
|
||||
* Returns true if the app can proceed, false if a critical check failed.
|
||||
*/
|
||||
async function _runStartupCheck() {
|
||||
const spinnerEl = document.getElementById('preloader-spinner');
|
||||
const wrapEl = document.getElementById('preloader-progress-wrap');
|
||||
const barEl = document.getElementById('preloader-bar');
|
||||
const labelEl = document.getElementById('preloader-check-label');
|
||||
const warningsEl = document.getElementById('preloader-warnings');
|
||||
const errorEl = document.getElementById('preloader-error-msg');
|
||||
const retryBtn = document.getElementById('preloader-retry-btn');
|
||||
|
||||
if (!wrapEl) return true; // preloader already removed
|
||||
|
||||
const tl = (key, fallback) => { try { return t('startup.' + key); } catch(e) { return fallback; } };
|
||||
|
||||
// Switch from spinner to progress bar
|
||||
if (spinnerEl) spinnerEl.style.display = 'none';
|
||||
wrapEl.style.display = '';
|
||||
|
||||
// Helper: set progress bar + label
|
||||
let _curPct = 0;
|
||||
const setProgress = (pct, label, state) => {
|
||||
_curPct = pct;
|
||||
if (barEl) {
|
||||
barEl.style.width = pct + '%';
|
||||
barEl.className = 'preloader-bar' + (state === 'error' ? ' bar-error' : state === 'warn' ? ' bar-warn' : '');
|
||||
}
|
||||
if (labelEl) labelEl.textContent = label || '';
|
||||
};
|
||||
|
||||
// Phase 1: animate 0→15% while fetching (so it never looks stuck)
|
||||
setProgress(0, tl('connecting', 'Connessione al server...'));
|
||||
let _fetchDone = false;
|
||||
const slowAnim = setInterval(() => {
|
||||
if (!_fetchDone && _curPct < 13) setProgress(_curPct + 1, labelEl?.textContent);
|
||||
}, 100);
|
||||
|
||||
// Make the request
|
||||
let result = null;
|
||||
try {
|
||||
const ctrl = new AbortController();
|
||||
const tid = setTimeout(() => ctrl.abort(), 12000);
|
||||
const resp = await fetch('api/index.php?action=health_check', { signal: ctrl.signal });
|
||||
clearTimeout(tid);
|
||||
result = await resp.json();
|
||||
} catch(e) {
|
||||
clearInterval(slowAnim);
|
||||
_showStartupErrorPopup(
|
||||
tl('error_network', 'Impossibile contattare il server'),
|
||||
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');
|
||||
return false;
|
||||
}
|
||||
clearInterval(slowAnim);
|
||||
_fetchDone = true;
|
||||
|
||||
// ── Ordered check definitions (must match PHP keys) ───────────────────────
|
||||
const CHECKS = [
|
||||
// PHP runtime
|
||||
{ key: 'php_version', label: 'PHP', critical: true },
|
||||
{ key: 'ext_pdo_sqlite', label: 'PDO SQLite', critical: true },
|
||||
{ key: 'ext_curl', label: 'cURL', critical: true },
|
||||
{ key: 'ext_json', label: 'JSON', critical: true },
|
||||
{ key: 'ext_mbstring', label: 'mbstring', critical: true },
|
||||
{ key: 'ext_openssl', label: 'OpenSSL', critical: false },
|
||||
{ key: 'ext_fileinfo', label: 'Fileinfo', critical: false },
|
||||
{ key: 'ext_zip', label: 'ZIP', critical: false },
|
||||
{ key: 'ext_intl', label: 'Intl', critical: false },
|
||||
{ key: 'php_memory', label: tl('check_php_memory', 'Memoria PHP'), critical: false },
|
||||
{ key: 'php_max_exec', label: tl('check_php_timeout', 'Timeout PHP'), critical: false },
|
||||
{ key: 'php_upload', label: tl('check_php_upload', 'Upload PHP'), critical: false },
|
||||
// Filesystem
|
||||
{ key: 'data_dir', label: tl('check_data_dir', 'Cartella dati'), critical: true },
|
||||
{ key: 'data_rate_limits', label: tl('check_rate_limits', 'Rate limits dir'), critical: false },
|
||||
{ key: 'data_backups', label: tl('check_backups', 'Backup dir'), critical: false },
|
||||
{ key: 'data_write_test', label: tl('check_write_test', 'Test scrittura'), critical: true },
|
||||
{ key: 'disk_space', label: tl('check_disk_space', 'Spazio disco'), critical: false },
|
||||
// Database
|
||||
{ key: 'db_legacy', label: tl('check_db_legacy', 'DB legacy'), critical: false },
|
||||
{ key: 'db_connect', label: tl('check_db_connect', 'Connessione DB'), critical: true },
|
||||
{ key: 'db_tables', label: tl('check_db_tables', 'Tabelle DB'), critical: true },
|
||||
{ key: 'db_integrity', label: tl('check_db_integrity','Integrità DB'), critical: true },
|
||||
{ key: 'db_wal', label: tl('check_db_wal', 'WAL mode'), critical: false },
|
||||
{ key: 'db_size', label: tl('check_db_size', 'Dimensione DB'), critical: false },
|
||||
{ key: 'db_row_count', label: tl('check_db_rows', 'Dati inventario'), critical: false },
|
||||
// Config & optional features
|
||||
{ key: 'env_file', label: tl('check_env', 'File .env'), critical: false },
|
||||
{ key: 'gemini_key', label: tl('check_gemini', 'Gemini AI key'), critical: false },
|
||||
{ key: 'bring_credentials', label: tl('check_bring_creds', 'Bring! credenziali'), critical: false },
|
||||
{ key: 'bring_token', label: tl('check_bring_token', 'Bring! token'), critical: false },
|
||||
{ key: 'tts_url', label: tl('check_tts', 'TTS URL'), critical: false },
|
||||
{ key: 'scale_gateway', label: tl('check_scale', 'Scale gateway'), critical: false },
|
||||
// Network
|
||||
{ key: 'curl_ssl', label: tl('check_curl_ssl', 'cURL SSL'), critical: false },
|
||||
{ key: 'internet', label: tl('check_internet', 'Internet'), critical: false },
|
||||
];
|
||||
|
||||
const checks = result.checks || {};
|
||||
const warnings = [];
|
||||
const errors = [];
|
||||
const total = CHECKS.filter(d => checks[d.key] !== undefined).length;
|
||||
let done = 0;
|
||||
|
||||
// Phase 2: step through each check with animated label
|
||||
for (const def of CHECKS) {
|
||||
const c = checks[def.key];
|
||||
if (c === undefined) continue; // not returned by server (feature not enabled)
|
||||
|
||||
done++;
|
||||
const pct = 15 + Math.round((done / total) * 83); // 15→98%
|
||||
const isOk = c.ok === true;
|
||||
const isOpt = c.optional === true || !def.critical;
|
||||
const isFresh = c.fresh === true;
|
||||
|
||||
// Build label with value
|
||||
let lbl = def.label;
|
||||
if (c.value) lbl += ` (${c.value})`;
|
||||
if (isFresh) lbl += ` — ${tl('fresh_install', 'nuovo impianto')}`;
|
||||
if (!isOk && c.error) lbl += ` — ${c.error}`;
|
||||
if (!isOk && c.missing?.length) lbl += ` — mancanti: ${c.missing.join(', ')}`;
|
||||
|
||||
const icon = isOk ? '✅' : isOpt ? '⚠️' : '❌';
|
||||
setProgress(pct, `${icon} ${lbl}`);
|
||||
|
||||
if (!isOk && !isFresh) {
|
||||
(isOpt ? warnings : errors).push({ def, c });
|
||||
}
|
||||
|
||||
await new Promise(r => setTimeout(r, 40));
|
||||
}
|
||||
|
||||
// ── Errors → red bar + blocking popup ────────────────────────────────────
|
||||
if (errors.length > 0) {
|
||||
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);
|
||||
return `❌ ${e.def.label}${hint ? '\n → ' + hint : ''}`;
|
||||
}).join('\n\n');
|
||||
_showStartupErrorPopup(
|
||||
tl('critical_error_short', 'Errore critico'),
|
||||
tl('critical_error_intro', 'L\'app non può avviarsi a causa dei seguenti problemi:') + '\n\n' + errLines,
|
||||
errorEl, retryBtn
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Warnings → amber bar + warning popup auto-close 5s ───────────────────
|
||||
if (warnings.length > 0) {
|
||||
setProgress(100, `⚠️ ${warnings.length} ${tl('warnings_found', 'avvisi')}`, 'warn');
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
|
||||
// Build warning popup (auto-close 5s)
|
||||
_showStartupWarningPopup(warnings, warningsEl, tl);
|
||||
|
||||
// Wait for user to read (5s) then proceed
|
||||
await new Promise(r => setTimeout(r, 5200));
|
||||
|
||||
// Hide warning popup
|
||||
warningsEl.style.display = 'none';
|
||||
} else {
|
||||
setProgress(100, `✅ ${tl('all_ok', 'Sistema OK')}`);
|
||||
await new Promise(r => setTimeout(r, 600));
|
||||
}
|
||||
|
||||
wrapEl.style.display = 'none';
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Builds and shows the warning popup with countdown (auto-closes after 5s). */
|
||||
function _showStartupWarningPopup(warnings, container, tl) {
|
||||
const lines = warnings.map(w => {
|
||||
const hint = w.c.hint || null;
|
||||
return `<div class="startup-warn-item">
|
||||
<span class="startup-warn-icon">⚠️</span>
|
||||
<div class="startup-warn-body">
|
||||
<strong>${w.def.label}</strong>
|
||||
${hint ? `<p>${hint}</p>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="startup-popup startup-popup-warn">
|
||||
<div class="startup-popup-header">
|
||||
<span>⚠️ ${warnings.length} ${tl('warnings_found', 'avviso/i rilevato/i')}</span>
|
||||
<span class="startup-popup-countdown" id="startup-countdown">5</span>
|
||||
</div>
|
||||
<div class="startup-popup-body">${lines}</div>
|
||||
<div class="startup-popup-bar-wrap"><div class="startup-popup-bar" id="startup-popup-bar"></div></div>
|
||||
</div>`;
|
||||
container.style.display = '';
|
||||
|
||||
// Animate countdown bar
|
||||
const barEl = document.getElementById('startup-popup-bar');
|
||||
const cntEl = document.getElementById('startup-countdown');
|
||||
if (barEl) {
|
||||
barEl.style.transition = 'none';
|
||||
barEl.style.width = '100%';
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
barEl.style.transition = 'width 5s linear';
|
||||
barEl.style.width = '0%';
|
||||
});
|
||||
});
|
||||
}
|
||||
let secs = 4;
|
||||
const t = setInterval(() => {
|
||||
if (cntEl) cntEl.textContent = secs;
|
||||
secs--;
|
||||
if (secs < 0) clearInterval(t);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/** Shows a blocking error in the preloader (no auto-close). */
|
||||
function _showStartupErrorPopup(title, detail, errorEl, retryBtn) {
|
||||
if (!errorEl) return;
|
||||
errorEl.innerHTML = `<strong>${title}</strong>\n${detail}`;
|
||||
errorEl.style.display = '';
|
||||
if (retryBtn) retryBtn.style.display = '';
|
||||
}
|
||||
|
||||
/** Retry button handler in the startup error screen. */
|
||||
function _startupRetry() {
|
||||
location.reload();
|
||||
}
|
||||
|
||||
/** Start the heartbeat loop (called once from _initApp). */
|
||||
function startHeartbeat() {
|
||||
_runHeartbeat(); // immediate first probe
|
||||
}
|
||||
|
||||
async function _initApp() {
|
||||
// ── Startup health check (runs during splash, blocks app if critical) ──────
|
||||
const _startupOk = await _runStartupCheck();
|
||||
if (!_startupOk) return; // preloader stays visible with error; app does not start
|
||||
|
||||
// Check for setup wizard resume (after language change)
|
||||
const resumeStep = localStorage.getItem('evershelf_setup_step');
|
||||
const resumeData = localStorage.getItem('evershelf_setup_data');
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
{
|
||||
"226887def70e33ef73290ebfe75ed4d0": {
|
||||
"days": 7,
|
||||
"source": "ai",
|
||||
"name": "Polpa di pomodoro finissima",
|
||||
"location": "frigo",
|
||||
"ts": 1777444819
|
||||
},
|
||||
"0ed51c9496aa9edfe38caf41772f54ed": {
|
||||
"days": 7,
|
||||
"source": "rule",
|
||||
"name": "Latte di Montagna",
|
||||
"location": "frigo",
|
||||
"ts": 1777444820
|
||||
},
|
||||
"2d63d0216a75d46b465150e925d2e7ad": {
|
||||
"days": 30,
|
||||
"source": "rule",
|
||||
"name": "Burro",
|
||||
"location": "frigo",
|
||||
"ts": 1777444821
|
||||
},
|
||||
"9afdf35c4a256867ef47c32495349eb6": {
|
||||
"days": 5,
|
||||
"source": "rule",
|
||||
"name": "Yaourt Vanille",
|
||||
"location": "frigo",
|
||||
"ts": 1777480477
|
||||
},
|
||||
"584f57418733a1f2acd29fe2e8816129": {
|
||||
"days": 5,
|
||||
"source": "rule",
|
||||
"name": "Passata di pomodoro",
|
||||
"location": "frigo",
|
||||
"ts": 1778133522
|
||||
},
|
||||
"baeb7f2021b4bb91c368c9131a61f07c": {
|
||||
"days": 10,
|
||||
"source": "rule",
|
||||
"name": "Formaggio Monte Maria",
|
||||
"location": "frigo",
|
||||
"ts": 1778133523
|
||||
},
|
||||
"063f2d534407214786d039bb2bffbb93": {
|
||||
"days": 5,
|
||||
"source": "rule",
|
||||
"name": "Carote",
|
||||
"location": "frigo",
|
||||
"ts": 1778133524
|
||||
},
|
||||
"10a3d07c19bb1f889ebc9293862b4b36": {
|
||||
"days": 60,
|
||||
"source": "rule",
|
||||
"name": "Ovomaltine",
|
||||
"location": "dispensa",
|
||||
"ts": 1778419084
|
||||
},
|
||||
"0fbad7ccd8b6155c06aaa6b3c17a67d3": {
|
||||
"days": 365,
|
||||
"source": "rule",
|
||||
"name": "Linguine pasta di Gragnano Igp",
|
||||
"location": "dispensa",
|
||||
"ts": 1778419084
|
||||
},
|
||||
"b4a03e7356e7a0983b9c8af5f3cd8c57": {
|
||||
"days": 60,
|
||||
"source": "rule",
|
||||
"name": "Polpa di pomodoro finissima",
|
||||
"location": "dispensa",
|
||||
"ts": 1778419085
|
||||
},
|
||||
"b8334ff0febd5c0440c9b24c9f3132ed": {
|
||||
"days": 180,
|
||||
"source": "rule",
|
||||
"name": "Basilico tritato surgelato",
|
||||
"location": "freezer",
|
||||
"ts": 1778419086
|
||||
},
|
||||
"0cb14384d0ba763ccf12e079d6aa8d34": {
|
||||
"days": 60,
|
||||
"source": "rule",
|
||||
"name": "Salsa Pronta Ciliegini",
|
||||
"location": "dispensa",
|
||||
"ts": 1778419086
|
||||
},
|
||||
"188634f49edb8b014a46942ee9fad689": {
|
||||
"days": 180,
|
||||
"source": "rule",
|
||||
"name": "Farina Barilla",
|
||||
"location": "dispensa",
|
||||
"ts": 1778419204
|
||||
},
|
||||
"c8db359d8709c69a95f0e6f68216d220": {
|
||||
"days": 9999,
|
||||
"source": "rule",
|
||||
"name": "Bicarbonato",
|
||||
"location": "dispensa",
|
||||
"ts": 1778419205
|
||||
},
|
||||
"a6d16a09fd9a6bfbd0a915f05dd71780": {
|
||||
"days": 7,
|
||||
"source": "ai",
|
||||
"name": "Salsa Pronta Ciliegini",
|
||||
"location": "frigo",
|
||||
"ts": 1778419205
|
||||
},
|
||||
"4f8f1bb04a00e5fc62d7a9cfb21e1796": {
|
||||
"days": 365,
|
||||
"source": "rule",
|
||||
"name": "Riso Chicchi Ricchi Gran Risparmio",
|
||||
"location": "dispensa",
|
||||
"ts": 1778419206
|
||||
},
|
||||
"e116e4c11084a463f9aaac02e1749fe7": {
|
||||
"days": 90,
|
||||
"source": "rule",
|
||||
"name": "Salsa di soia",
|
||||
"location": "dispensa",
|
||||
"ts": 1778419207
|
||||
},
|
||||
"b1ad9afd4139b3f225b79af4dae256ce": {
|
||||
"days": 60,
|
||||
"source": "rule",
|
||||
"name": "Tè Al limone",
|
||||
"location": "dispensa",
|
||||
"ts": 1778419504
|
||||
},
|
||||
"7ff2b7d326dcba52a664cebbf12f78a2": {
|
||||
"days": 3,
|
||||
"source": "ai",
|
||||
"name": "Piselli fini 1\/2 vapore",
|
||||
"location": "frigo",
|
||||
"ts": 1778419505
|
||||
},
|
||||
"71062dc7ffd82b3ee4f40bad076a7c91": {
|
||||
"days": 60,
|
||||
"source": "rule",
|
||||
"name": "Cioccolato bianco",
|
||||
"location": "frigo",
|
||||
"ts": 1778419506
|
||||
},
|
||||
"38a0eaea422dfe970eba125494e75981": {
|
||||
"days": 180,
|
||||
"source": "rule",
|
||||
"name": "Zucca a pezzi",
|
||||
"location": "freezer",
|
||||
"ts": 1778419506
|
||||
},
|
||||
"cde21270e1cd50c431742e49117b225d": {
|
||||
"days": 7,
|
||||
"source": "rule",
|
||||
"name": "Pancetta Dolce",
|
||||
"location": "frigo",
|
||||
"ts": 1778419507
|
||||
},
|
||||
"9e4189bd3f8cb1121e7389967dd4f74c": {
|
||||
"days": 180,
|
||||
"source": "rule",
|
||||
"name": "Farina di grano tenero tipo rossa",
|
||||
"location": "dispensa",
|
||||
"ts": 1778427005
|
||||
},
|
||||
"e3472dd051ed13ae18fc96bbebedc1ba": {
|
||||
"days": 60,
|
||||
"source": "rule",
|
||||
"name": "Lievito di birra",
|
||||
"location": "dispensa",
|
||||
"ts": 1778427005
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "it.dadaloop.evershelf.kiosk"
|
||||
minSdk = 24
|
||||
targetSdk = 34
|
||||
versionCode = 17
|
||||
versionName = "1.7.16"
|
||||
versionCode = 15
|
||||
versionName = "1.7.14"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
||||
@@ -113,9 +113,7 @@ class KioskActivity : AppCompatActivity() {
|
||||
private const val KEY_SCREENSAVER = "screensaver_enabled"
|
||||
private const val KIOSK_DOWNLOAD_URL = "https://github.com/dadaloop82/EverShelf/releases/download/kiosk-latest/evershelf-kiosk.apk"
|
||||
private const val SPLASH_DURATION = 1500L
|
||||
// Use the kiosk-specific rolling release tag so version comparison is always
|
||||
// against the KIOSK version, not the webapp version (they diverge).
|
||||
private const val GITHUB_RELEASES_API = "https://api.github.com/repos/dadaloop82/EverShelf/releases/tags/kiosk-latest"
|
||||
private const val GITHUB_RELEASES_API = "https://api.github.com/repos/dadaloop82/EverShelf/releases/latest"
|
||||
// Keys for persisting a pending update across restarts
|
||||
private const val KEY_PENDING_UPDATE_VERSION = "pending_update_version"
|
||||
private const val KEY_PENDING_UPDATE_URL = "pending_update_url"
|
||||
@@ -629,16 +627,10 @@ class KioskActivity : AppCompatActivity() {
|
||||
packageManager.getPackageInfo(packageName, 0).versionName ?: ""
|
||||
} catch (_: Exception) { "" }
|
||||
|
||||
// The kiosk-latest release uses a non-semver tag ("kiosk-latest").
|
||||
// Extract the actual kiosk version from the release body text.
|
||||
// Body format: "Alias automatico → kiosk-X.Y.Z" or just "kiosk-X.Y.Z".
|
||||
// Fall back to stripping the tag prefix if body parsing fails.
|
||||
val bodyText = json.optString("body", "")
|
||||
// Strip any non-numeric prefix so "kiosk-1.7.0", "v1.7.0", "kiosk-v1.7.1"
|
||||
// all normalise to "1.7.0" / "1.7.1" for comparison.
|
||||
val norm = { v: String -> v.replace(Regex("^[^0-9]*"), "") }
|
||||
val remoteKioskVersion = Regex("""kiosk-v?(\d+\.\d+(?:\.\d+)?)""")
|
||||
.find(bodyText)?.groupValues?.get(1)
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: norm(latestTag)
|
||||
val isSemver = norm(latestTag).matches(Regex("\\d+\\.\\d+.*"))
|
||||
|
||||
// Compare semver: returns true if `remote` is strictly greater than `local`
|
||||
fun semverNewer(remote: String, local: String): Boolean {
|
||||
@@ -653,31 +645,29 @@ class KioskActivity : AppCompatActivity() {
|
||||
return false
|
||||
}
|
||||
|
||||
val isSemver = remoteKioskVersion.matches(Regex("\\d+\\.\\d+.*"))
|
||||
|
||||
// Get APK URL from assets; fall back to the hardcoded KIOSK_DOWNLOAD_URL
|
||||
val assets = json.optJSONArray("assets")
|
||||
var kioskApkUrl = ""
|
||||
if (assets != null) {
|
||||
for (i in 0 until assets.length()) {
|
||||
val a = assets.getJSONObject(i)
|
||||
val url = a.optString("browser_download_url", "")
|
||||
if (url.endsWith(".apk", ignoreCase = true) && url.isNotEmpty()) {
|
||||
kioskApkUrl = url; break
|
||||
}
|
||||
val a = assets.getJSONObject(i)
|
||||
val name = a.optString("name", "").lowercase()
|
||||
val url = a.optString("browser_download_url", "")
|
||||
if (name.contains("kiosk") && url.isNotEmpty()) kioskApkUrl = url
|
||||
}
|
||||
}
|
||||
if (kioskApkUrl.isEmpty()) kioskApkUrl = KIOSK_DOWNLOAD_URL
|
||||
|
||||
// Only flag an update when the remote version is parseable as semver AND
|
||||
// strictly greater than the installed version.
|
||||
val kioskNeedsUpdate = currentKiosk.isNotEmpty() && isSemver &&
|
||||
semverNewer(remoteKioskVersion, currentKiosk)
|
||||
// Only flag an update when the remote tag is parseable as semver AND
|
||||
// the remote version is strictly greater than the installed version.
|
||||
// Non-semver tags (e.g. "kiosk-latest", "rolling") cannot be compared
|
||||
// numerically → treat as "no update" to avoid false positives.
|
||||
val kioskNeedsUpdate = currentKiosk.isNotEmpty() &&
|
||||
isSemver && semverNewer(norm(latestTag), norm(currentKiosk))
|
||||
|
||||
val result = JSONObject()
|
||||
.put("has_update", kioskNeedsUpdate)
|
||||
.put("current", currentKiosk)
|
||||
.put("latest", remoteKioskVersion)
|
||||
.put("latest", latestTag)
|
||||
.put("apk_url", kioskApkUrl)
|
||||
|
||||
notifyJs(result)
|
||||
@@ -690,11 +680,12 @@ class KioskActivity : AppCompatActivity() {
|
||||
|
||||
// Persist the pending update so the banner reappears after a crash/restart
|
||||
prefs.edit()
|
||||
.putString(KEY_PENDING_UPDATE_VERSION, remoteKioskVersion)
|
||||
.putString(KEY_PENDING_UPDATE_VERSION, latestTag)
|
||||
.putString(KEY_PENDING_UPDATE_URL, kioskApkUrl)
|
||||
.apply()
|
||||
|
||||
runOnUiThread { showNativeUpdateBanner("🔄 Kiosk $currentKiosk → $remoteKioskVersion", kioskApkUrl) }
|
||||
val label = if (isSemver) "$currentKiosk → $latestTag" else latestTag
|
||||
runOnUiThread { showNativeUpdateBanner("🔄 Kiosk $label", kioskApkUrl) }
|
||||
} catch (e: Exception) {
|
||||
notifyJs(JSONObject().put("has_update", false).put("error", e.message ?: "network error"))
|
||||
}
|
||||
@@ -811,52 +802,6 @@ class KioskActivity : AppCompatActivity() {
|
||||
file.delete()
|
||||
return
|
||||
}
|
||||
// ── Pre-install validation via PackageManager ──────────────────────
|
||||
// This catches version-downgrade or same-version attempts before PackageInstaller
|
||||
// gets them (which would silently fail with STATUS_FAILURE=1 on many OEMs).
|
||||
@Suppress("DEPRECATION")
|
||||
val apkInfo = try { packageManager.getPackageArchiveInfo(file.absolutePath, 0) } catch (_: Exception) { null }
|
||||
if (apkInfo != null) {
|
||||
// Wrong package: would always fail with STATUS_FAILURE=1
|
||||
if (apkInfo.packageName != packageName) {
|
||||
val detail = "APK package=${apkInfo.packageName}, expected=$packageName"
|
||||
setInstallUI("\u274C", "APK non valido", detail, 0xFFf87171.toInt(), btnEnabled = true, progress = -2)
|
||||
runOnUiThread { activeInstallBtn?.text = getString(R.string.install_btn_retry) }
|
||||
ErrorReporter.reportMessage("install_wrong_package", detail, mapOf("apk_pkg" to apkInfo.packageName, "expected" to packageName), forceReport = true)
|
||||
file.delete()
|
||||
return
|
||||
}
|
||||
// Version downgrade or same versionCode: Android rejects it
|
||||
@Suppress("DEPRECATION")
|
||||
val apkVc: Long = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
|
||||
apkInfo.longVersionCode
|
||||
else
|
||||
apkInfo.versionCode.toLong()
|
||||
val installedVc: Long = try {
|
||||
@Suppress("DEPRECATION")
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
|
||||
packageManager.getPackageInfo(packageName, 0).longVersionCode
|
||||
else
|
||||
packageManager.getPackageInfo(packageName, 0).versionCode.toLong()
|
||||
} catch (_: Exception) { -1L }
|
||||
|
||||
if (installedVc >= 0 && apkVc <= installedVc) {
|
||||
// Same or older version — no real update, dismiss banner silently
|
||||
runOnUiThread {
|
||||
updateBanner.visibility = View.GONE
|
||||
bannerProgressBar.visibility = View.GONE
|
||||
prefs.edit().remove(KEY_PENDING_UPDATE_VERSION).remove(KEY_PENDING_UPDATE_URL).apply()
|
||||
}
|
||||
ErrorReporter.reportMessage(
|
||||
"install_no_upgrade",
|
||||
"APK versionCode=$apkVc (${apkInfo.versionName}) ≤ installed=$installedVc — not an upgrade",
|
||||
mapOf("apk_vc" to apkVc, "apk_ver" to (apkInfo.versionName ?: ""), "installed_vc" to installedVc),
|
||||
forceReport = true
|
||||
)
|
||||
file.delete()
|
||||
return
|
||||
}
|
||||
}
|
||||
// Only kiosk self-update is handled; gateway is now integrated
|
||||
val targetPkg = packageName
|
||||
installWithPackageInstaller(file, targetPkg)
|
||||
|
||||
@@ -110,9 +110,6 @@ class SetupActivity : AppCompatActivity() {
|
||||
|
||||
// Screensaver step
|
||||
private lateinit var setupSwitchScreensaver: SwitchMaterial
|
||||
private lateinit var setupSwitchPrices: SwitchMaterial
|
||||
private lateinit var setupSwitchMealPlan: SwitchMaterial
|
||||
private lateinit var setupSwitchZeroWaste: SwitchMaterial
|
||||
|
||||
// Done step
|
||||
private lateinit var summaryText: TextView
|
||||
@@ -131,9 +128,6 @@ class SetupActivity : AppCompatActivity() {
|
||||
private const val KEY_HAS_SCALE = "has_scale"
|
||||
private const val KEY_LANGUAGE = "kiosk_language"
|
||||
private const val KEY_SCREENSAVER = "screensaver_enabled"
|
||||
private const val KEY_PRICE_ENABLED = "price_enabled"
|
||||
private const val KEY_MEAL_PLAN = "meal_plan_enabled"
|
||||
private const val KEY_ZEROWASTE_TIPS = "zerowaste_tips_enabled"
|
||||
private const val PERMISSION_REQUEST_CODE = 2004
|
||||
private const val BLE_PERMISSION_REQUEST = 2006
|
||||
|
||||
@@ -244,17 +238,10 @@ class SetupActivity : AppCompatActivity() {
|
||||
tvTestWeight = findViewById(R.id.tvTestWeight)
|
||||
testWeightBox = findViewById(R.id.testWeightBox)
|
||||
|
||||
// Features step — bind all four toggles
|
||||
// Screensaver step
|
||||
setupSwitchScreensaver = findViewById(R.id.setupSwitchScreensaver)
|
||||
setupSwitchPrices = findViewById(R.id.setupSwitchPrices)
|
||||
setupSwitchMealPlan = findViewById(R.id.setupSwitchMealPlan)
|
||||
setupSwitchZeroWaste = findViewById(R.id.setupSwitchZeroWaste)
|
||||
// Pre-fill from saved prefs only if each key was previously configured
|
||||
// ("se non sono impostati, chiedi!" — fresh install → all start at false)
|
||||
setupSwitchScreensaver.isChecked = if (prefs.contains(KEY_SCREENSAVER)) prefs.getBoolean(KEY_SCREENSAVER, false) else false
|
||||
setupSwitchPrices.isChecked = if (prefs.contains(KEY_PRICE_ENABLED)) prefs.getBoolean(KEY_PRICE_ENABLED, false) else false
|
||||
setupSwitchMealPlan.isChecked = if (prefs.contains(KEY_MEAL_PLAN)) prefs.getBoolean(KEY_MEAL_PLAN, false) else false
|
||||
setupSwitchZeroWaste.isChecked = if (prefs.contains(KEY_ZEROWASTE_TIPS)) prefs.getBoolean(KEY_ZEROWASTE_TIPS, false) else false
|
||||
// Pre-fill saved screensaver pref
|
||||
setupSwitchScreensaver.isChecked = prefs.getBoolean(KEY_SCREENSAVER, false)
|
||||
|
||||
// Done step
|
||||
summaryText = findViewById(R.id.setupSummaryText)
|
||||
@@ -394,15 +381,10 @@ class SetupActivity : AppCompatActivity() {
|
||||
findViewById<MaterialButton>(R.id.btnScaleNext).isEnabled = true
|
||||
}
|
||||
|
||||
// ── Features step (screensaver / prices / meal plan / zero-waste) ────
|
||||
// ── Screensaver ───────────────────────────────────────────────────
|
||||
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_ZEROWASTE_TIPS, setupSwitchZeroWaste.isChecked)
|
||||
.apply()
|
||||
prefs.edit().putBoolean(KEY_SCREENSAVER, setupSwitchScreensaver.isChecked).apply()
|
||||
showStep(6)
|
||||
}
|
||||
|
||||
@@ -989,16 +971,13 @@ class SetupActivity : AppCompatActivity() {
|
||||
// ── Summary / Finish ─────────────────────────────────────────────────
|
||||
|
||||
private fun buildSummary() {
|
||||
val url = prefs.getString(KEY_URL, "") ?: ""
|
||||
val hasScale = prefs.getBoolean(KEY_HAS_SCALE, false)
|
||||
val screensOn = setupSwitchScreensaver.isChecked
|
||||
val pricesOn = setupSwitchPrices.isChecked
|
||||
val mealPlanOn = setupSwitchMealPlan.isChecked
|
||||
val zeroWasteOn = setupSwitchZeroWaste.isChecked
|
||||
val scaleName = bleManager?.getSavedDeviceName()
|
||||
val scaleOk = hasScale && scaleName != null
|
||||
val lang = prefs.getString(KEY_LANGUAGE, "it") ?: "it"
|
||||
val langLabel = when (lang) { "en" -> "English 🇬🇧"; "de" -> "Deutsch 🇩🇪"; else -> "Italiano 🇮🇹" }
|
||||
val url = prefs.getString(KEY_URL, "") ?: ""
|
||||
val hasScale = prefs.getBoolean(KEY_HAS_SCALE, false)
|
||||
val screensOn = setupSwitchScreensaver.isChecked
|
||||
val scaleName = bleManager?.getSavedDeviceName()
|
||||
val scaleOk = hasScale && scaleName != null
|
||||
val lang = prefs.getString(KEY_LANGUAGE, "it") ?: "it"
|
||||
val langLabel = when (lang) { "en" -> "English 🇬🇧"; "de" -> "Deutsch 🇩🇪"; else -> "Italiano 🇮🇹" }
|
||||
val sb = StringBuilder()
|
||||
sb.appendLine("🌐 ${getString(R.string.summary_lang)}: $langLabel")
|
||||
if (url.isNotEmpty()) sb.appendLine("🖥️ Server: $url")
|
||||
@@ -1007,10 +986,7 @@ class SetupActivity : AppCompatActivity() {
|
||||
hasScale -> "⚠️ Bilancia: da configurare"
|
||||
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))
|
||||
sb.appendLine(if (screensOn) "🌙 ${getString(R.string.summary_screensaver_on)}" else "💡 ${getString(R.string.summary_screensaver_off)}")
|
||||
summaryText.text = sb.toString().trimEnd()
|
||||
}
|
||||
|
||||
@@ -1018,20 +994,16 @@ class SetupActivity : AppCompatActivity() {
|
||||
prefs.edit().putBoolean(KEY_SETUP_COMPLETE, true).apply()
|
||||
val baseUrl = (prefs.getString(KEY_URL, "") ?: "").trimEnd('/')
|
||||
if (baseUrl.isNotEmpty()) {
|
||||
val hasScale = prefs.getBoolean(KEY_HAS_SCALE, false) && (bleManager?.getSavedDeviceAddress() != null)
|
||||
val screensaver = prefs.getBoolean(KEY_SCREENSAVER, false)
|
||||
val priceEnabled = prefs.getBoolean(KEY_PRICE_ENABLED, false)
|
||||
val mealPlan = prefs.getBoolean(KEY_MEAL_PLAN, false)
|
||||
val zeroWaste = prefs.getBoolean(KEY_ZEROWASTE_TIPS, false)
|
||||
val hasScale = prefs.getBoolean(KEY_HAS_SCALE, false) && (bleManager?.getSavedDeviceAddress() != null)
|
||||
val screensaver = prefs.getBoolean(KEY_SCREENSAVER, false)
|
||||
Thread {
|
||||
try {
|
||||
val url = "$baseUrl/api/index.php?action=save_settings"
|
||||
val body = buildString {
|
||||
append("{\"screensaver_enabled\":$screensaver")
|
||||
append(",\"price_enabled\":$priceEnabled")
|
||||
append(",\"meal_plan_enabled\":$mealPlan")
|
||||
append(",\"zerowaste_tips_enabled\":$zeroWaste")
|
||||
if (hasScale) {
|
||||
// Use the tablet's actual LAN IP so the EverShelf server
|
||||
// (potentially on a different machine) can reach the gateway.
|
||||
val lanIp = getDeviceLanIp() ?: "127.0.0.1"
|
||||
append(",\"scale_enabled\":true,\"scale_gateway_url\":\"ws://$lanIp:8765\"")
|
||||
}
|
||||
|
||||
@@ -43,18 +43,17 @@
|
||||
android:layout_height="match_parent"
|
||||
android:visibility="gone" />
|
||||
|
||||
<!-- Settings gear (shown after setup, over WebView) — bottom-right corner so it never
|
||||
overlaps the webapp header buttons (e.g. the 📷 scan button at top-right) -->
|
||||
<!-- Settings gear (shown after setup, over WebView) — top-right corner to avoid overlapping modals -->
|
||||
<ImageButton
|
||||
android:id="@+id/btnSettings"
|
||||
android:layout_width="44dp"
|
||||
android:layout_height="44dp"
|
||||
android:layout_gravity="bottom|end"
|
||||
android:layout_marginBottom="80dp"
|
||||
android:layout_gravity="top|end"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:background="@android:color/transparent"
|
||||
android:src="@android:drawable/ic_menu_manage"
|
||||
android:alpha="0.28"
|
||||
android:alpha="0.12"
|
||||
android:contentDescription="Settings"
|
||||
android:scaleType="centerInside"
|
||||
android:visibility="gone" />
|
||||
|
||||
@@ -1050,7 +1050,7 @@
|
||||
</LinearLayout>
|
||||
|
||||
<!-- ════════════════════════════════════════════
|
||||
STEP 5 — Features
|
||||
STEP 5 — Screensaver
|
||||
════════════════════════════════════════════ -->
|
||||
<LinearLayout
|
||||
android:id="@+id/stepScreensaver"
|
||||
@@ -1063,58 +1063,66 @@
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="⚡"
|
||||
android:text="🌙"
|
||||
android:textSize="52sp"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/setup_features_title"
|
||||
android:id="@+id/tvScreensaverTitle"
|
||||
android:text="@string/setup_screensaver_title"
|
||||
android:textColor="#f1f5f9"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvScreensaverDesc"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/setup_features_desc"
|
||||
android:text="Dopo 5 minuti di inattività mostra un overlay con l'orologio e informazioni utili (statistiche, piano pasti). Lo schermo rimane SEMPRE acceso — questa opzione riguarda solo l'overlay visivo in-app."
|
||||
android:textColor="#94a3b8"
|
||||
android:textSize="15sp"
|
||||
android:gravity="center"
|
||||
android:lineSpacingExtra="4dp"
|
||||
android:layout_marginBottom="20dp" />
|
||||
android:layout_marginBottom="28dp" />
|
||||
|
||||
<!-- Toggle: Screensaver -->
|
||||
<!-- Toggle card -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:background="@drawable/card_background"
|
||||
android:padding="16dp"
|
||||
android:padding="20dp"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="10dp">
|
||||
android:layout_marginBottom="32dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvScreensaverToggleLabel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/setup_screensaver_toggle_label"
|
||||
android:textColor="#f1f5f9"
|
||||
android:textSize="15sp"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="3dp" />
|
||||
android:layout_marginBottom="4dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvScreensaverToggleHint"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/setup_screensaver_toggle_hint"
|
||||
android:textColor="#64748b"
|
||||
android:textSize="12sp" />
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/setupSwitchScreensaver"
|
||||
android:layout_width="wrap_content"
|
||||
@@ -1122,114 +1130,6 @@
|
||||
android:checked="false" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Toggle: Prezzi lista spesa -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:background="@drawable/card_background"
|
||||
android:padding="16dp"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="10dp">
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/setup_prices_toggle_label"
|
||||
android:textColor="#f1f5f9"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="3dp" />
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/setup_prices_toggle_hint"
|
||||
android:textColor="#64748b"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/setupSwitchPrices"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checked="false" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Toggle: Piano pasti -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:background="@drawable/card_background"
|
||||
android:padding="16dp"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="10dp">
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/setup_mealplan_toggle_label"
|
||||
android:textColor="#f1f5f9"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="3dp" />
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/setup_mealplan_toggle_hint"
|
||||
android:textColor="#64748b"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/setupSwitchMealPlan"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checked="false" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Toggle: Suggerimenti zero-waste -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:background="@drawable/card_background"
|
||||
android:padding="16dp"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="24dp">
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/setup_zerowaste_toggle_label"
|
||||
android:textColor="#f1f5f9"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="3dp" />
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/setup_zerowaste_toggle_hint"
|
||||
android:textColor="#64748b"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/setupSwitchZeroWaste"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checked="false" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Navigation -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -58,26 +58,15 @@
|
||||
<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>
|
||||
<!-- Screensaver step -->
|
||||
<string name="setup_screensaver_title">Salvaschermo in-app</string>
|
||||
<string name="setup_screensaver_desc">Shows a clock with useful facts after 5 minutes of inactivity. Off by default (screen stays always on).</string>
|
||||
<string name="setup_screensaver_toggle_label">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_screensaver_toggle_label">Abilita salvaschermo orologio</string>
|
||||
<string name="setup_screensaver_toggle_hint">Mostra l\'overlay orologio dopo 5 min. Lo schermo resta sempre acceso.</string>
|
||||
|
||||
<!-- Summary -->
|
||||
<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>
|
||||
</resources>
|
||||
|
||||
+5
-58
@@ -11,7 +11,7 @@
|
||||
<title>EverShelf</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<link rel="icon" type="image/png" href="assets/img/logo/logo_icon.png">
|
||||
<link rel="stylesheet" href="assets/css/style.css?v=20260517a">
|
||||
<link rel="stylesheet" href="assets/css/style.css?v=20260516b">
|
||||
<!-- QuaggaJS for barcode scanning -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2@1.8.4/dist/quagga.min.js"></script>
|
||||
<!-- @xenova/transformers: ES-module bootstrap that exposes a lazy category-classifier as window._categoryPipelinePromise -->
|
||||
@@ -54,17 +54,8 @@
|
||||
<div id="app-preloader" aria-hidden="true">
|
||||
<div class="app-preloader-inner">
|
||||
<img src="assets/img/logo/logo.png" alt="EverShelf" class="app-preloader-logo" />
|
||||
<div class="app-preloader-spinner" id="preloader-spinner"></div>
|
||||
<div id="preloader-progress-wrap" class="preloader-progress-wrap" style="display:none">
|
||||
<div class="preloader-bar-track">
|
||||
<div id="preloader-bar" class="preloader-bar"></div>
|
||||
</div>
|
||||
<div id="preloader-check-label" class="preloader-check-label"> </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>
|
||||
<div class="app-preloader-spinner"></div>
|
||||
<span class="app-preloader-version" id="preloader-version">v1.7.15</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -77,7 +68,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.15</span>
|
||||
</h1>
|
||||
<!-- Update badge — shown alongside title, never replaces it -->
|
||||
<span class="header-update-badge" id="header-update-badge" style="display:none"></span>
|
||||
@@ -194,7 +185,6 @@
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
|
||||
<h2 id="inventory-title" data-i18n="inventory.title">Dispensa</h2>
|
||||
<button class="page-header-action-btn" onclick="_showExportModal()" title="Export" data-i18n-title="export.btn_title">📤</button>
|
||||
</div>
|
||||
<div class="location-tabs" id="location-tabs">
|
||||
<button class="tab active" onclick="filterLocation('')" data-loc="" data-i18n="inventory.filter_all">Tutti</button>
|
||||
@@ -1297,43 +1287,6 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-card">
|
||||
<h4 data-i18n="settings.theme.title">🌙 Tema / Aspetto</h4>
|
||||
<p class="settings-hint" data-i18n="settings.theme.hint">Scegli il tema dell'interfaccia.</p>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.theme.label">🌙 Tema</label>
|
||||
<select id="setting-dark-mode" class="form-input" onchange="_setThemeMode(this.value)">
|
||||
<option value="off" data-i18n="settings.theme.off">☀️ Chiaro</option>
|
||||
<option value="auto" selected data-i18n="settings.theme.auto">🔄 Automatico (sistema)</option>
|
||||
<option value="on" data-i18n="settings.theme.on">🌙 Scuro</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-card">
|
||||
<h4 data-i18n="settings.zerowaste.card_title">♻️ Suggerimenti zero-waste</h4>
|
||||
<p class="settings-hint" data-i18n="settings.zerowaste.card_hint">Durante la cottura, mostra consigli su come riutilizzare gli scarti prodotti in ogni passo (bucce, acqua di cottura, ecc.). Disattivo per impostazione predefinita.</p>
|
||||
<div class="form-group">
|
||||
<label class="toggle-row">
|
||||
<span data-i18n="settings.zerowaste.label">Mostra suggerimenti durante la cottura</span>
|
||||
<span class="toggle-switch">
|
||||
<input type="checkbox" id="setting-zerowaste-tips">
|
||||
<span class="toggle-slider"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-card">
|
||||
<h4 data-i18n="export.title">📤 Esporta inventario</h4>
|
||||
<p class="settings-hint" data-i18n="export.hint">Scarica l'inventario corrente in CSV o apri una versione stampabile (PDF).</p>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<button class="btn btn-outline" onclick="exportInventory('csv')" style="flex:1;min-width:120px">
|
||||
📊 <span data-i18n="export.btn_csv">CSV</span>
|
||||
</button>
|
||||
<button class="btn btn-outline" onclick="exportInventory('html')" style="flex:1;min-width:120px">
|
||||
🖨️ <span data-i18n="export.btn_pdf">PDF / Stampa</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1600,12 +1553,6 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="cooking-step-ings" id="cooking-step-ings" style="display:none"></div>
|
||||
<div id="cooking-zerowaste-tip" class="cooking-zerowaste-tip" style="display:none">
|
||||
<span class="cooking-zerowaste-label" data-i18n="cooking.zerowaste_label">♻️ Scarto</span>
|
||||
<span id="cooking-zerowaste-scrap" class="cooking-zerowaste-scrap"></span>
|
||||
<p id="cooking-zerowaste-text" class="cooking-zerowaste-text"></p>
|
||||
<button class="cooking-zerowaste-close" onclick="_dismissZeroWasteTip()" aria-label="Chiudi">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cooking-nav">
|
||||
<button class="cooking-nav-btn cooking-prev-btn" id="cooking-prev" onclick="navigateCookingStep(-1)">◀ Precedente</button>
|
||||
@@ -1613,6 +1560,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="assets/js/app.js?v=20260517a"></script>
|
||||
<script src="assets/js/app.js?v=20260516b"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+1
-1
@@ -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.15",
|
||||
"start_url": "/evershelf/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f0f4e8",
|
||||
|
||||
+3
-61
@@ -500,8 +500,7 @@
|
||||
"undo_success": "↩ Vorgang rückgängig gemacht für {name}",
|
||||
"already_undone": "Vorgang bereits rückgängig gemacht",
|
||||
"too_old": "Vorgänge älter als 24 Stunden können nicht rückgängig gemacht werden",
|
||||
"undo_error": "Fehler beim Rückgängigmachen",
|
||||
"recipe_prefix": "Rezept"
|
||||
"undo_error": "Fehler beim Rückgängigmachen"
|
||||
},
|
||||
"chat": {
|
||||
"title": "Gemini Chef",
|
||||
@@ -540,9 +539,7 @@
|
||||
"recipe_done_tts": "Rezept abgeschlossen! Guten Appetit!",
|
||||
"expires_chip": "läuft ab {date}",
|
||||
"finish": "✅ Fertig",
|
||||
"step_fallback": "Schritt {n}",
|
||||
"zerowaste_label": "♻️ Abfall",
|
||||
"zerowaste_tip_title": "Zero-Waste-Tipp"
|
||||
"step_fallback": "Schritt {n}"
|
||||
},
|
||||
"settings": {
|
||||
"title": "⚙️ Einstellungen",
|
||||
@@ -746,20 +743,7 @@
|
||||
},
|
||||
"saved": "✅ Konfiguration gespeichert!",
|
||||
"saved_local": "✅ Konfiguration lokal gespeichert",
|
||||
"saved_local_error": "⚠️ Lokal gespeichert, Serverfehler: {error}",
|
||||
"theme": {
|
||||
"title": "🌙 Erscheinungsbild",
|
||||
"hint": "Wähle das Interface-Design.",
|
||||
"label": "🌙 Design",
|
||||
"off": "☀️ Hell",
|
||||
"on": "🌙 Dunkel",
|
||||
"auto": "🔄 Automatisch (System)"
|
||||
},
|
||||
"zerowaste": {
|
||||
"card_title": "♻️ Zero-Waste-Tipps",
|
||||
"card_hint": "Zeige während des Kochens Tipps zur Wiederverwendung von Abfällen (Schalen, Kochwasser usw.). Standardmäßig deaktiviert.",
|
||||
"label": "Tipps beim Kochen anzeigen"
|
||||
}
|
||||
"saved_local_error": "⚠️ Lokal gespeichert, Serverfehler: {error}"
|
||||
},
|
||||
"expiry": {
|
||||
"today": "HEUTE",
|
||||
@@ -1194,47 +1178,5 @@
|
||||
"report_bug_error": "Bericht konnte nicht gesendet werden. Verbindung prüfen.",
|
||||
"changelog": "Changelog",
|
||||
"github": "GitHub-Repository"
|
||||
},
|
||||
"export": {
|
||||
"title": "Inventar exportieren",
|
||||
"hint": "Lade das aktuelle Inventar als CSV herunter oder öffne die druckfertige Version (PDF).",
|
||||
"btn_csv": "CSV herunterladen",
|
||||
"btn_pdf": "PDF / Drucken",
|
||||
"btn_title": "Exportieren"
|
||||
},
|
||||
"startup": {
|
||||
"connecting": "Serververbindung wird hergestellt...",
|
||||
"check_php_memory": "PHP-Speicher",
|
||||
"check_php_timeout": "PHP-Timeout",
|
||||
"check_php_upload": "PHP-Upload",
|
||||
"check_data_dir": "Datenverzeichnis",
|
||||
"check_rate_limits": "Rate-Limits-Verzeichnis",
|
||||
"check_backups": "Backup-Verzeichnis",
|
||||
"check_write_test": "Schreibtest",
|
||||
"check_disk_space": "Speicherplatz",
|
||||
"check_db_legacy": "Legacy-DB (dispensa.db)",
|
||||
"check_db_connect": "Datenbankverbindung",
|
||||
"check_db_tables": "Datenbanktabellen",
|
||||
"check_db_integrity": "Datenbankintegrität",
|
||||
"check_db_wal": "WAL-Modus",
|
||||
"check_db_size": "Datenbankgröße",
|
||||
"check_db_rows": "Inventardaten",
|
||||
"check_env": ".env-Datei",
|
||||
"check_gemini": "Gemini-AI-Schlüssel",
|
||||
"check_bring_creds": "Bring!-Anmeldedaten",
|
||||
"check_bring_token": "Bring!-Token",
|
||||
"check_tts": "Text-to-Speech-URL",
|
||||
"check_scale": "Waagen-Gateway",
|
||||
"check_curl_ssl": "cURL-SSL",
|
||||
"check_internet": "Internetverbindung",
|
||||
"fresh_install": "Neuinstallation",
|
||||
"warnings_found": "Warnungen",
|
||||
"all_ok": "System OK",
|
||||
"critical_error_short": "Kritischer Fehler",
|
||||
"critical_error": "Kritischer Fehler: Die App kann nicht gestartet werden. Prüfe die Serverlogs.",
|
||||
"critical_error_intro": "Die App kann aufgrund folgender Probleme nicht gestartet werden:",
|
||||
"error_network": "Server nicht erreichbar.",
|
||||
"error_network_detail": "Der Browser kann den PHP-Server nicht erreichen.\n\nMögliche Ursachen:\n• Apache/PHP-Server läuft nicht\n• Netzwerk- oder Firewall-Problem\n• Falsche App-URL\n\nBitte Server starten und erneut versuchen.",
|
||||
"retry": "Erneut versuchen"
|
||||
}
|
||||
}
|
||||
+2
-59
@@ -540,9 +540,7 @@
|
||||
"recipe_done_tts": "Recipe complete! Enjoy your meal!",
|
||||
"expires_chip": "exp. {date}",
|
||||
"finish": "✅ Finish",
|
||||
"step_fallback": "Step {n}",
|
||||
"zerowaste_label": "♻️ Scrap",
|
||||
"zerowaste_tip_title": "Zero-waste tip"
|
||||
"step_fallback": "Step {n}"
|
||||
},
|
||||
"settings": {
|
||||
"title": "⚙️ Settings",
|
||||
@@ -746,20 +744,7 @@
|
||||
},
|
||||
"saved": "✅ Configuration saved!",
|
||||
"saved_local": "✅ Configuration saved locally",
|
||||
"saved_local_error": "⚠️ Saved locally, server error: {error}",
|
||||
"theme": {
|
||||
"title": "🌙 Appearance",
|
||||
"hint": "Choose the interface theme.",
|
||||
"label": "🌙 Theme",
|
||||
"off": "☀️ Light",
|
||||
"on": "🌙 Dark",
|
||||
"auto": "🔄 Auto (system)"
|
||||
},
|
||||
"zerowaste": {
|
||||
"card_title": "♻️ Zero-waste tips",
|
||||
"card_hint": "During cooking, show tips on how to reuse scraps generated in each step (peels, cooking water, etc.). Disabled by default.",
|
||||
"label": "Show tips during cooking"
|
||||
}
|
||||
"saved_local_error": "⚠️ Saved locally, server error: {error}"
|
||||
},
|
||||
"expiry": {
|
||||
"today": "TODAY",
|
||||
@@ -1194,47 +1179,5 @@
|
||||
"report_bug_error": "Could not send the report. Check your connection.",
|
||||
"changelog": "Changelog",
|
||||
"github": "GitHub Repository"
|
||||
},
|
||||
"export": {
|
||||
"title": "Export inventory",
|
||||
"hint": "Download the current inventory as CSV or open a print-ready version (PDF).",
|
||||
"btn_csv": "Download CSV",
|
||||
"btn_pdf": "PDF / Print",
|
||||
"btn_title": "Export"
|
||||
},
|
||||
"startup": {
|
||||
"connecting": "Connecting to server...",
|
||||
"check_php_memory": "PHP memory",
|
||||
"check_php_timeout": "PHP timeout",
|
||||
"check_php_upload": "PHP upload",
|
||||
"check_data_dir": "Data directory",
|
||||
"check_rate_limits": "Rate limits dir",
|
||||
"check_backups": "Backup dir",
|
||||
"check_write_test": "Disk write test",
|
||||
"check_disk_space": "Disk space",
|
||||
"check_db_legacy": "Legacy DB (dispensa.db)",
|
||||
"check_db_connect": "Database connection",
|
||||
"check_db_tables": "Database tables",
|
||||
"check_db_integrity": "Database integrity",
|
||||
"check_db_wal": "WAL mode",
|
||||
"check_db_size": "Database size",
|
||||
"check_db_rows": "Inventory data",
|
||||
"check_env": ".env file",
|
||||
"check_gemini": "Gemini AI key",
|
||||
"check_bring_creds": "Bring! credentials",
|
||||
"check_bring_token": "Bring! token",
|
||||
"check_tts": "Text-to-Speech URL",
|
||||
"check_scale": "Scale gateway",
|
||||
"check_curl_ssl": "cURL SSL",
|
||||
"check_internet": "Internet connection",
|
||||
"fresh_install": "fresh install",
|
||||
"warnings_found": "warnings found",
|
||||
"all_ok": "System OK",
|
||||
"critical_error_short": "Critical error",
|
||||
"critical_error": "Critical error: the app cannot start. Check your server logs.",
|
||||
"critical_error_intro": "The app cannot start due to the following issues:",
|
||||
"error_network": "Cannot reach the server.",
|
||||
"error_network_detail": "The browser cannot reach the PHP server.\n\nPossible causes:\n• Apache/PHP server is not running\n• Network or firewall issue\n• Incorrect app URL\n\nMake sure the server is started and try again.",
|
||||
"retry": "Retry"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+2
-59
@@ -540,9 +540,7 @@
|
||||
"recipe_done_tts": "Ricetta completata! Buon appetito!",
|
||||
"expires_chip": "scade {date}",
|
||||
"finish": "✅ Fine",
|
||||
"step_fallback": "Passo {n}",
|
||||
"zerowaste_label": "♻️ Scarto",
|
||||
"zerowaste_tip_title": "Consiglio anti-spreco"
|
||||
"step_fallback": "Passo {n}"
|
||||
},
|
||||
"settings": {
|
||||
"title": "⚙️ Configurazione",
|
||||
@@ -746,20 +744,7 @@
|
||||
},
|
||||
"saved": "✅ Configurazione salvata!",
|
||||
"saved_local": "✅ Configurazione salvata localmente",
|
||||
"saved_local_error": "⚠️ Salvato localmente, errore server: {error}",
|
||||
"theme": {
|
||||
"title": "🌙 Tema / Aspetto",
|
||||
"hint": "Scegli il tema dell interfaccia.",
|
||||
"label": "🌙 Tema",
|
||||
"off": "☀️ Chiaro",
|
||||
"on": "🌙 Scuro",
|
||||
"auto": "🔄 Automatico (sistema)"
|
||||
},
|
||||
"zerowaste": {
|
||||
"card_title": "♻️ Suggerimenti zero-waste",
|
||||
"card_hint": "Durante la cottura, mostra consigli su come riutilizzare gli scarti prodotti in ogni passo (bucce, acqua di cottura, ecc.). Disattivo per impostazione predefinita.",
|
||||
"label": "Mostra suggerimenti durante la cottura"
|
||||
}
|
||||
"saved_local_error": "⚠️ Salvato localmente, errore server: {error}"
|
||||
},
|
||||
"expiry": {
|
||||
"today": "OGGI",
|
||||
@@ -1194,47 +1179,5 @@
|
||||
"report_bug_error": "Impossibile inviare la segnalazione. Controlla la connessione.",
|
||||
"changelog": "Changelog",
|
||||
"github": "Repository GitHub"
|
||||
},
|
||||
"export": {
|
||||
"title": "Esporta inventario",
|
||||
"hint": "Scarica l inventario corrente in CSV o apri la versione stampabile (PDF).",
|
||||
"btn_csv": "Scarica CSV",
|
||||
"btn_pdf": "PDF / Stampa",
|
||||
"btn_title": "Esporta"
|
||||
},
|
||||
"startup": {
|
||||
"connecting": "Connessione al server...",
|
||||
"check_php_memory": "Memoria PHP",
|
||||
"check_php_timeout": "Timeout PHP",
|
||||
"check_php_upload": "Upload PHP",
|
||||
"check_data_dir": "Cartella dati",
|
||||
"check_rate_limits": "Dir rate limits",
|
||||
"check_backups": "Dir backup",
|
||||
"check_write_test": "Test scrittura disco",
|
||||
"check_disk_space": "Spazio disco",
|
||||
"check_db_legacy": "DB legacy (dispensa.db)",
|
||||
"check_db_connect": "Connessione database",
|
||||
"check_db_tables": "Tabelle database",
|
||||
"check_db_integrity": "Integrità database",
|
||||
"check_db_wal": "WAL mode",
|
||||
"check_db_size": "Dimensione database",
|
||||
"check_db_rows": "Dati inventario",
|
||||
"check_env": "File .env",
|
||||
"check_gemini": "Chiave Gemini AI",
|
||||
"check_bring_creds": "Credenziali Bring!",
|
||||
"check_bring_token": "Token Bring!",
|
||||
"check_tts": "URL Text-to-Speech",
|
||||
"check_scale": "Gateway bilancia",
|
||||
"check_curl_ssl": "cURL SSL",
|
||||
"check_internet": "Connessione internet",
|
||||
"fresh_install": "nuovo impianto",
|
||||
"warnings_found": "avvisi rilevati",
|
||||
"all_ok": "Sistema OK",
|
||||
"critical_error_short": "Errore critico",
|
||||
"critical_error": "Errore critico: l'app non può avviarsi. Controlla i log del server.",
|
||||
"critical_error_intro": "L'app non può avviarsi a causa dei seguenti problemi:",
|
||||
"error_network": "Impossibile contattare il server.",
|
||||
"error_network_detail": "Il browser non riesce a raggiungere il server PHP.\n\nPossibili cause:\n• Il server Apache/PHP non è in esecuzione\n• Problema di rete o firewall\n• URL dell'app non corretta\n\nControlla che il server sia avviato e riprova.",
|
||||
"retry": "Riprova"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user