Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea2dae2be9 | |||
| 8360f5a0a0 | |||
| f5b1913ffa | |||
| d26dce283d | |||
| e67e490162 | |||
| 92048c9eba | |||
| ce504d5d41 | |||
| a690d2e7cf |
@@ -11,6 +11,22 @@ 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
|
||||
|
||||
+181
-104
@@ -110,6 +110,10 @@ if (($_GET['action'] ?? '') === 'ping') {
|
||||
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', '>='),
|
||||
@@ -127,28 +131,16 @@ if (($_GET['action'] ?? '') === 'health_check') {
|
||||
}
|
||||
|
||||
// ── 4. PHP runtime configuration ─────────────────────────────────────────
|
||||
// Memory limit
|
||||
$memRaw = ini_get('memory_limit');
|
||||
$memRaw = ini_get('memory_limit');
|
||||
$memBytes = (function ($v) {
|
||||
$v = trim($v);
|
||||
if ($v === '-1') return PHP_INT_MAX;
|
||||
$unit = strtolower(substr($v, -1));
|
||||
$num = (int) $v;
|
||||
return match ($unit) { 'g' => $num * 1073741824, 'm' => $num * 1048576, 'k' => $num * 1024, default => $num };
|
||||
$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];
|
||||
|
||||
// Max execution time
|
||||
$maxExec = (int) ini_get('max_execution_time');
|
||||
$checks['php_max_exec'] = [
|
||||
'ok' => $maxExec === 0 || $maxExec >= 30,
|
||||
'value' => $maxExec === 0 ? '∞' : $maxExec . 's',
|
||||
'optional' => true,
|
||||
];
|
||||
|
||||
// Upload size
|
||||
$uploadRaw = ini_get('upload_max_filesize');
|
||||
$checks['php_upload'] = ['ok' => true, 'value' => $uploadRaw, 'optional' => true];
|
||||
$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';
|
||||
@@ -158,13 +150,24 @@ if (($_GET['action'] ?? '') === 'health_check') {
|
||||
|
||||
// data/rate_limits/
|
||||
$rlDir = $dataDir . '/rate_limits';
|
||||
if (!is_dir($rlDir)) @mkdir($rlDir, 0775, true);
|
||||
if (!is_dir($rlDir) && $dataDirOk) @mkdir($rlDir, 0775, true);
|
||||
$checks['data_rate_limits'] = ['ok' => is_dir($rlDir) && is_writable($rlDir), 'optional' => true];
|
||||
|
||||
// data/backups/
|
||||
$bkDir = $dataDir . '/backups';
|
||||
if (!is_dir($bkDir)) @mkdir($bkDir, 0775, true);
|
||||
$checks['data_backups'] = ['ok' => is_dir($bkDir) && is_writable($bkDir), 'optional' => true];
|
||||
// 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';
|
||||
@@ -174,19 +177,37 @@ if (($_GET['action'] ?? '') === 'health_check') {
|
||||
|
||||
// ── 7. Free disk space ────────────────────────────────────────────────────
|
||||
$freeBytes = $dataDirOk ? @disk_free_space($dataDir) : false;
|
||||
$freeMB = $freeBytes !== false ? round($freeBytes / 1048576) : null;
|
||||
$freeMB = $freeBytes !== false ? round($freeBytes/1048576) : null;
|
||||
$checks['disk_space'] = [
|
||||
'ok' => $freeBytes === false || $freeBytes > 50 * 1048576,
|
||||
'value' => $freeMB !== null ? $freeMB . ' MB liberi' : null,
|
||||
'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 ────────────────────────────────────────────────────
|
||||
$dbPath = $dataDir . '/dispensa.db';
|
||||
$isFresh = !file_exists($dbPath) && $dataDirOk;
|
||||
// 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) {
|
||||
// Fresh install: DB will be created automatically on first real API call
|
||||
$checks['db_connect'] = ['ok' => true, 'fresh' => true, 'value' => 'nuovo impianto'];
|
||||
$checks['db_tables'] = ['ok' => true, 'fresh' => true];
|
||||
$checks['db_integrity'] = ['ok' => true, 'fresh' => true];
|
||||
@@ -202,110 +223,138 @@ if (($_GET['action'] ?? '') === 'health_check') {
|
||||
]);
|
||||
$pdo->query('SELECT 1');
|
||||
$dbConnOk = true;
|
||||
$checks['db_connect'] = ['ok' => true];
|
||||
$checks['db_connect'] = ['ok' => true, 'value' => basename($dbPath)];
|
||||
} catch (\Throwable $e) {
|
||||
$checks['db_connect'] = ['ok' => false, 'error' => $e->getMessage()];
|
||||
$checks['db_connect'] = ['ok' => false, 'error' => $e->getMessage(),
|
||||
'hint' => 'Impossibile aprire il database — verifica permessi su data/evershelf.db'];
|
||||
}
|
||||
|
||||
if ($dbConnOk && $pdo) {
|
||||
// Required tables
|
||||
try {
|
||||
$tables = $pdo->query("SELECT name FROM sqlite_master WHERE type='table'")->fetchAll(PDO::FETCH_COLUMN);
|
||||
$required = ['inventory', 'products', 'transactions'];
|
||||
$missing = array_values(array_diff($required, $tables));
|
||||
$checks['db_tables'] = ['ok' => empty($missing), 'missing' => $missing];
|
||||
} catch (\Throwable $e) {
|
||||
$checks['db_tables'] = ['ok' => false, 'error' => $e->getMessage()];
|
||||
}
|
||||
$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 (fast)
|
||||
try {
|
||||
$integ = $pdo->query("PRAGMA quick_check")->fetchColumn();
|
||||
$checks['db_integrity'] = ['ok' => $integ === 'ok', 'value' => $integ !== 'ok' ? $integ : null];
|
||||
} catch (\Throwable $e) {
|
||||
$checks['db_integrity'] = ['ok' => false, 'error' => $e->getMessage()];
|
||||
}
|
||||
// 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 mode
|
||||
try {
|
||||
$wal = $pdo->query("PRAGMA journal_mode")->fetchColumn();
|
||||
$checks['db_wal'] = ['ok' => $wal === 'wal', 'value' => $wal, 'optional' => true];
|
||||
} catch (\Throwable $e) {
|
||||
$checks['db_wal'] = ['ok' => false, 'optional' => true];
|
||||
}
|
||||
// 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];
|
||||
|
||||
// DB file size
|
||||
$dbSizeKB = round(filesize($dbPath) / 1024);
|
||||
$checks['db_size'] = ['ok' => true, 'value' => $dbSizeKB . ' KB', 'optional' => true];
|
||||
|
||||
// Row count
|
||||
try {
|
||||
$cnt = $pdo->query("SELECT COUNT(*) FROM inventory WHERE quantity > 0")->fetchColumn();
|
||||
$checks['db_row_count'] = ['ok' => true, 'value' => $cnt . ' prodotti in inventario', 'optional' => true];
|
||||
} catch (\Throwable $e) {
|
||||
$checks['db_row_count'] = ['ok' => true, 'value' => '?', 'optional' => true];
|
||||
}
|
||||
// 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];
|
||||
foreach (['db_wal', 'db_size', 'db_row_count'] as $k) $checks[$k] = ['ok' => false, 'optional' => true];
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
$checks['env_file'] = ['ok' => file_exists(__DIR__ . '/../.env'), 'optional' => true];
|
||||
|
||||
// ── 10. Gemini AI key ─────────────────────────────────────────────────────
|
||||
$checks['gemini_key'] = ['ok' => !empty(env('GEMINI_API_KEY')), 'optional' => true];
|
||||
|
||||
// ── 11. Bring! credentials & token ────────────────────────────────────────
|
||||
$checks['bring_credentials'] = [
|
||||
'ok' => !empty(env('BRING_EMAIL')) && !empty(env('BRING_PASSWORD')),
|
||||
$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,
|
||||
];
|
||||
$checks['bring_token'] = ['ok' => !empty(env('BRING_ACCESS_TOKEN')), 'optional' => true];
|
||||
|
||||
// ── 12. cURL SSL support ──────────────────────────────────────────────────
|
||||
// ── 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,
|
||||
];
|
||||
$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];
|
||||
$checks['curl_ssl'] = ['ok' => false, 'optional' => true, 'hint' => 'cURL non disponibile'];
|
||||
}
|
||||
|
||||
// ── 13. Internet / Gemini API reachability ────────────────────────────────
|
||||
$internetOk = false;
|
||||
if (extension_loaded('curl')) {
|
||||
// ── 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 => 3,
|
||||
CURLOPT_CONNECTTIMEOUT => 2,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
]);
|
||||
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);
|
||||
$curlErr = curl_errno($ch);
|
||||
$curlErrNo = curl_errno($ch);
|
||||
curl_close($ch);
|
||||
$internetOk = ($httpCode > 0) || ($curlErr === 0);
|
||||
$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];
|
||||
}
|
||||
$checks['internet'] = ['ok' => $internetOk, 'optional' => true];
|
||||
|
||||
// ── Compute overall result ────────────────────────────────────────────────
|
||||
$criticalKeys = [
|
||||
'php_version', 'ext_pdo_sqlite', 'ext_curl', 'ext_json', 'ext_mbstring',
|
||||
'data_dir', 'data_write_test', 'db_connect', 'db_tables', 'db_integrity',
|
||||
];
|
||||
$criticalKeys = ['php_version', 'ext_pdo_sqlite', 'ext_curl', 'ext_json', 'ext_mbstring',
|
||||
'data_dir', 'data_write_test', 'db_connect', 'db_tables', 'db_integrity'];
|
||||
$allOk = array_reduce($criticalKeys, fn($c, $k) => $c && ($checks[$k]['ok'] ?? false), true);
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['ok' => $allOk, 'checks' => $checks, 'fresh' => $isFresh ?? false], JSON_UNESCAPED_UNICODE);
|
||||
echo json_encode(['ok' => $allOk, 'checks' => $checks, 'fresh' => $isFresh], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -2603,6 +2652,7 @@ 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'),
|
||||
@@ -2660,6 +2710,7 @@ 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 = [
|
||||
@@ -8385,6 +8436,32 @@ 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);
|
||||
}
|
||||
|
||||
+94
-12
@@ -155,13 +155,88 @@ body {
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.preloader-warnings {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
justify-content: center;
|
||||
max-width: 270px;
|
||||
animation: zwFadeIn 0.25s ease;
|
||||
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;
|
||||
@@ -175,15 +250,22 @@ body {
|
||||
color: #fca5a5;
|
||||
background: rgba(239,68,68,0.18);
|
||||
border: 1px solid rgba(239,68,68,0.4);
|
||||
border-radius: 10px;
|
||||
padding: 12px 18px;
|
||||
font-size: 0.84rem;
|
||||
text-align: center;
|
||||
max-width: 280px;
|
||||
line-height: 1.5;
|
||||
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;
|
||||
|
||||
+123
-55
@@ -2185,7 +2185,8 @@ 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'];
|
||||
'price_enabled','price_country','price_currency','price_update_months',
|
||||
'zerowaste_tips_enabled'];
|
||||
let changed = false;
|
||||
for (const key of serverKeys) {
|
||||
if (serverSettings[key] !== undefined && serverSettings[key] !== null && serverSettings[key] !== '') {
|
||||
@@ -14924,52 +14925,56 @@ async function _runStartupCheck() {
|
||||
result = await resp.json();
|
||||
} catch(e) {
|
||||
clearInterval(slowAnim);
|
||||
setProgress(100, tl('error_network', 'Impossibile contattare il server'), 'error');
|
||||
errorEl.textContent = tl('error_network', 'Impossibile contattare il server. Controlla la connessione di rete.');
|
||||
errorEl.style.display = '';
|
||||
retryBtn.style.display = '';
|
||||
_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) ───────────────────────
|
||||
// { key, label, critical }
|
||||
const CHECKS = [
|
||||
// PHP runtime
|
||||
{ key: 'php_version', label: 'PHP', critical: true },
|
||||
{ key: 'ext_pdo_sqlite', label: 'PDO SQLite', critical: true },
|
||||
{ key: 'ext_curl', label: 'cURL', critical: true },
|
||||
{ key: 'ext_json', label: 'JSON', critical: true },
|
||||
{ key: 'ext_mbstring', label: 'mbstring', critical: true },
|
||||
{ key: 'ext_openssl', label: 'OpenSSL', critical: false },
|
||||
{ key: 'ext_fileinfo', label: 'Fileinfo', critical: false },
|
||||
{ key: 'ext_zip', label: 'ZIP', critical: false },
|
||||
{ key: 'ext_intl', label: 'Intl', critical: false },
|
||||
{ key: 'php_memory', label: tl('check_php_memory', 'Memoria PHP'), critical: false },
|
||||
{ key: 'php_max_exec', label: tl('check_php_timeout', 'Timeout PHP'), critical: false },
|
||||
{ key: 'php_upload', label: tl('check_php_upload', 'Upload PHP'), critical: false },
|
||||
{ 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 },
|
||||
{ key: 'data_dir', label: tl('check_data_dir', 'Cartella dati'), critical: true },
|
||||
{ key: 'data_rate_limits', label: tl('check_rate_limits', 'Rate limits dir'), critical: false },
|
||||
{ key: 'data_backups', label: tl('check_backups', 'Backup dir'), critical: false },
|
||||
{ key: 'data_write_test', label: tl('check_write_test', 'Test scrittura'), critical: true },
|
||||
{ key: 'disk_space', label: tl('check_disk_space', 'Spazio disco'), critical: false },
|
||||
// Database
|
||||
{ key: 'db_connect', label: tl('check_db_connect', 'Connessione DB'), critical: true },
|
||||
{ key: 'db_tables', label: tl('check_db_tables', 'Tabelle DB'), critical: true },
|
||||
{ key: 'db_integrity', label: tl('check_db_integrity','Integrità DB'), critical: true },
|
||||
{ key: 'db_wal', label: tl('check_db_wal', 'WAL mode'), critical: false },
|
||||
{ key: 'db_size', label: tl('check_db_size', 'Dimensione DB'), critical: false },
|
||||
{ key: 'db_row_count', label: tl('check_db_rows', 'Dati inventario'),critical: false },
|
||||
// Config
|
||||
{ key: 'env_file', label: tl('check_env', 'File .env'), critical: false },
|
||||
{ key: 'gemini_key', label: tl('check_gemini', 'Gemini AI key'), critical: false },
|
||||
{ key: 'bring_credentials', label: tl('check_bring_creds', 'Bring! credenziali'), critical: false },
|
||||
{ key: 'bring_token', label: tl('check_bring_token', 'Bring! token'), critical: false },
|
||||
{ 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 },
|
||||
{ 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 || {};
|
||||
@@ -14978,10 +14983,10 @@ async function _runStartupCheck() {
|
||||
const total = CHECKS.filter(d => checks[d.key] !== undefined).length;
|
||||
let done = 0;
|
||||
|
||||
// Phase 2: step through each check with real-time label
|
||||
// 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
|
||||
if (c === undefined) continue; // not returned by server (feature not enabled)
|
||||
|
||||
done++;
|
||||
const pct = 15 + Math.round((done / total) * 83); // 15→98%
|
||||
@@ -14989,10 +14994,10 @@ async function _runStartupCheck() {
|
||||
const isOpt = c.optional === true || !def.critical;
|
||||
const isFresh = c.fresh === true;
|
||||
|
||||
// Build label: "check name (extra value)"
|
||||
// Build label with value
|
||||
let lbl = def.label;
|
||||
if (c.value) lbl += ` (${c.value})`;
|
||||
if (isFresh) lbl += ` — ${tl('fresh_install', 'nuovo impianto')}`;
|
||||
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(', ')}`;
|
||||
|
||||
@@ -15003,37 +15008,100 @@ async function _runStartupCheck() {
|
||||
(isOpt ? warnings : errors).push({ def, c });
|
||||
}
|
||||
|
||||
await new Promise(r => setTimeout(r, 45)); // ~45ms per step → ~1.3s total
|
||||
await new Promise(r => setTimeout(r, 40));
|
||||
}
|
||||
|
||||
// ── Completed ─────────────────────────────────────────────────────────────
|
||||
// ── Errors → red bar + blocking popup ────────────────────────────────────
|
||||
if (errors.length > 0) {
|
||||
setProgress(100, `❌ ${tl('critical_error_short', 'Errore critico')}`, 'error');
|
||||
const errDetail = errors.map(e => e.def.label + (e.c.error ? `: ${e.c.error}` : '')).join('\n');
|
||||
errorEl.textContent = `${tl('critical_error', 'Errore critico: l\'app non può avviarsi.')}${errDetail ? '\n' + errDetail : ''}`;
|
||||
errorEl.style.display = '';
|
||||
retryBtn.style.display = '';
|
||||
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 only
|
||||
// ── Warnings → amber bar + warning popup auto-close 5s ───────────────────
|
||||
if (warnings.length > 0) {
|
||||
setProgress(100, `⚠️ ${warnings.length} ${tl('warnings_found', 'avvisi rilevati')}`, 'warn');
|
||||
warningsEl.innerHTML = warnings
|
||||
.map(w => `<span class="preloader-warn-badge">⚠️ ${w.def.label}</span>`)
|
||||
.join('');
|
||||
warningsEl.style.display = '';
|
||||
await new Promise(r => setTimeout(r, 2200)); // show warnings for 2.2s
|
||||
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, 700));
|
||||
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();
|
||||
|
||||
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "it.dadaloop.evershelf.kiosk"
|
||||
minSdk = 24
|
||||
targetSdk = 34
|
||||
versionCode = 16
|
||||
versionName = "1.7.15"
|
||||
versionCode = 17
|
||||
versionName = "1.7.16"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
||||
@@ -113,7 +113,9 @@ class KioskActivity : AppCompatActivity() {
|
||||
private const val KEY_SCREENSAVER = "screensaver_enabled"
|
||||
private const val KIOSK_DOWNLOAD_URL = "https://github.com/dadaloop82/EverShelf/releases/download/kiosk-latest/evershelf-kiosk.apk"
|
||||
private const val SPLASH_DURATION = 1500L
|
||||
private const val GITHUB_RELEASES_API = "https://api.github.com/repos/dadaloop82/EverShelf/releases/latest"
|
||||
// Use the kiosk-specific rolling release tag so version comparison is always
|
||||
// against the KIOSK version, not the webapp version (they diverge).
|
||||
private const val GITHUB_RELEASES_API = "https://api.github.com/repos/dadaloop82/EverShelf/releases/tags/kiosk-latest"
|
||||
// Keys for persisting a pending update across restarts
|
||||
private const val KEY_PENDING_UPDATE_VERSION = "pending_update_version"
|
||||
private const val KEY_PENDING_UPDATE_URL = "pending_update_url"
|
||||
@@ -627,10 +629,16 @@ class KioskActivity : AppCompatActivity() {
|
||||
packageManager.getPackageInfo(packageName, 0).versionName ?: ""
|
||||
} catch (_: Exception) { "" }
|
||||
|
||||
// Strip any non-numeric prefix so "kiosk-1.7.0", "v1.7.0", "kiosk-v1.7.1"
|
||||
// all normalise to "1.7.0" / "1.7.1" for comparison.
|
||||
// The kiosk-latest release uses a non-semver tag ("kiosk-latest").
|
||||
// Extract the actual kiosk version from the release body text.
|
||||
// Body format: "Alias automatico → kiosk-X.Y.Z" or just "kiosk-X.Y.Z".
|
||||
// Fall back to stripping the tag prefix if body parsing fails.
|
||||
val bodyText = json.optString("body", "")
|
||||
val norm = { v: String -> v.replace(Regex("^[^0-9]*"), "") }
|
||||
val isSemver = norm(latestTag).matches(Regex("\\d+\\.\\d+.*"))
|
||||
val remoteKioskVersion = Regex("""kiosk-v?(\d+\.\d+(?:\.\d+)?)""")
|
||||
.find(bodyText)?.groupValues?.get(1)
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: norm(latestTag)
|
||||
|
||||
// Compare semver: returns true if `remote` is strictly greater than `local`
|
||||
fun semverNewer(remote: String, local: String): Boolean {
|
||||
@@ -645,29 +653,31 @@ class KioskActivity : AppCompatActivity() {
|
||||
return false
|
||||
}
|
||||
|
||||
val isSemver = remoteKioskVersion.matches(Regex("\\d+\\.\\d+.*"))
|
||||
|
||||
// Get APK URL from assets; fall back to the hardcoded KIOSK_DOWNLOAD_URL
|
||||
val assets = json.optJSONArray("assets")
|
||||
var kioskApkUrl = ""
|
||||
if (assets != null) {
|
||||
for (i in 0 until assets.length()) {
|
||||
val a = assets.getJSONObject(i)
|
||||
val name = a.optString("name", "").lowercase()
|
||||
val url = a.optString("browser_download_url", "")
|
||||
if (name.contains("kiosk") && url.isNotEmpty()) kioskApkUrl = url
|
||||
val a = assets.getJSONObject(i)
|
||||
val url = a.optString("browser_download_url", "")
|
||||
if (url.endsWith(".apk", ignoreCase = true) && url.isNotEmpty()) {
|
||||
kioskApkUrl = url; break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (kioskApkUrl.isEmpty()) kioskApkUrl = KIOSK_DOWNLOAD_URL
|
||||
|
||||
// Only flag an update when the remote tag is parseable as semver AND
|
||||
// the remote version is strictly greater than the installed version.
|
||||
// Non-semver tags (e.g. "kiosk-latest", "rolling") cannot be compared
|
||||
// numerically → treat as "no update" to avoid false positives.
|
||||
val kioskNeedsUpdate = currentKiosk.isNotEmpty() &&
|
||||
isSemver && semverNewer(norm(latestTag), norm(currentKiosk))
|
||||
// Only flag an update when the remote version is parseable as semver AND
|
||||
// strictly greater than the installed version.
|
||||
val kioskNeedsUpdate = currentKiosk.isNotEmpty() && isSemver &&
|
||||
semverNewer(remoteKioskVersion, currentKiosk)
|
||||
|
||||
val result = JSONObject()
|
||||
.put("has_update", kioskNeedsUpdate)
|
||||
.put("current", currentKiosk)
|
||||
.put("latest", latestTag)
|
||||
.put("latest", remoteKioskVersion)
|
||||
.put("apk_url", kioskApkUrl)
|
||||
|
||||
notifyJs(result)
|
||||
@@ -680,12 +690,11 @@ class KioskActivity : AppCompatActivity() {
|
||||
|
||||
// Persist the pending update so the banner reappears after a crash/restart
|
||||
prefs.edit()
|
||||
.putString(KEY_PENDING_UPDATE_VERSION, latestTag)
|
||||
.putString(KEY_PENDING_UPDATE_VERSION, remoteKioskVersion)
|
||||
.putString(KEY_PENDING_UPDATE_URL, kioskApkUrl)
|
||||
.apply()
|
||||
|
||||
val label = if (isSemver) "$currentKiosk → $latestTag" else latestTag
|
||||
runOnUiThread { showNativeUpdateBanner("🔄 Kiosk $label", kioskApkUrl) }
|
||||
runOnUiThread { showNativeUpdateBanner("🔄 Kiosk $currentKiosk → $remoteKioskVersion", kioskApkUrl) }
|
||||
} catch (e: Exception) {
|
||||
notifyJs(JSONObject().put("has_update", false).put("error", e.message ?: "network error"))
|
||||
}
|
||||
@@ -802,6 +811,52 @@ class KioskActivity : AppCompatActivity() {
|
||||
file.delete()
|
||||
return
|
||||
}
|
||||
// ── Pre-install validation via PackageManager ──────────────────────
|
||||
// This catches version-downgrade or same-version attempts before PackageInstaller
|
||||
// gets them (which would silently fail with STATUS_FAILURE=1 on many OEMs).
|
||||
@Suppress("DEPRECATION")
|
||||
val apkInfo = try { packageManager.getPackageArchiveInfo(file.absolutePath, 0) } catch (_: Exception) { null }
|
||||
if (apkInfo != null) {
|
||||
// Wrong package: would always fail with STATUS_FAILURE=1
|
||||
if (apkInfo.packageName != packageName) {
|
||||
val detail = "APK package=${apkInfo.packageName}, expected=$packageName"
|
||||
setInstallUI("\u274C", "APK non valido", detail, 0xFFf87171.toInt(), btnEnabled = true, progress = -2)
|
||||
runOnUiThread { activeInstallBtn?.text = getString(R.string.install_btn_retry) }
|
||||
ErrorReporter.reportMessage("install_wrong_package", detail, mapOf("apk_pkg" to apkInfo.packageName, "expected" to packageName), forceReport = true)
|
||||
file.delete()
|
||||
return
|
||||
}
|
||||
// Version downgrade or same versionCode: Android rejects it
|
||||
@Suppress("DEPRECATION")
|
||||
val apkVc: Long = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
|
||||
apkInfo.longVersionCode
|
||||
else
|
||||
apkInfo.versionCode.toLong()
|
||||
val installedVc: Long = try {
|
||||
@Suppress("DEPRECATION")
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
|
||||
packageManager.getPackageInfo(packageName, 0).longVersionCode
|
||||
else
|
||||
packageManager.getPackageInfo(packageName, 0).versionCode.toLong()
|
||||
} catch (_: Exception) { -1L }
|
||||
|
||||
if (installedVc >= 0 && apkVc <= installedVc) {
|
||||
// Same or older version — no real update, dismiss banner silently
|
||||
runOnUiThread {
|
||||
updateBanner.visibility = View.GONE
|
||||
bannerProgressBar.visibility = View.GONE
|
||||
prefs.edit().remove(KEY_PENDING_UPDATE_VERSION).remove(KEY_PENDING_UPDATE_URL).apply()
|
||||
}
|
||||
ErrorReporter.reportMessage(
|
||||
"install_no_upgrade",
|
||||
"APK versionCode=$apkVc (${apkInfo.versionName}) ≤ installed=$installedVc — not an upgrade",
|
||||
mapOf("apk_vc" to apkVc, "apk_ver" to (apkInfo.versionName ?: ""), "installed_vc" to installedVc),
|
||||
forceReport = true
|
||||
)
|
||||
file.delete()
|
||||
return
|
||||
}
|
||||
}
|
||||
// Only kiosk self-update is handled; gateway is now integrated
|
||||
val targetPkg = packageName
|
||||
installWithPackageInstaller(file, targetPkg)
|
||||
|
||||
@@ -110,6 +110,9 @@ 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
|
||||
@@ -128,6 +131,9 @@ 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
|
||||
|
||||
@@ -238,10 +244,17 @@ class SetupActivity : AppCompatActivity() {
|
||||
tvTestWeight = findViewById(R.id.tvTestWeight)
|
||||
testWeightBox = findViewById(R.id.testWeightBox)
|
||||
|
||||
// Screensaver step
|
||||
// Features step — bind all four toggles
|
||||
setupSwitchScreensaver = findViewById(R.id.setupSwitchScreensaver)
|
||||
// Pre-fill saved screensaver pref
|
||||
setupSwitchScreensaver.isChecked = prefs.getBoolean(KEY_SCREENSAVER, false)
|
||||
setupSwitchPrices = findViewById(R.id.setupSwitchPrices)
|
||||
setupSwitchMealPlan = findViewById(R.id.setupSwitchMealPlan)
|
||||
setupSwitchZeroWaste = findViewById(R.id.setupSwitchZeroWaste)
|
||||
// Pre-fill from saved prefs only if each key was previously configured
|
||||
// ("se non sono impostati, chiedi!" — fresh install → all start at false)
|
||||
setupSwitchScreensaver.isChecked = if (prefs.contains(KEY_SCREENSAVER)) prefs.getBoolean(KEY_SCREENSAVER, false) else false
|
||||
setupSwitchPrices.isChecked = if (prefs.contains(KEY_PRICE_ENABLED)) prefs.getBoolean(KEY_PRICE_ENABLED, false) else false
|
||||
setupSwitchMealPlan.isChecked = if (prefs.contains(KEY_MEAL_PLAN)) prefs.getBoolean(KEY_MEAL_PLAN, false) else false
|
||||
setupSwitchZeroWaste.isChecked = if (prefs.contains(KEY_ZEROWASTE_TIPS)) prefs.getBoolean(KEY_ZEROWASTE_TIPS, false) else false
|
||||
|
||||
// Done step
|
||||
summaryText = findViewById(R.id.setupSummaryText)
|
||||
@@ -381,10 +394,15 @@ class SetupActivity : AppCompatActivity() {
|
||||
findViewById<MaterialButton>(R.id.btnScaleNext).isEnabled = true
|
||||
}
|
||||
|
||||
// ── Screensaver ───────────────────────────────────────────────────
|
||||
// ── Features step (screensaver / prices / meal plan / zero-waste) ────
|
||||
findViewById<MaterialButton>(R.id.btnScreensaverBack).setOnClickListener { showStep(4) }
|
||||
findViewById<MaterialButton>(R.id.btnScreensaverNext).setOnClickListener {
|
||||
prefs.edit().putBoolean(KEY_SCREENSAVER, setupSwitchScreensaver.isChecked).apply()
|
||||
prefs.edit()
|
||||
.putBoolean(KEY_SCREENSAVER, setupSwitchScreensaver.isChecked)
|
||||
.putBoolean(KEY_PRICE_ENABLED, setupSwitchPrices.isChecked)
|
||||
.putBoolean(KEY_MEAL_PLAN, setupSwitchMealPlan.isChecked)
|
||||
.putBoolean(KEY_ZEROWASTE_TIPS, setupSwitchZeroWaste.isChecked)
|
||||
.apply()
|
||||
showStep(6)
|
||||
}
|
||||
|
||||
@@ -971,13 +989,16 @@ class SetupActivity : AppCompatActivity() {
|
||||
// ── Summary / Finish ─────────────────────────────────────────────────
|
||||
|
||||
private fun buildSummary() {
|
||||
val url = prefs.getString(KEY_URL, "") ?: ""
|
||||
val hasScale = prefs.getBoolean(KEY_HAS_SCALE, false)
|
||||
val screensOn = setupSwitchScreensaver.isChecked
|
||||
val scaleName = bleManager?.getSavedDeviceName()
|
||||
val scaleOk = hasScale && scaleName != null
|
||||
val lang = prefs.getString(KEY_LANGUAGE, "it") ?: "it"
|
||||
val langLabel = when (lang) { "en" -> "English 🇬🇧"; "de" -> "Deutsch 🇩🇪"; else -> "Italiano 🇮🇹" }
|
||||
val url = prefs.getString(KEY_URL, "") ?: ""
|
||||
val hasScale = prefs.getBoolean(KEY_HAS_SCALE, false)
|
||||
val screensOn = setupSwitchScreensaver.isChecked
|
||||
val pricesOn = setupSwitchPrices.isChecked
|
||||
val mealPlanOn = setupSwitchMealPlan.isChecked
|
||||
val zeroWasteOn = setupSwitchZeroWaste.isChecked
|
||||
val scaleName = bleManager?.getSavedDeviceName()
|
||||
val scaleOk = hasScale && scaleName != null
|
||||
val lang = prefs.getString(KEY_LANGUAGE, "it") ?: "it"
|
||||
val langLabel = when (lang) { "en" -> "English 🇬🇧"; "de" -> "Deutsch 🇩🇪"; else -> "Italiano 🇮🇹" }
|
||||
val sb = StringBuilder()
|
||||
sb.appendLine("🌐 ${getString(R.string.summary_lang)}: $langLabel")
|
||||
if (url.isNotEmpty()) sb.appendLine("🖥️ Server: $url")
|
||||
@@ -986,7 +1007,10 @@ 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)}")
|
||||
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))
|
||||
summaryText.text = sb.toString().trimEnd()
|
||||
}
|
||||
|
||||
@@ -994,16 +1018,20 @@ class SetupActivity : AppCompatActivity() {
|
||||
prefs.edit().putBoolean(KEY_SETUP_COMPLETE, true).apply()
|
||||
val baseUrl = (prefs.getString(KEY_URL, "") ?: "").trimEnd('/')
|
||||
if (baseUrl.isNotEmpty()) {
|
||||
val hasScale = prefs.getBoolean(KEY_HAS_SCALE, false) && (bleManager?.getSavedDeviceAddress() != null)
|
||||
val screensaver = prefs.getBoolean(KEY_SCREENSAVER, false)
|
||||
val hasScale = prefs.getBoolean(KEY_HAS_SCALE, false) && (bleManager?.getSavedDeviceAddress() != null)
|
||||
val screensaver = prefs.getBoolean(KEY_SCREENSAVER, false)
|
||||
val priceEnabled = prefs.getBoolean(KEY_PRICE_ENABLED, false)
|
||||
val mealPlan = prefs.getBoolean(KEY_MEAL_PLAN, false)
|
||||
val zeroWaste = prefs.getBoolean(KEY_ZEROWASTE_TIPS, false)
|
||||
Thread {
|
||||
try {
|
||||
val url = "$baseUrl/api/index.php?action=save_settings"
|
||||
val 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\"")
|
||||
}
|
||||
|
||||
@@ -1050,7 +1050,7 @@
|
||||
</LinearLayout>
|
||||
|
||||
<!-- ════════════════════════════════════════════
|
||||
STEP 5 — Screensaver
|
||||
STEP 5 — Features
|
||||
════════════════════════════════════════════ -->
|
||||
<LinearLayout
|
||||
android:id="@+id/stepScreensaver"
|
||||
@@ -1063,66 +1063,58 @@
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="🌙"
|
||||
android:text="⚡"
|
||||
android:textSize="52sp"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:id="@+id/tvScreensaverTitle"
|
||||
android:text="@string/setup_screensaver_title"
|
||||
android:text="@string/setup_features_title"
|
||||
android:textColor="#f1f5f9"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvScreensaverDesc"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Dopo 5 minuti di inattività mostra un overlay con l'orologio e informazioni utili (statistiche, piano pasti). Lo schermo rimane SEMPRE acceso — questa opzione riguarda solo l'overlay visivo in-app."
|
||||
android:text="@string/setup_features_desc"
|
||||
android:textColor="#94a3b8"
|
||||
android:textSize="15sp"
|
||||
android:gravity="center"
|
||||
android:lineSpacingExtra="4dp"
|
||||
android:layout_marginBottom="28dp" />
|
||||
android:layout_marginBottom="20dp" />
|
||||
|
||||
<!-- Toggle card -->
|
||||
<!-- Toggle: Screensaver -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:background="@drawable/card_background"
|
||||
android:padding="20dp"
|
||||
android:padding="16dp"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="32dp">
|
||||
|
||||
android:layout_marginBottom="10dp">
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvScreensaverToggleLabel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/setup_screensaver_toggle_label"
|
||||
android:textColor="#f1f5f9"
|
||||
android:textSize="16sp"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="4dp" />
|
||||
|
||||
android:layout_marginBottom="3dp" />
|
||||
<TextView
|
||||
android:id="@+id/tvScreensaverToggleHint"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/setup_screensaver_toggle_hint"
|
||||
android:textColor="#64748b"
|
||||
android:textSize="13sp" />
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/setupSwitchScreensaver"
|
||||
android:layout_width="wrap_content"
|
||||
@@ -1130,6 +1122,114 @@
|
||||
android:checked="false" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Toggle: Prezzi lista spesa -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:background="@drawable/card_background"
|
||||
android:padding="16dp"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="10dp">
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/setup_prices_toggle_label"
|
||||
android:textColor="#f1f5f9"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="3dp" />
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/setup_prices_toggle_hint"
|
||||
android:textColor="#64748b"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/setupSwitchPrices"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checked="false" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Toggle: Piano pasti -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:background="@drawable/card_background"
|
||||
android:padding="16dp"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="10dp">
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/setup_mealplan_toggle_label"
|
||||
android:textColor="#f1f5f9"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="3dp" />
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/setup_mealplan_toggle_hint"
|
||||
android:textColor="#64748b"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/setupSwitchMealPlan"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checked="false" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Toggle: Suggerimenti zero-waste -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:background="@drawable/card_background"
|
||||
android:padding="16dp"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="24dp">
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/setup_zerowaste_toggle_label"
|
||||
android:textColor="#f1f5f9"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="3dp" />
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/setup_zerowaste_toggle_hint"
|
||||
android:textColor="#64748b"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/setupSwitchZeroWaste"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checked="false" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Navigation -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -58,15 +58,26 @@
|
||||
<string name="wizard_server_ok_detail">Error reporting is active — install failures will be sent to GitHub Issues automatically.</string>
|
||||
<string name="wizard_server_error">Server not reachable ⚠️</string>
|
||||
<string name="wizard_server_error_detail">Install errors won\'t reach GitHub Issues. Check the URL entered in step 2.</string>
|
||||
<!-- Screensaver step -->
|
||||
<!-- Features step (step 5) -->
|
||||
<string name="setup_features_title">Funzionalità</string>
|
||||
<string name="setup_features_desc">Attiva le funzioni che vuoi usare. Puoi sempre cambiarle in seguito dalle impostazioni del server.</string>
|
||||
<string name="setup_screensaver_title">Salvaschermo in-app</string>
|
||||
<string name="setup_screensaver_desc">Shows a clock with useful facts after 5 minutes of inactivity. Off by default (screen stays always on).</string>
|
||||
<string name="setup_screensaver_toggle_label">Abilita salvaschermo orologio</string>
|
||||
<string name="setup_screensaver_toggle_hint">Mostra l\'overlay orologio dopo 5 min. Lo schermo resta sempre acceso.</string>
|
||||
<string name="setup_screensaver_toggle_label">Salvaschermo orologio</string>
|
||||
<string name="setup_screensaver_toggle_hint">Mostra l\'overlay orologio dopo 5 min di inattività.</string>
|
||||
<string name="setup_prices_toggle_label">Prezzi lista spesa</string>
|
||||
<string name="setup_prices_toggle_hint">Stima automatica del costo di ogni articolo in lista tramite AI.</string>
|
||||
<string name="setup_mealplan_toggle_label">Piano pasti</string>
|
||||
<string name="setup_mealplan_toggle_hint">Pianifica i pasti della settimana suggerendo ricette basate sulla dispensa.</string>
|
||||
<string name="setup_zerowaste_toggle_label">Suggerimenti zero-waste</string>
|
||||
<string name="setup_zerowaste_toggle_hint">Durante la cottura mostra consigli per riutilizzare scarti (bucce, acqua di cottura, ecc.).</string>
|
||||
|
||||
<!-- Summary -->
|
||||
<string name="summary_lang">Language</string>
|
||||
<string name="summary_scale_skip">Scale: not configured</string>
|
||||
<string name="summary_screensaver_on">Screensaver: enabled</string>
|
||||
<string name="summary_screensaver_off">Screen always on (screensaver disabled)</string>
|
||||
<string name="summary_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>
|
||||
</resources>
|
||||
|
||||
+4
-4
@@ -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=20260520b">
|
||||
<link rel="stylesheet" href="assets/css/style.css?v=20260517a">
|
||||
<!-- QuaggaJS for barcode scanning -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2@1.8.4/dist/quagga.min.js"></script>
|
||||
<!-- @xenova/transformers: ES-module bootstrap that exposes a lazy category-classifier as window._categoryPipelinePromise -->
|
||||
@@ -64,7 +64,7 @@
|
||||
<div id="preloader-warnings" class="preloader-warnings" style="display:none"></div>
|
||||
<div id="preloader-error-msg" class="preloader-error-msg" style="display:none"></div>
|
||||
<button id="preloader-retry-btn" class="preloader-retry-btn" style="display:none" onclick="_startupRetry()">🔄 <span data-i18n="startup.retry">Riprova</span></button>
|
||||
<span class="app-preloader-version" id="preloader-version">v1.7.21</span>
|
||||
<span class="app-preloader-version" id="preloader-version">v1.7.22</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
<!-- Title — left-aligned; grows to fill space -->
|
||||
<div class="header-title-wrap">
|
||||
<h1 class="header-title" onclick="showPage('dashboard')">
|
||||
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.21</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.22</span>
|
||||
</h1>
|
||||
<!-- Update badge — shown alongside title, never replaces it -->
|
||||
<span class="header-update-badge" id="header-update-badge" style="display:none"></span>
|
||||
@@ -1613,6 +1613,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="assets/js/app.js?v=20260520b"></script>
|
||||
<script src="assets/js/app.js?v=20260517a"></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.20",
|
||||
"version": "1.7.22",
|
||||
"start_url": "/evershelf/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f0f4e8",
|
||||
|
||||
@@ -1212,6 +1212,7 @@
|
||||
"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",
|
||||
@@ -1222,6 +1223,8 @@
|
||||
"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",
|
||||
@@ -1229,7 +1232,9 @@
|
||||
"all_ok": "System OK",
|
||||
"critical_error_short": "Kritischer Fehler",
|
||||
"critical_error": "Kritischer Fehler: Die App kann nicht gestartet werden. Prüfe die Serverlogs.",
|
||||
"error_network": "Server nicht erreichbar. Bitte Netzwerkverbindung prüfen.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1212,6 +1212,7 @@
|
||||
"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",
|
||||
@@ -1222,6 +1223,8 @@
|
||||
"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",
|
||||
@@ -1229,7 +1232,9 @@
|
||||
"all_ok": "System OK",
|
||||
"critical_error_short": "Critical error",
|
||||
"critical_error": "Critical error: the app cannot start. Check your server logs.",
|
||||
"error_network": "Cannot reach the server. Check your network connection.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1212,6 +1212,7 @@
|
||||
"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",
|
||||
@@ -1222,6 +1223,8 @@
|
||||
"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",
|
||||
@@ -1229,7 +1232,9 @@
|
||||
"all_ok": "Sistema OK",
|
||||
"critical_error_short": "Errore critico",
|
||||
"critical_error": "Errore critico: l'app non può avviarsi. Controlla i log del server.",
|
||||
"error_network": "Impossibile contattare il server. Controlla la connessione di rete.",
|
||||
"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