Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f0acd0dfa | |||
| ba0c4c3d88 | |||
| a58ef241e9 | |||
| bd5d4bcac6 | |||
| c9a859463c | |||
| b3454062bf | |||
| 56e68b72f8 | |||
| b91203f151 | |||
| cc0d9763ed | |||
| d8c7d1545a | |||
| 9f554c6e22 | |||
| 4f715730ec | |||
| dc3cefefd0 | |||
| a2eaf695bb | |||
| db2e32322b | |||
| 36821bde7a | |||
| 9d49609e4b | |||
| de897cc0f9 | |||
| 30f4bf4a1b | |||
| 1379cfc388 | |||
| 2806cb0903 | |||
| 56b6eb5f0d | |||
| 83d1868309 | |||
| 788d4fe848 | |||
| 91616b3a6d | |||
| 844fe3ba1e | |||
| da4aa5a1ae | |||
| 9541e3a385 | |||
| 47ce849311 |
@@ -51,3 +51,4 @@ data/latest_release_cache.json
|
||||
data/food_facts_cache.json
|
||||
data/category_ai_cache.json
|
||||
assets/img/logo/*_backup.*
|
||||
logs/*.log
|
||||
|
||||
@@ -11,6 +11,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- **Recipe scraps tips** — During cooking steps, detect "waste" generated (peels, cores, bones, eggshells, coffee grounds, citrus zest, etc.) and surface AI-powered tips on how to reuse them (compost, natural cleaner, broth, candied peel, etc.). Could be shown as an optional collapsible hint card below the step that generates the scrap.
|
||||
|
||||
## [1.7.23] - 2026-05-18
|
||||
|
||||
### Added
|
||||
- **⚙️ Generali tab** — new first tab in Settings groups all global settings: language, currency, theme, screensaver, zero-waste tips, inventory export. Old Language tab removed.
|
||||
- **DB auto-cleanup** — `RECIPE_RETENTION_DAYS` (default 7) and `TRANSACTION_RETENTION_DAYS` (default 7) added to `.env`; old rows are deleted automatically every cron cycle, followed by `VACUUM` to compact the database. Manual trigger: `GET /api/?action=db_cleanup`.
|
||||
- **Vacuum-sealed expiry grace period** — `VACUUM_EXPIRY_EXTENSION_DAYS` (default 30): vacuum-sealed products are only flagged as expired N days *after* the printed date, preventing false alarms on long-lasting items like cured meats.
|
||||
- **Gemini AI usage tracking** — monthly and yearly token/cost stats now shown in Settings → ℹ️ Info tab, using tracked data from `data/ai_usage.json`. Cost rates configurable via `GEMINI_COST_25F_IN/OUT` and `GEMINI_COST_20F_IN/OUT` in `.env`.
|
||||
|
||||
### Changed
|
||||
- **Auto theme is now time-based** — "Automatico" mode switches to dark at 20:00 and back to light at 07:00, based on server/device clock (not OS preference). Re-evaluates every 5 minutes; ideal for always-on kiosk displays.
|
||||
- **`dispensa.db` auto-deleted** — if the legacy empty `dispensa.db` file appears alongside `evershelf.db`, it is now removed automatically by the health check.
|
||||
- **ZeroWaste tips and screensaver timeout** — these settings were not being persisted to `.env` on save (missing from POST payload); fixed.
|
||||
|
||||
## [1.7.22] - 2026-05-17
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -38,10 +38,11 @@
|
||||
|
||||
## ✨ Features
|
||||
|
||||
> ♻️ **New in v1.7.19 — Zero-waste cooking tips**
|
||||
> During cooking, EverShelf shows a contextual ♻️ tip card for each step that generates reusable scraps — peels, cooking water, egg whites, cheese rinds, bread crusts and more.
|
||||
> Tips are generated by Gemini *as part of the recipe* at zero extra API cost, shown inline in cooking mode, and dismissible per step.
|
||||
> Enable the toggle in **Settings → Zero-waste tips** (default: off).
|
||||
> ⚙️ **New in v1.7.23 — Global settings tab, DB auto-cleanup, vacuum-sealed expiry**
|
||||
> A new **Generali** tab groups all global settings (language, currency, theme, screensaver, zero-waste, export) in one place.
|
||||
> Recipes older than `RECIPE_RETENTION_DAYS` and transactions older than `TRANSACTION_RETENTION_DAYS` are deleted automatically every cron cycle, followed by a SQLite `VACUUM` to keep the database small.
|
||||
> Vacuum-sealed products get an extended grace period (`VACUUM_EXPIRY_EXTENSION_DAYS`, default 30 days) before being flagged as expired.
|
||||
> Auto theme now follows **time of day** (dark 20:00–07:00) instead of the OS setting, making it server-friendly.
|
||||
|
||||
### 📦 Inventory Management
|
||||
- **Export inventory** — Download the full inventory as a UTF-8 CSV (Excel-compatible) or open a print-ready page to save as PDF; export button always visible in the inventory page header
|
||||
@@ -50,7 +51,7 @@
|
||||
- **Smart locations** — Track items across Pantry, Fridge, Freezer, and custom locations
|
||||
- **Expiry tracking** — Automatic shelf-life estimation based on product type and storage
|
||||
- **Opened product tracking** — Reduced shelf-life calculation when packages are opened; opened-product expiry is now also checked when building banner alerts (not just the dashboard section)
|
||||
- **Vacuum-sealed support** — Extended expiry dates for vacuum-sealed items
|
||||
- **Vacuum-sealed support** — Extended expiry dates for vacuum-sealed items; products sealed under vacuum are only flagged as expired after a configurable grace period past the printed date (`VACUUM_EXPIRY_EXTENSION_DAYS`, default 30 days, configurable in `.env`)
|
||||
- **Anomaly detection** — Banner alerts for suspicious quantities and consumption predictions with inline correction; dismiss button now shows the current inventory quantity so the action is unambiguous ("Quantity is correct (2 pcs)")
|
||||
|
||||
### 🤖 AI-Powered (Google Gemini)
|
||||
@@ -98,9 +99,15 @@
|
||||
- **Quick-access buttons** — Recently used and most popular products shown on the inventory page for fast access
|
||||
|
||||
### 🌙 Appearance
|
||||
- **Dark mode** — Three modes: Light, Dark, and Auto (follows the OS/browser setting); theme is applied before the first render to prevent a white flash on dark-mode systems; toggle in Settings → Appearance
|
||||
- **Dark mode** — Three modes: Light, Dark, and Auto (time-based: dark from 20:00 to 07:00, light otherwise); applies immediately without page reload; auto mode re-evaluates every 5 minutes, so night/day transitions happen automatically even on always-on kiosk displays; theme is applied before the first render to prevent a white flash
|
||||
- **Global settings tab** — A dedicated **⚙️ Generali** tab groups all system-wide settings (language, currency, theme, screensaver, zero-waste tips, export) at the top of the Settings panel
|
||||
|
||||
### 📱 Progressive Web App
|
||||
### �️ Database Maintenance
|
||||
- **Automatic cleanup** — Recipes older than `RECIPE_RETENTION_DAYS` (default 7) and transactions older than `TRANSACTION_RETENTION_DAYS` (default 7) are deleted automatically on every cron cycle; SQLite `VACUUM` runs after each cleanup to keep the file compact
|
||||
- **Manual cleanup** — Trigger immediately via `GET /api/?action=db_cleanup`
|
||||
- **Compact by default** — Fresh installs stay small; large accumulated databases shrink back to a few hundred KB within one cron cycle
|
||||
|
||||
### �📱 Progressive Web App
|
||||
- **Mobile-first design** — Optimized for phones, works on tablets and desktop
|
||||
- **Installable** — Add to home screen for a native app experience
|
||||
- **Multi-device** — All user data (shopping tags, pinned items, location preferences, scan history) is stored server-side in SQLite and shared across every device on the same instance; no data is siloed in a single browser's localStorage
|
||||
@@ -192,12 +199,32 @@ TTS_URL=http://your-home-assistant:8123/api/events/tts_speak
|
||||
TTS_TOKEN=your_long_lived_token
|
||||
TTS_ENABLED=true
|
||||
|
||||
# Optional: DB retention and cleanup (applied automatically each cron cycle)
|
||||
RECIPE_RETENTION_DAYS=7 # delete recipe plans older than N days
|
||||
TRANSACTION_RETENTION_DAYS=7 # delete stock transactions older than N days
|
||||
|
||||
# Optional: Vacuum-sealed expiry grace period
|
||||
VACUUM_EXPIRY_EXTENSION_DAYS=30 # extra days before vacuum-sealed items are flagged expired
|
||||
|
||||
# Optional: Gemini cost rates (USD per million tokens, for the Info tab cost estimate)
|
||||
GEMINI_COST_25F_IN=0.15
|
||||
GEMINI_COST_25F_OUT=0.60
|
||||
GEMINI_COST_20F_IN=0.10
|
||||
GEMINI_COST_20F_OUT=0.40
|
||||
|
||||
# Optional: Security — protect the save_settings endpoint
|
||||
# Set a strong random string; the Settings UI will ask for it before saving
|
||||
SETTINGS_TOKEN=
|
||||
|
||||
# Optional: Demo mode — block all write operations at the router level
|
||||
DEMO_MODE=false
|
||||
|
||||
# Optional: Logging
|
||||
# LOG_LEVEL sets the minimum severity written to disk (DEBUG / INFO / WARN / ERROR)
|
||||
# DEBUG also logs every SQL query executed against the database
|
||||
LOG_LEVEL=INFO
|
||||
LOG_ROTATE_HOURS=24 # hours before opening a new log file (default: 24)
|
||||
LOG_MAX_FILES=14 # maximum number of rotated files to keep (default: 14)
|
||||
```
|
||||
|
||||
### Web Server Configuration
|
||||
|
||||
@@ -79,6 +79,19 @@ try {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Shelf life pre-warm warning: ' . $pe->getMessage() . "\n";
|
||||
}
|
||||
|
||||
// ── DB cleanup (retention policy) ────────────────────────────────────
|
||||
// Delete old recipes and transactions based on .env retention settings.
|
||||
try {
|
||||
ob_start();
|
||||
dbCleanup($db);
|
||||
ob_end_clean();
|
||||
echo '[' . date('Y-m-d H:i:s') . '] DB cleanup done'
|
||||
. ' (recipes >' . env('RECIPE_RETENTION_DAYS','7') . 'd'
|
||||
. ', tx >' . env('TRANSACTION_RETENTION_DAYS','7') . 'd' . ")\n";
|
||||
} catch (Throwable $ce) {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] DB cleanup warning: ' . $ce->getMessage() . "\n";
|
||||
}
|
||||
|
||||
} catch (Throwable $e) {
|
||||
$msg = $e->getMessage();
|
||||
echo '[' . date('Y-m-d H:i:s') . '] ERROR: ' . $msg . "\n";
|
||||
|
||||
+10
-3
@@ -40,8 +40,13 @@ function _ensureDataDir(): void {
|
||||
|
||||
function getDB(): PDO {
|
||||
_ensureDataDir();
|
||||
// logger.php is required by index.php before getDB() is called.
|
||||
// In cron context it may not be loaded yet — guard with class_exists.
|
||||
$useLogging = class_exists('LoggingPDO', false);
|
||||
$isNew = !file_exists(DB_PATH);
|
||||
$db = new PDO('sqlite:' . DB_PATH);
|
||||
$db = $useLogging
|
||||
? new LoggingPDO('sqlite:' . DB_PATH)
|
||||
: new PDO('sqlite:' . DB_PATH);
|
||||
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||
$db->exec("PRAGMA journal_mode=WAL");
|
||||
@@ -379,8 +384,10 @@ function estimateOpenedExpiryDaysPHP(string $name, string $category, string $loc
|
||||
if (preg_match('/\b(yogurt|yaourt|yoghurt)\b/', $n)) return 5;
|
||||
if (preg_match('/mozzarella|burrata|stracciatella/', $n)) return 3;
|
||||
if (preg_match('/philadelphia|spalmabile/', $n)) return 7;
|
||||
// Specific hard cheeses that contain 'fresco' in their commercial name (e.g. Asiago fresco)
|
||||
// must be matched BEFORE the generic 'formaggio fresco' catch-all
|
||||
if (preg_match('/parmigiano|grana|pecorino|provolone|asiago|fontina|emmental|gruyere|scamorza|groviera/', $n)) return 28;
|
||||
if (preg_match('/formaggio.*(fresco|ricotta|mascarpone|stracchino|crescenza)/', $n)) return 5;
|
||||
if (preg_match('/parmigiano|grana|pecorino|provolone/', $n)) return 21;
|
||||
if (preg_match('/formaggio/', $n)) return 10;
|
||||
if (preg_match('/\bburro\b/', $n)) return 30;
|
||||
if (preg_match('/\bpanna\b/', $n)) return 4;
|
||||
@@ -449,7 +456,7 @@ function estimateSealedExpiryDaysPHP(string $name, string $category, string $loc
|
||||
elseif (preg_match('/yogurt/', $n)) $days = 21;
|
||||
elseif (preg_match('/mozzarella|burrata|stracciatella/', $n)) $days = 5;
|
||||
elseif (preg_match('/formaggio\s+(fresco|ricotta|mascarpone|stracchino|crescenza)/', $n)) $days = 10;
|
||||
elseif (preg_match('/parmigiano|grana|pecorino|provolone/', $n)) $days = 60;
|
||||
elseif (preg_match('/parmigiano|grana|pecorino|provolone|asiago|fontina|emmental|gruyere|scamorza|groviera/', $n)) $days = 60;
|
||||
elseif (preg_match('/burro/', $n)) $days = 60;
|
||||
elseif (preg_match('/panna/', $n)) $days = 14;
|
||||
elseif (preg_match('/prosciutto\s+cotto|mortadella|wurstel/', $n)) $days = 7;
|
||||
|
||||
+533
-86
File diff suppressed because it is too large
Load Diff
+375
@@ -0,0 +1,375 @@
|
||||
<?php
|
||||
/**
|
||||
* EverShelf Logger — rotating file logger with 4 configurable levels.
|
||||
*
|
||||
* Levels (in order of verbosity):
|
||||
* DEBUG(0) — ogni minima operazione: query, cache, AI payload, function entry/exit
|
||||
* INFO (1) — azioni completate, AI result summary, sync status [default]
|
||||
* WARN (2) — rate limit, cache miss, AI fallback, token renewal, slow op
|
||||
* ERROR(3) — DB failure, AI API error, file write error, exception
|
||||
*
|
||||
* Config via .env (all optional):
|
||||
* LOG_LEVEL = INFO (DEBUG|INFO|WARN|ERROR)
|
||||
* LOG_ROTATE_HOURS = 24 (new file every N hours; 1–168; default 24)
|
||||
* LOG_MAX_FILES = 14 (max rotated files to keep; default 14)
|
||||
*
|
||||
* Log files: logs/evershelf_YYYY-MM-DD_HH.log
|
||||
* Each line: [2026-05-18 14:23:11] [INFO ] [rid=a1b2c3d4] [action] Message {ctx}
|
||||
*/
|
||||
class EverLog {
|
||||
|
||||
// ── Level constants ────────────────────────────────────────────────────
|
||||
const DEBUG = 0;
|
||||
const INFO = 1;
|
||||
const WARN = 2;
|
||||
const ERROR = 3;
|
||||
|
||||
private static bool $initialized = false;
|
||||
private static int $level = self::INFO;
|
||||
private static string $logFile = '';
|
||||
private static string $logDir = '';
|
||||
private static int $rotateHours = 24;
|
||||
private static int $maxFiles = 14;
|
||||
private static string $requestId = '';
|
||||
private static string $currentAction = '-';
|
||||
|
||||
// ── Init (called lazily on first write) ────────────────────────────────
|
||||
private static function init(): void {
|
||||
if (self::$initialized) return;
|
||||
self::$initialized = true;
|
||||
|
||||
// Read .env values via getenv() (populated by Apache SetEnv or putenv() in index.php)
|
||||
$envLevel = strtoupper((string)(getenv('LOG_LEVEL') ?: 'INFO'));
|
||||
$rotateHours = max(1, min(168, (int)(getenv('LOG_ROTATE_HOURS') ?: 24)));
|
||||
$maxFiles = max(1, min(365, (int)(getenv('LOG_MAX_FILES') ?: 14)));
|
||||
|
||||
self::$level = match($envLevel) {
|
||||
'DEBUG' => self::DEBUG,
|
||||
'WARN' => self::WARN,
|
||||
'ERROR' => self::ERROR,
|
||||
default => self::INFO,
|
||||
};
|
||||
self::$rotateHours = $rotateHours;
|
||||
self::$maxFiles = $maxFiles;
|
||||
self::$requestId = substr(bin2hex(random_bytes(4)), 0, 8);
|
||||
|
||||
// Ensure log directory exists
|
||||
$base = dirname(__DIR__) . '/logs';
|
||||
self::$logDir = $base;
|
||||
if (!is_dir($base)) {
|
||||
@mkdir($base, 0755, true);
|
||||
}
|
||||
|
||||
// Compute current log file path (slot by rotate-hours bucket)
|
||||
$slotTs = (int)(floor(time() / ($rotateHours * 3600)) * ($rotateHours * 3600));
|
||||
$slotLabel = gmdate('Y-m-d_H', $slotTs);
|
||||
self::$logFile = "$base/evershelf_{$slotLabel}.log";
|
||||
|
||||
// Rotate (delete oldest files beyond max)
|
||||
self::rotate();
|
||||
}
|
||||
|
||||
// ── Rotate old log files ───────────────────────────────────────────────
|
||||
private static function rotate(): void {
|
||||
$files = glob(self::$logDir . '/evershelf_*.log') ?: [];
|
||||
if (count($files) <= self::$maxFiles) return;
|
||||
sort($files); // oldest first (filenames are lexicographically sortable by date)
|
||||
$toDelete = array_slice($files, 0, count($files) - self::$maxFiles);
|
||||
foreach ($toDelete as $f) {
|
||||
@unlink($f);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Core write ────────────────────────────────────────────────────────
|
||||
private static function write(int $lvl, string $msg, array $ctx, string $action): void {
|
||||
self::init();
|
||||
if ($lvl < self::$level) return;
|
||||
|
||||
$labels = ['DEBUG', 'INFO ', 'WARN ', 'ERROR'];
|
||||
$ts = gmdate('Y-m-d H:i:s');
|
||||
$act = $action !== '-' ? $action : self::$currentAction;
|
||||
$ctxStr = empty($ctx) ? '' : ' ' . json_encode($ctx, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
$line = "[{$ts}] [{$labels[$lvl]}] [rid=" . self::$requestId . "] [{$act}] {$msg}{$ctxStr}\n";
|
||||
|
||||
@file_put_contents(self::$logFile, $line, FILE_APPEND | LOCK_EX);
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────
|
||||
|
||||
/** Set the current action name (shown in every subsequent log line for this request). */
|
||||
public static function setAction(string $action): void {
|
||||
self::$currentAction = $action;
|
||||
}
|
||||
|
||||
/** Log at DEBUG level — every minor operation, query, cache hit/miss, AI payload. */
|
||||
public static function debug(string $msg, array $ctx = [], string $action = '-'): void {
|
||||
self::write(self::DEBUG, $msg, $ctx, $action);
|
||||
}
|
||||
|
||||
/** Log at INFO level — action completed, recipe generated, sync done. */
|
||||
public static function info(string $msg, array $ctx = [], string $action = '-'): void {
|
||||
self::write(self::INFO, $msg, $ctx, $action);
|
||||
}
|
||||
|
||||
/** Log at WARN level — rate limit, AI fallback, slow op, token renewal. */
|
||||
public static function warn(string $msg, array $ctx = [], string $action = '-'): void {
|
||||
self::write(self::WARN, $msg, $ctx, $action);
|
||||
}
|
||||
|
||||
/** Log at ERROR level — DB failure, AI API error, file write error, exception. */
|
||||
public static function error(string $msg, array $ctx = [], string $action = '-'): void {
|
||||
self::write(self::ERROR, $msg, $ctx, $action);
|
||||
}
|
||||
|
||||
/** Convenience: log a Throwable at ERROR level with class + location. */
|
||||
public static function exception(\Throwable $e, string $action = '-', array $extra = []): void {
|
||||
self::write(self::ERROR, $e->getMessage(), array_merge([
|
||||
'class' => get_class($e),
|
||||
'at' => basename($e->getFile()) . ':' . $e->getLine(),
|
||||
'trace' => substr($e->getTraceAsString(), 0, 800),
|
||||
], $extra), $action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the start of an action request (INFO).
|
||||
* Automatically sets the current action name so subsequent lines inherit it.
|
||||
*/
|
||||
public static function request(string $action, string $method, array $params = []): void {
|
||||
self::setAction($action);
|
||||
// At DEBUG: include all params; at INFO just the action+method
|
||||
if (self::$level <= self::DEBUG) {
|
||||
self::write(self::DEBUG, "→ {$method} /{$action}", $params, $action);
|
||||
} else {
|
||||
self::write(self::INFO, "→ {$method} /{$action}", [], $action);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a DB query at DEBUG level.
|
||||
* @param string $sql Truncated SQL or a descriptive label
|
||||
* @param mixed $result Number of rows affected/returned (optional)
|
||||
* @param float $elapsed Execution time in seconds (optional)
|
||||
*/
|
||||
public static function query(string $sql, $result = null, float $elapsed = 0.0): void {
|
||||
if (self::$level > self::DEBUG) return; // skip entirely unless DEBUG
|
||||
$ctx = [];
|
||||
if ($result !== null) $ctx['rows'] = $result;
|
||||
if ($elapsed > 0) $ctx['ms'] = round($elapsed * 1000, 1);
|
||||
if ($elapsed > 1.0) $ctx['SLOW'] = true; // highlight slow queries even in context
|
||||
self::write(self::DEBUG, 'DB: ' . substr($sql, 0, 200), $ctx, self::$currentAction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a slow operation as WARN regardless of configured level.
|
||||
* Call this after any operation that took more than $thresholdSec.
|
||||
*/
|
||||
public static function slowOp(string $label, float $elapsed, float $thresholdSec = 2.0): void {
|
||||
if ($elapsed < $thresholdSec) return;
|
||||
self::write(self::WARN, "SLOW_OP: {$label}", ['elapsed_s' => round($elapsed, 2)], self::$currentAction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an AI call at INFO level (or DEBUG for full payload).
|
||||
* @param string $model Model name (e.g. 'gemini-2.5-flash')
|
||||
* @param int $promptLen Character length of the prompt
|
||||
* @param bool $isFallback Whether this is the fallback model
|
||||
*/
|
||||
public static function aiCall(string $model, int $promptLen, bool $isFallback = false): void {
|
||||
$ctx = ['model' => $model, 'prompt_chars' => $promptLen];
|
||||
if ($isFallback) $ctx['fallback'] = true;
|
||||
$level = $isFallback ? self::WARN : self::INFO;
|
||||
self::write($level, 'AI call', $ctx, self::$currentAction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an AI response at INFO level.
|
||||
* @param string $model Model that responded
|
||||
* @param int $outputLen Character length of output
|
||||
* @param float $elapsed Call duration in seconds
|
||||
* @param bool $ok Whether the call succeeded
|
||||
* @param string $errorMsg Error message if not ok
|
||||
*/
|
||||
public static function aiResponse(string $model, int $outputLen, float $elapsed, bool $ok = true, string $errorMsg = ''): void {
|
||||
$ctx = ['model' => $model, 'output_chars' => $outputLen, 'elapsed_s' => round($elapsed, 2)];
|
||||
if (!$ok) {
|
||||
$ctx['error'] = substr($errorMsg, 0, 200);
|
||||
self::write(self::ERROR, 'AI error', $ctx, self::$currentAction);
|
||||
} else {
|
||||
self::write(self::INFO, 'AI ok', $ctx, self::$currentAction);
|
||||
}
|
||||
// Warn if over 10s
|
||||
if ($ok && $elapsed > 10.0) {
|
||||
self::write(self::WARN, 'AI response slow', ['elapsed_s' => round($elapsed, 2)], self::$currentAction);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a cache event at DEBUG level.
|
||||
* @param string $cacheKey The cache key (or a label)
|
||||
* @param bool $hit true = cache hit, false = cache miss
|
||||
* @param string $cacheType 'file', 'session', 'memory'
|
||||
*/
|
||||
public static function cache(string $cacheKey, bool $hit, string $cacheType = 'file'): void {
|
||||
if (self::$level > self::DEBUG) return;
|
||||
self::write(self::DEBUG,
|
||||
($hit ? 'CACHE HIT' : 'CACHE MISS') . " [{$cacheType}]",
|
||||
['key' => substr($cacheKey, 0, 64)],
|
||||
self::$currentAction
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the last $lines log lines from all available log files, newest last.
|
||||
* Used by the get_logs API endpoint.
|
||||
*/
|
||||
public static function tail(int $lines = 500): array {
|
||||
self::init();
|
||||
$files = glob(self::$logDir . '/evershelf_*.log') ?: [];
|
||||
if (empty($files)) return [];
|
||||
rsort($files); // newest file first
|
||||
|
||||
$collected = [];
|
||||
foreach ($files as $f) {
|
||||
if (count($collected) >= $lines) break;
|
||||
$content = @file_get_contents($f);
|
||||
if ($content === false) continue;
|
||||
$fLines = array_filter(explode("\n", $content));
|
||||
// Prepend so we read newest-first → older lines at front
|
||||
$collected = array_merge(array_values($fLines), $collected);
|
||||
}
|
||||
// Return last $lines, newest at end (chronological order)
|
||||
return array_values(array_slice($collected, -$lines));
|
||||
}
|
||||
|
||||
/** List available log files with their sizes and date ranges. */
|
||||
public static function listFiles(): array {
|
||||
self::init();
|
||||
$files = glob(self::$logDir . '/evershelf_*.log') ?: [];
|
||||
rsort($files);
|
||||
return array_map(fn($f) => [
|
||||
'file' => basename($f),
|
||||
'size_kb' => round(filesize($f) / 1024, 1),
|
||||
'mtime' => date('Y-m-d H:i:s', filemtime($f)),
|
||||
], $files);
|
||||
}
|
||||
|
||||
/** Current effective level name. */
|
||||
public static function levelName(): string {
|
||||
self::init();
|
||||
return ['DEBUG', 'INFO', 'WARN', 'ERROR'][self::$level];
|
||||
}
|
||||
|
||||
/** Current log file path. */
|
||||
public static function currentFile(): string {
|
||||
self::init();
|
||||
return self::$logFile;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// LoggingPDOStatement — wraps PDOStatement to time and log every execute()
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
class LoggingPDOStatement {
|
||||
private \PDOStatement $stmt;
|
||||
private string $sql;
|
||||
|
||||
public function __construct(\PDOStatement $stmt, string $sql) {
|
||||
$this->stmt = $stmt;
|
||||
$this->sql = $sql;
|
||||
}
|
||||
|
||||
public function execute(?array $params = null): bool {
|
||||
$t0 = microtime(true);
|
||||
$ok = $this->stmt->execute($params);
|
||||
$ms = round((microtime(true) - $t0) * 1000, 2);
|
||||
$ctx = ['ms' => $ms, 'rows' => $this->stmt->rowCount()];
|
||||
if ($ms > 500) $ctx['SLOW'] = true;
|
||||
EverLog::query($this->sql, $this->stmt->rowCount(), (microtime(true) - $t0));
|
||||
return $ok;
|
||||
}
|
||||
|
||||
public function fetch(int $mode = \PDO::FETCH_DEFAULT, ...$args): mixed {
|
||||
return $this->stmt->fetch($mode, ...$args);
|
||||
}
|
||||
|
||||
public function fetchAll(int $mode = \PDO::FETCH_DEFAULT, ...$args): array {
|
||||
return $this->stmt->fetchAll($mode ?: \PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function fetchColumn(int $col = 0): mixed {
|
||||
return $this->stmt->fetchColumn($col);
|
||||
}
|
||||
|
||||
public function rowCount(): int {
|
||||
return $this->stmt->rowCount();
|
||||
}
|
||||
|
||||
public function bindValue(int|string $param, mixed $value, int $type = \PDO::PARAM_STR): bool {
|
||||
return $this->stmt->bindValue($param, $value, $type);
|
||||
}
|
||||
|
||||
public function bindParam(int|string $param, mixed &$var, int $type = \PDO::PARAM_STR, int $maxLength = 0): bool {
|
||||
return $this->stmt->bindParam($param, $var, $type, $maxLength);
|
||||
}
|
||||
|
||||
public function closeCursor(): bool {
|
||||
return $this->stmt->closeCursor();
|
||||
}
|
||||
|
||||
public function setFetchMode(int $mode, mixed ...$args): bool {
|
||||
return $this->stmt->setFetchMode($mode, ...$args);
|
||||
}
|
||||
|
||||
public function __get(string $name): mixed {
|
||||
return $this->stmt->$name;
|
||||
}
|
||||
|
||||
public function __call(string $name, array $args): mixed {
|
||||
return $this->stmt->$name(...$args);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// LoggingPDO — wraps PDO to auto-log all prepare(), query(), exec()
|
||||
// Drop-in replacement: return LoggingPDO from getDB() instead of PDO.
|
||||
// Type hint: use PDO in all functions (LoggingPDO extends PDO).
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
class LoggingPDO extends \PDO {
|
||||
public function prepare(string $query, array $options = []): LoggingPDOStatement|false {
|
||||
$stmt = parent::prepare($query, $options);
|
||||
if ($stmt === false) {
|
||||
EverLog::error('PDO::prepare failed', ['sql' => substr($query, 0, 200)]);
|
||||
return false;
|
||||
}
|
||||
return new LoggingPDOStatement($stmt, $query);
|
||||
}
|
||||
|
||||
public function query(string $query, ?int $fetchMode = null, mixed ...$fetchModeArgs): \PDOStatement|false {
|
||||
$t0 = microtime(true);
|
||||
$stmt = $fetchMode !== null
|
||||
? parent::query($query, $fetchMode, ...$fetchModeArgs)
|
||||
: parent::query($query);
|
||||
$elapsed = microtime(true) - $t0;
|
||||
if ($stmt !== false) {
|
||||
EverLog::query($query, $stmt->rowCount(), $elapsed);
|
||||
} else {
|
||||
EverLog::error('PDO::query failed', ['sql' => substr($query, 0, 200)]);
|
||||
}
|
||||
return $stmt;
|
||||
}
|
||||
|
||||
public function exec(string $statement): int|false {
|
||||
// Skip WAL/PRAGMA logging below DEBUG (too noisy at startup)
|
||||
$isPragma = stripos(ltrim($statement), 'PRAGMA') === 0;
|
||||
$t0 = microtime(true);
|
||||
$result = parent::exec($statement);
|
||||
$elapsed = microtime(true) - $t0;
|
||||
if (!$isPragma) {
|
||||
EverLog::query($statement, $result === false ? 0 : $result, $elapsed);
|
||||
} elseif (EverLog::DEBUG >= 0) {
|
||||
// Log PRAGMAs only at DEBUG level
|
||||
EverLog::query($statement, is_int($result) ? $result : 0, $elapsed);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
+67
-34
@@ -72,12 +72,12 @@ body {
|
||||
#app-preloader {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--bg-dark, #0f172a);
|
||||
background: #0c1222;
|
||||
z-index: 200000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: opacity 0.35s ease;
|
||||
transition: opacity 0.45s ease;
|
||||
}
|
||||
#app-preloader.fade-out {
|
||||
opacity: 0;
|
||||
@@ -87,75 +87,91 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
gap: 28px;
|
||||
}
|
||||
.app-preloader-spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid rgba(255,255,255,0.15);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgba(255,255,255,0.1);
|
||||
border-top-color: #4ade80;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
.app-preloader-label {
|
||||
color: rgba(255,255,255,0.75);
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.app-preloader-logo {
|
||||
height: 160px;
|
||||
height: 150px;
|
||||
width: auto;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 4px 16px rgba(74,222,128,0.2));
|
||||
animation: logoPulse 3.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes logoPulse {
|
||||
0%, 100% { filter: drop-shadow(0 4px 20px rgba(74,222,128,0.18)); }
|
||||
50% { filter: drop-shadow(0 4px 36px rgba(74,222,128,0.48)); }
|
||||
}
|
||||
.app-preloader-version {
|
||||
color: rgba(255,255,255,0.35);
|
||||
font-size: 0.72rem;
|
||||
color: rgba(255,255,255,0.22);
|
||||
font-size: 0.68rem;
|
||||
font-family: monospace;
|
||||
letter-spacing: 0.5px;
|
||||
margin-top: -8px;
|
||||
letter-spacing: 0.6px;
|
||||
margin-top: -16px;
|
||||
}
|
||||
/* ── Startup progress bar ───────────────────────────────────────────── */
|
||||
/* ── Startup progress section ────────────────────────────────────────── */
|
||||
.preloader-progress-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 250px;
|
||||
max-width: 88vw;
|
||||
animation: zwFadeIn 0.2s ease;
|
||||
gap: 14px;
|
||||
width: min(92vw, 520px);
|
||||
animation: zwFadeIn 0.25s ease;
|
||||
}
|
||||
.preloader-bar-track {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: rgba(255,255,255,0.12);
|
||||
height: 3px;
|
||||
background: rgba(255,255,255,0.08);
|
||||
border-radius: 99px;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
}
|
||||
.preloader-bar {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: linear-gradient(90deg, #4ade80, #22c55e);
|
||||
border-radius: 99px;
|
||||
transition: width 0.18s ease, background 0.3s ease;
|
||||
transition: width 0.22s cubic-bezier(0.4,0,0.2,1), background 0.3s ease;
|
||||
box-shadow: 0 0 8px rgba(74,222,128,0.55);
|
||||
}
|
||||
.preloader-bar.bar-error { background: linear-gradient(90deg, #f87171, #ef4444); }
|
||||
.preloader-bar.bar-warn { background: linear-gradient(90deg, #fbbf24, #f59e0b); }
|
||||
.preloader-check-label {
|
||||
color: rgba(255,255,255,0.60);
|
||||
font-size: 0.74rem;
|
||||
font-family: monospace;
|
||||
text-align: center;
|
||||
.preloader-bar.bar-error { background: linear-gradient(90deg,#f87171,#ef4444); box-shadow: 0 0 8px rgba(239,68,68,0.5); }
|
||||
.preloader-bar.bar-warn { background: linear-gradient(90deg,#fbbf24,#f59e0b); box-shadow: 0 0 8px rgba(251,191,36,0.5); }
|
||||
.preloader-check-label { display: none; }
|
||||
|
||||
/* ── Status line: single element, opacity crossfade via JS ─────────── */
|
||||
.check-ticker {
|
||||
width: 100%;
|
||||
height: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.preloader-status-text {
|
||||
font-size: clamp(0.78rem, 2vw, 0.9rem);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.03em;
|
||||
color: rgba(255,255,255,0.45);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
min-height: 1.1em;
|
||||
letter-spacing: 0.01em;
|
||||
padding: 0 12px;
|
||||
}
|
||||
.preloader-status-text.state-ok { color: #86efac; }
|
||||
.preloader-status-text.state-warn { color: #fde68a; }
|
||||
.preloader-status-text.state-error { color: #fca5a5; }
|
||||
.preloader-warnings {
|
||||
max-width: 310px;
|
||||
max-width: min(92vw, 600px);
|
||||
width: 100%;
|
||||
animation: zwFadeIn 0.3s ease;
|
||||
}
|
||||
@@ -4175,6 +4191,15 @@ body.server-offline .bottom-nav {
|
||||
min-width: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.recipe-ing-name {
|
||||
cursor: pointer;
|
||||
border-bottom: 1px dashed rgba(74,222,128,0.5);
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.recipe-ing-name:hover {
|
||||
color: #4ade80;
|
||||
border-bottom-color: #4ade80;
|
||||
}
|
||||
|
||||
.btn-use-ingredient {
|
||||
flex-shrink: 0;
|
||||
@@ -7019,6 +7044,14 @@ body.cooking-mode-active .app-header {
|
||||
color: #9ca3af;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
.btn-banner-vacuum {
|
||||
background: #ede9fe;
|
||||
color: #6d28d9;
|
||||
}
|
||||
.btn-banner-edit2 {
|
||||
background: #e0f2fe;
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
/* ===== PAGE HEADER ACTION BUTTON (export etc.) ===== */
|
||||
.page-header-action-btn {
|
||||
|
||||
+285
-25
@@ -1052,7 +1052,8 @@ if (!_SUPPORTED_LANGS[_currentLang]) _currentLang = 'en';
|
||||
try {
|
||||
const s = JSON.parse(localStorage.getItem('evershelf_settings') || '{}');
|
||||
const mode = s.dark_mode || 'auto';
|
||||
const dark = mode === 'on' || (mode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
const h = new Date().getHours();
|
||||
const dark = mode === 'on' || (mode === 'auto' && (h >= 20 || h < 7));
|
||||
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
|
||||
} catch(e) {}
|
||||
})();
|
||||
@@ -1176,7 +1177,9 @@ function _applyTheme() {
|
||||
} else if (mode === 'off') {
|
||||
isDark = false;
|
||||
} else {
|
||||
isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
// auto: dark from 20:00 to 07:00 (time-based, not system preference)
|
||||
const h = new Date().getHours();
|
||||
isDark = h >= 20 || h < 7;
|
||||
}
|
||||
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
|
||||
}
|
||||
@@ -1189,10 +1192,10 @@ function _setThemeMode(mode) {
|
||||
}
|
||||
|
||||
// Listen to system theme changes (for 'auto' mode)
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||
const s = getSettings();
|
||||
if ((s.dark_mode || 'auto') === 'auto') _applyTheme();
|
||||
});
|
||||
// Re-evaluate auto theme every 5 minutes (catches 20:00 dark / 07:00 light transitions)
|
||||
setInterval(() => {
|
||||
if ((getSettings().dark_mode || 'auto') === 'auto') _applyTheme();
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
// ===== EXPORT INVENTORY =====
|
||||
function exportInventory(format) {
|
||||
@@ -1226,7 +1229,8 @@ function _showExportModal() {
|
||||
🖨️ ${t('export.btn_pdf')}
|
||||
</button>
|
||||
</div>`;
|
||||
openModal(html);
|
||||
document.getElementById('modal-content').innerHTML = html;
|
||||
document.getElementById('modal-overlay').style.display = 'flex';
|
||||
}
|
||||
|
||||
const LOCATIONS = {
|
||||
@@ -1601,7 +1605,7 @@ function estimateExpiryDays(product, location) {
|
||||
else if (/yogurt/.test(name)) days = 21;
|
||||
else if (/mozzarella|burrata|stracciatella/.test(name)) days = 5;
|
||||
else if (/formaggio\s+(fresco|ricotta|mascarpone|stracchino|crescenza)/.test(name)) days = 10;
|
||||
else if (/parmigiano|grana|pecorino|provolone/.test(name)) days = 60;
|
||||
else if (/parmigiano|grana|pecorino|provolone|asiago|fontina|emmental|gruyere|scamorza|groviera/.test(name)) days = 60;
|
||||
else if (/burro/.test(name)) days = 60;
|
||||
else if (/panna/.test(name)) days = 14;
|
||||
else if (/prosciutto\s+cotto|mortadella|wurstel/.test(name)) days = 7;
|
||||
@@ -1740,8 +1744,10 @@ function estimateOpenedExpiryDays(product, location) {
|
||||
if (/\b(yogurt|yaourt|yoghurt)\b/.test(name)) return 5;
|
||||
if (/mozzarella|burrata|stracciatella/.test(name)) return 3;
|
||||
if (/philadelphia|spalmabile/.test(name)) return 7;
|
||||
// Specific hard cheeses that contain 'fresco' in their commercial name (e.g. Asiago fresco)
|
||||
// must be matched BEFORE the generic 'formaggio fresco' catch-all
|
||||
if (/parmigiano|grana|pecorino|provolone|asiago|fontina|emmental|gruyere|scamorza|groviera/.test(name)) return 28;
|
||||
if (/formaggio.*(fresco|ricotta|mascarpone|stracchino|crescenza)/.test(name)) return 5;
|
||||
if (/parmigiano|grana|pecorino|provolone/.test(name)) return 21;
|
||||
if (/formaggio/.test(name)) return 10;
|
||||
if (/\bburro\b/.test(name)) return 30;
|
||||
if (/\bpanna\b/.test(name)) return 4;
|
||||
@@ -2200,6 +2206,175 @@ function _applySyncedSettings(serverSettings) {
|
||||
}
|
||||
}
|
||||
|
||||
let _infoTabTimer = null;
|
||||
|
||||
/**
|
||||
* Load the Info tab: Gemini token usage + cost, log size, DB size, log level.
|
||||
* Called on tab click; auto-refreshes every 30s while the tab is open.
|
||||
*/
|
||||
async function _loadInfoTab() {
|
||||
// Cancel any previous auto-refresh
|
||||
if (_infoTabTimer) { clearInterval(_infoTabTimer); _infoTabTimer = null; }
|
||||
await _renderInfoTab();
|
||||
// Auto-refresh every 30s while Info tab is visible
|
||||
_infoTabTimer = setInterval(_renderInfoTab, 30_000);
|
||||
}
|
||||
|
||||
async function _renderInfoTab() {
|
||||
const aiEl = document.getElementById('info-ai-content');
|
||||
const sysEl = document.getElementById('info-system-content');
|
||||
if (!aiEl && !sysEl) return;
|
||||
|
||||
try {
|
||||
const d = await api('gemini_usage');
|
||||
const s = getSettings();
|
||||
|
||||
// ── Locale & helpers ─────────────────────────────────────────────────
|
||||
const langMap = {it:'it-IT', en:'en-US', de:'de-DE', fr:'fr-FR', es:'es-ES'};
|
||||
const locale = langMap[s.language] || langMap[navigator.language?.slice(0,2)] || 'it-IT';
|
||||
const [yr, mo] = (d.month || '').split('-');
|
||||
const monthLabel = new Intl.DateTimeFormat(locale, {month:'long', year:'numeric'})
|
||||
.format(new Date(parseInt(yr), parseInt(mo)-1, 1));
|
||||
|
||||
// Cost → user currency
|
||||
const toCurr = (usd) => {
|
||||
if (!usd) return '—';
|
||||
const c = s.price_currency || 'EUR';
|
||||
let v = usd, sym = '$';
|
||||
if (c === 'EUR') { v = usd * 0.92; sym = '€'; }
|
||||
else if (c === 'GBP') { v = usd * 0.79; sym = '£'; }
|
||||
else if (c === 'CHF') { v = usd * 0.90; sym = 'CHF '; }
|
||||
else if (c === 'CAD') { v = usd * 1.36; sym = 'CA$'; }
|
||||
else if (c === 'AUD') { v = usd * 1.54; sym = 'A$'; }
|
||||
else if (c === 'BRL') { v = usd * 5.20; sym = 'R$'; }
|
||||
else if (c === 'JPY') { v = usd * 155; sym = '¥'; }
|
||||
else if (c === 'SEK') { v = usd * 10.4; sym = 'kr'; }
|
||||
else if (c === 'NOK') { v = usd * 10.6; sym = 'kr'; }
|
||||
else if (c === 'DKK') { v = usd * 6.85; sym = 'kr'; }
|
||||
else if (c === 'PLN') { v = usd * 3.98; sym = 'zł'; }
|
||||
const decimals = (c === 'JPY') ? 1 : 4;
|
||||
return sym + v.toFixed(decimals);
|
||||
};
|
||||
const fmtTok = n => n >= 1_000_000 ? (n/1_000_000).toFixed(2)+'M'
|
||||
: n >= 1_000 ? Math.round(n/1_000)+'K' : String(n||0);
|
||||
const fmtBytes = b => b > 1048576 ? (b/1048576).toFixed(1)+' MB'
|
||||
: b > 1024 ? Math.round(b/1024)+' KB' : (b||0)+' B';
|
||||
const fmtDate = ts => ts ? new Intl.DateTimeFormat(locale, {day:'2-digit', month:'short', hour:'2-digit', minute:'2-digit'}).format(new Date(ts*1000)) : '—';
|
||||
const pill = (val, label, color='') =>
|
||||
`<div style="background:var(--bg-secondary);border:1px solid var(--border-color,#e2e8f0);border-radius:10px;padding:8px 14px;min-width:70px;text-align:center${color ? ';border-color:'+color : ''}">
|
||||
<div style="font-size:1.1rem;font-weight:700;color:${color||'var(--text-primary,#1e293b)'}">${val}</div>
|
||||
<div style="font-size:0.7rem;color:var(--text-secondary,#64748b);margin-top:2px">${label}</div>
|
||||
</div>`;
|
||||
const sectionHeader = (label) =>
|
||||
`<div style="font-size:0.78rem;font-weight:600;color:var(--text-secondary);margin-bottom:8px;text-transform:uppercase;letter-spacing:.04em">${label}</div>`;
|
||||
|
||||
// ── AI Usage card ────────────────────────────────────────────────────
|
||||
if (aiEl) {
|
||||
const ms = d.month_stats || {};
|
||||
const ys = d.year_stats || {};
|
||||
|
||||
const hintEl = aiEl.closest('.settings-card')?.querySelector('.info-ai-subtitle');
|
||||
if (hintEl) hintEl.textContent = t('settings.info.ai_overview');
|
||||
|
||||
const msIn = ms.input_tokens || 0;
|
||||
const msOut = ms.output_tokens || 0;
|
||||
const ysIn = ys.input_tokens || 0;
|
||||
const ysOut = ys.output_tokens || 0;
|
||||
|
||||
// Month section
|
||||
const actionRows = Object.entries(ms.by_action || {})
|
||||
.sort((a,b) => b[1]-a[1]).slice(0, 8)
|
||||
.map(([k,v]) => `<tr><td style="padding:3px 12px 3px 0;color:var(--text-secondary);font-size:0.82rem">${k}</td><td style="font-variant-numeric:tabular-nums;font-size:0.82rem"><strong>${v}</strong> ${t('settings.info.calls_unit')}</td></tr>`).join('');
|
||||
const modelRows = Object.entries(ms.by_model || {})
|
||||
.map(([m,mv]) => `<tr><td style="padding:3px 12px 3px 0;color:var(--text-secondary);font-size:0.82rem">${m}</td><td style="font-variant-numeric:tabular-nums;font-size:0.82rem"><strong>${fmtTok((mv.in||0)+(mv.out||0))}</strong></td></tr>`).join('');
|
||||
|
||||
const monthHtml = `
|
||||
<div style="background:var(--bg-secondary);border-radius:10px;padding:12px;margin-bottom:10px">
|
||||
${sectionHeader(monthLabel)}
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
${pill(ms.calls || 0, t('settings.info.ai_calls'))}
|
||||
${pill('~'+fmtTok(msIn+msOut), t('settings.info.total_tokens'))}
|
||||
${pill('~'+toCurr(ms.cost_usd), t('settings.info.est_cost'), '#15803d')}
|
||||
</div>
|
||||
${actionRows ? `<details style="margin-top:8px"><summary style="font-size:0.82rem;cursor:pointer;color:var(--text-secondary)">${t('settings.info.by_action')}</summary><table style="margin-top:6px;border-collapse:collapse">${actionRows}</table></details>` : ''}
|
||||
${modelRows ? `<details style="margin-top:4px"><summary style="font-size:0.82rem;cursor:pointer;color:var(--text-secondary)">${t('settings.info.by_model')}</summary><table style="margin-top:6px;border-collapse:collapse">${modelRows}</table></details>` : ''}
|
||||
</div>`;
|
||||
|
||||
// Year section
|
||||
const yearHtml = `
|
||||
<div style="background:var(--bg-secondary);border-radius:10px;padding:12px;margin-bottom:10px">
|
||||
${sectionHeader(t('settings.info.year_label').replace('{year}', d.year))}
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
${pill('~'+(ys.calls || 0), t('settings.info.ai_calls'))}
|
||||
${pill('~'+fmtTok(ysIn+ysOut), t('settings.info.total_tokens'))}
|
||||
${pill('~'+toCurr(ys.cost_usd), t('settings.info.est_cost'), '#15803d')}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
aiEl.innerHTML = monthHtml + yearHtml
|
||||
+ `<p class="settings-hint" style="margin-top:4px">${t('settings.info.pricing_note')}</p>`;
|
||||
}
|
||||
|
||||
// ── Inventory card ───────────────────────────────────────────────────
|
||||
const invEl = document.getElementById('info-inv-content');
|
||||
if (invEl && d.db) {
|
||||
const db = d.db;
|
||||
invEl.innerHTML = `
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
${pill(db.inventory_active, t('settings.info.inv_active'))}
|
||||
${pill(db.products_total, t('settings.info.inv_products'))}
|
||||
${pill(db.expiring_soon, t('settings.info.inv_expiring'), db.expiring_soon > 0 ? '#d97706' : '')}
|
||||
${pill(db.expired, t('settings.info.inv_expired'), db.expired > 0 ? '#dc2626' : '')}
|
||||
${pill(db.finished, t('settings.info.inv_finished'))}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Activity card ────────────────────────────────────────────────────
|
||||
const actEl = document.getElementById('info-act-content');
|
||||
if (actEl && d.db) {
|
||||
const db = d.db;
|
||||
actEl.innerHTML = `
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
${pill(db.tx_month, t('settings.info.act_tx_month'))}
|
||||
${pill(db.restock_month, t('settings.info.act_restock'))}
|
||||
${pill(db.use_month, t('settings.info.act_use'))}
|
||||
${pill(db.products_month, t('settings.info.act_new_products'))}
|
||||
${pill(db.tx_year, t('settings.info.act_tx_year'))}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── System card ──────────────────────────────────────────────────────
|
||||
if (sysEl) {
|
||||
const db = d.db || {};
|
||||
const lvlColors = {DEBUG:'#1e40af//#dbeafe', INFO:'#15803d//#dcfce7', WARN:'#854d0e//#fef9c3', ERROR:'#991b1b//#fee2e2'};
|
||||
const [lvlFg, lvlBg] = (lvlColors[d.log_level] || '#64748b//#f1f5f9').split('//');
|
||||
|
||||
sysEl.innerHTML = `
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px">
|
||||
${pill(fmtBytes(db.bytes), t('settings.info.db_size'))}
|
||||
${pill(fmtBytes(d.log_bytes), t('settings.info.log_size'))}
|
||||
${pill(`<span style="background:${lvlBg};color:${lvlFg};padding:2px 6px;border-radius:5px;font-size:0.78rem">${d.log_level||'INFO'}</span>`, t('settings.info.log_level'))}
|
||||
</div>
|
||||
<table style="border-collapse:collapse;width:100%;font-size:0.85rem">
|
||||
<tr style="border-top:1px solid var(--border-color,#e2e8f0)">
|
||||
<td style="padding:7px 0;color:var(--text-secondary)">${t('settings.info.price_cache')}</td>
|
||||
<td style="padding:7px 0;font-weight:600;text-align:right">${(d.caches?.price||0)} ${t('settings.info.cache_entries')}</td>
|
||||
</tr>
|
||||
<tr style="border-top:1px solid var(--border-color,#e2e8f0)">
|
||||
<td style="padding:7px 0;color:var(--text-secondary)">${t('settings.info.last_backup')}</td>
|
||||
<td style="padding:7px 0;font-weight:600;text-align:right">${d.last_backup_ts ? fmtDate(d.last_backup_ts)+' · '+fmtBytes(d.last_backup_bytes) : '—'}</td>
|
||||
</tr>
|
||||
</table>`;
|
||||
}
|
||||
} catch(e) {
|
||||
['info-ai-content','info-inv-content','info-act-content','info-system-content'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.innerHTML = `<p class="settings-hint">${t('error.generic')}</p>`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Populate the About section with the current app version from the server.
|
||||
*/
|
||||
@@ -2941,6 +3116,8 @@ async function saveSettings() {
|
||||
scale_gateway_url: s.scale_gateway_url,
|
||||
meal_plan_enabled: s.meal_plan_enabled,
|
||||
screensaver_enabled: s.screensaver_enabled,
|
||||
screensaver_timeout: s.screensaver_timeout || 5,
|
||||
zerowaste_tips_enabled: s.zerowaste_tips_enabled,
|
||||
tts_enabled: s.tts_enabled,
|
||||
tts_url: s.tts_url,
|
||||
tts_token: s.tts_token,
|
||||
@@ -2958,6 +3135,9 @@ async function saveSettings() {
|
||||
price_country: s.price_country,
|
||||
price_currency: s.price_currency,
|
||||
price_update_months: s.price_update_months,
|
||||
recipe_retention_days: s.recipe_retention_days || 7,
|
||||
transaction_retention_days: s.transaction_retention_days || 7,
|
||||
vacuum_expiry_extension_days: s.vacuum_expiry_extension_days || 30,
|
||||
}, tokenHeader);
|
||||
const statusEl = document.getElementById('settings-status');
|
||||
if (result.success) {
|
||||
@@ -2999,6 +3179,11 @@ async function saveSettings() {
|
||||
}
|
||||
|
||||
function switchSettingsTab(btn, tabId) {
|
||||
// Stop info-tab auto-refresh when leaving that tab
|
||||
if (tabId !== 'tab-info' && _infoTabTimer) {
|
||||
clearInterval(_infoTabTimer);
|
||||
_infoTabTimer = null;
|
||||
}
|
||||
document.querySelectorAll('.settings-tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.settings-panel').forEach(p => p.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
@@ -4352,15 +4537,22 @@ function renderBannerItem() {
|
||||
detailEl.innerHTML = `${baseDetail} <span class="banner-safety-tip banner-safety-${safety.level}">${safety.icon} ${safety.tip}</span>`;
|
||||
let btns = '';
|
||||
btns += `<button class="btn-banner btn-banner-finish" onclick="bannerFinishAll()">${t('dashboard.banner_expired_action_finished')}</button>`;
|
||||
if (safety.level !== 'danger') {
|
||||
if (!isOpenedExpiry && safety.level !== 'danger') {
|
||||
btns += `<button class="btn-banner btn-banner-use" onclick="bannerQuickUse()">${t('dashboard.banner_expired_action_use')}</button>`;
|
||||
}
|
||||
btns += `<button class="btn-banner btn-banner-throw" onclick="bannerThrowAway()">${t('dashboard.banner_expired_action_throw')}</button>`;
|
||||
btns += `<button class="btn-banner btn-banner-edit" onclick="editBannerExpiry()">${t('dashboard.banner_expired_action_edit')}</button>`;
|
||||
if (safety.level === 'danger') {
|
||||
// "Modifica" — opens full edit modal (includes date correction)
|
||||
btns += `<button class="btn-banner btn-banner-edit2" onclick="editInventoryItem(${item.id})">${t('dashboard.banner_expired_action_modify')}</button>`;
|
||||
if (isOpenedExpiry && !item.vacuum_sealed) {
|
||||
// Offer to re-seal with vacuum — extends shelf life
|
||||
btns += `<button class="btn-banner btn-banner-vacuum" onclick="bannerMarkVacuum()">${t('dashboard.banner_expired_action_vacuum')}</button>`;
|
||||
}
|
||||
if (!isOpenedExpiry && safety.level === 'danger') {
|
||||
btns += `<button class="btn-banner btn-banner-use btn-banner-use-danger" onclick="bannerQuickUse()">${t('dashboard.banner_expired_action_use')}</button>`;
|
||||
}
|
||||
btns += `<button class="btn-banner btn-banner-ok" onclick="dismissBannerExpired()">${t('dashboard.banner_review_dismiss')}</button>`;
|
||||
if (!isOpenedExpiry) {
|
||||
btns += `<button class="btn-banner btn-banner-ok" onclick="dismissBannerExpired()">${t('dashboard.banner_review_dismiss')}</button>`;
|
||||
}
|
||||
actionsEl.innerHTML = btns;
|
||||
|
||||
} else if (entry.type === 'review') {
|
||||
@@ -4650,6 +4842,43 @@ function bannerThrowAway() {
|
||||
dismissBannerItem();
|
||||
}
|
||||
|
||||
async function bannerMarkVacuum() {
|
||||
const entry = _bannerQueue[_bannerIndex];
|
||||
if (!entry || entry.type !== 'expired') return;
|
||||
const item = entry.data;
|
||||
if (item.vacuum_sealed) return; // already sealed
|
||||
|
||||
// Calculate new expiry: opened_at + opened_shelf_life_days_with_vacuum
|
||||
let newExpiry = null;
|
||||
if (item.opened_at) {
|
||||
// estimateOpenedExpiryDays returns days without vacuum; add 50% for vacuum sealed
|
||||
const baseDays = estimateOpenedExpiryDays(
|
||||
{ name: item.name, category: item.category || '' },
|
||||
item.location
|
||||
);
|
||||
const vacuumDays = Math.round(baseDays * 1.5);
|
||||
const d = new Date(item.opened_at);
|
||||
d.setDate(d.getDate() + vacuumDays);
|
||||
newExpiry = d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
const body = { id: item.id, vacuum_sealed: 1 };
|
||||
if (newExpiry) body.expiry_date = newExpiry;
|
||||
|
||||
try {
|
||||
const res = await api('inventory_update', {}, 'POST', body);
|
||||
if (res.success || res.ok) {
|
||||
showToast(t('toast.vacuum_sealed', { name: item.name }), 'success');
|
||||
dismissBannerItem();
|
||||
loadDashboard();
|
||||
} else {
|
||||
showToast(res.error || t('error.generic'), 'error');
|
||||
}
|
||||
} catch(e) {
|
||||
showToast(t('error.connection'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function bannerFinishAll() {
|
||||
const entry = _bannerQueue[_bannerIndex];
|
||||
if (!entry) return;
|
||||
@@ -8547,7 +8776,7 @@ function showMoveAfterUseModal(product, fromLoc, remaining, openedId, openedVacu
|
||||
const vacuumRow = `
|
||||
<label style="display:flex;align-items:center;gap:8px;margin-top:12px;cursor:pointer">
|
||||
<input type="checkbox" id="move-vacuum-check" ${wasVacuum ? 'checked' : ''}>
|
||||
<span>${t('move.vacuum_seal_rest')}${wasVacuum ? ' ' + t('move.was_sealed') : ''}</span>
|
||||
<span>${wasVacuum ? t('move.vacuum_restore') : t('move.vacuum_seal_rest')}</span>
|
||||
</label>`;
|
||||
document.getElementById('modal-content').innerHTML = `
|
||||
<div class="modal-header">
|
||||
@@ -12206,7 +12435,7 @@ function showRecipeMoveModal(productId, fromLoc, remaining, openedId, wasVacuum)
|
||||
<p style="margin-bottom:12px">${t('move.question_short').replace('{thing}', openedId ? t('move.thing_opened') : t('move.thing_rest'))}</p>
|
||||
<div class="location-selector">${locButtons}</div>
|
||||
${vacuumRow}
|
||||
<button type="button" id="btn-move-stay" class="btn btn-secondary full-width move-countdown-btn" style="margin-top:12px" onclick="clearMoveModalTimer();closeModal()">No, resta in ${LOCATIONS[fromLoc]?.label || fromLoc}</button>
|
||||
<button type="button" id="btn-move-stay" class="btn btn-secondary full-width move-countdown-btn" style="margin-top:12px" onclick="clearMoveModalTimer();closeModal()">${t('move.stay_btn').replace('{location}', LOCATIONS[fromLoc]?.label || fromLoc)}</button>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('modal-overlay').style.display = 'flex';
|
||||
@@ -12314,7 +12543,7 @@ function renderRecipe(r) {
|
||||
const loc = (ing.location || 'dispensa').replace(/'/g, "\\'");
|
||||
const alreadyUsed = ing.used === true;
|
||||
html += `<li class="recipe-ingredient${alreadyUsed ? ' recipe-ing-used' : ''}" id="recipe-ing-${idx}">`;
|
||||
html += `<span class="recipe-ing-text"><strong>${ing.name}</strong>${ing.brand ? ' <em>(' + ing.brand + ')</em>' : ''}: ${ing.qty} ✅`;
|
||||
html += `<span class="recipe-ing-text"><strong class="recipe-ing-name" onclick="openIngredientDetail(${ing.product_id}, '${loc}')" title="${t('action.edit') || 'Modifica'}">${ing.name}</strong>${ing.brand ? ' <em>(' + ing.brand + ')</em>' : ''}: ${ing.qty} ✅`;
|
||||
// Detail line: location + expiry
|
||||
let details = [];
|
||||
const ingredientLocLabels = Object.fromEntries(Object.entries(LOCATIONS).map(([k,v]) => [k, `${v.icon} ${v.label}`]));
|
||||
@@ -13692,6 +13921,21 @@ async function chatTransferToRecipes(btn, replyText) {
|
||||
}
|
||||
}
|
||||
|
||||
async function openIngredientDetail(productId, location) {
|
||||
try {
|
||||
const res = await api('inventory_list');
|
||||
const items = res.inventory || res;
|
||||
// Find by product_id + location; fallback to any row with that product_id
|
||||
let item = items.find(i => i.product_id === productId && i.location === location);
|
||||
if (!item) item = items.find(i => i.product_id === productId);
|
||||
if (!item) { showToast(t('error.not_found'), 'error'); return; }
|
||||
currentInventory = items;
|
||||
editInventoryItem(item.id);
|
||||
} catch(e) {
|
||||
showToast(t('error.connection'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function generateRecipeForIngredient(ingredientName) {
|
||||
if (!_requireGemini()) return;
|
||||
document.getElementById('recipe-overlay').style.display = 'flex';
|
||||
@@ -14897,7 +15141,7 @@ async function _runStartupCheck() {
|
||||
if (spinnerEl) spinnerEl.style.display = 'none';
|
||||
wrapEl.style.display = '';
|
||||
|
||||
// Helper: set progress bar + label
|
||||
// Helper: set progress bar + crossfade status text
|
||||
let _curPct = 0;
|
||||
const setProgress = (pct, label, state) => {
|
||||
_curPct = pct;
|
||||
@@ -14905,14 +15149,31 @@ async function _runStartupCheck() {
|
||||
barEl.style.width = pct + '%';
|
||||
barEl.className = 'preloader-bar' + (state === 'error' ? ' bar-error' : state === 'warn' ? ' bar-warn' : '');
|
||||
}
|
||||
if (labelEl) labelEl.textContent = label || '';
|
||||
if (!label) return;
|
||||
const ticker = document.getElementById('check-ticker');
|
||||
if (!ticker) return;
|
||||
const sc = state === 'error' ? 'state-error' : state === 'warn' ? 'state-warn' : 'state-ok';
|
||||
// Strip emoji from label — colors convey the state
|
||||
const cleanLabel = label.replace(/[\u{1F000}-\u{1FFFF}\u{2600}-\u{27BF}\u{2700}-\u{27BF}✅❌⚠️🔄]/gu, '').trim().replace(/^[-–—\s]+/, '');
|
||||
let el = ticker.querySelector('.preloader-status-text');
|
||||
if (!el) {
|
||||
el = document.createElement('div');
|
||||
el.className = 'preloader-status-text';
|
||||
ticker.appendChild(el);
|
||||
}
|
||||
// Direct update — checks fire every 40ms, any fade would hide most labels
|
||||
el.className = `preloader-status-text ${sc}`;
|
||||
el.textContent = cleanLabel;
|
||||
};
|
||||
|
||||
// Phase 1: animate 0→15% while fetching (so it never looks stuck)
|
||||
setProgress(0, tl('connecting', 'Connessione al server...'));
|
||||
let _fetchDone = false;
|
||||
const slowAnim = setInterval(() => {
|
||||
if (!_fetchDone && _curPct < 13) setProgress(_curPct + 1, labelEl?.textContent);
|
||||
if (!_fetchDone && _curPct < 13) {
|
||||
_curPct++;
|
||||
if (barEl) barEl.style.width = _curPct + '%';
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Make the request
|
||||
@@ -14930,7 +15191,7 @@ async function _runStartupCheck() {
|
||||
tl('error_network_detail', 'Il browser non riesce a raggiungere il server PHP.\n\nPossibili cause:\n• Il server Apache/PHP non è in esecuzione\n• Problema di rete o firewall\n• URL dell\'app non corretta\n\nControlla che il server sia avviato e riprova.'),
|
||||
errorEl, retryBtn
|
||||
);
|
||||
setProgress(100, `❌ ${tl('error_network', 'Server non raggiungibile')}`, 'error');
|
||||
setProgress(100, tl('error_network', 'Server non raggiungibile'), 'error');
|
||||
return false;
|
||||
}
|
||||
clearInterval(slowAnim);
|
||||
@@ -15001,8 +15262,7 @@ async function _runStartupCheck() {
|
||||
if (!isOk && c.error) lbl += ` — ${c.error}`;
|
||||
if (!isOk && c.missing?.length) lbl += ` — mancanti: ${c.missing.join(', ')}`;
|
||||
|
||||
const icon = isOk ? '✅' : isOpt ? '⚠️' : '❌';
|
||||
setProgress(pct, `${icon} ${lbl}`);
|
||||
setProgress(pct, lbl, isOk ? 'ok' : isOpt ? 'warn' : 'error');
|
||||
|
||||
if (!isOk && !isFresh) {
|
||||
(isOpt ? warnings : errors).push({ def, c });
|
||||
@@ -15013,7 +15273,7 @@ async function _runStartupCheck() {
|
||||
|
||||
// ── Errors → red bar + blocking popup ────────────────────────────────────
|
||||
if (errors.length > 0) {
|
||||
setProgress(100, `❌ ${tl('critical_error_short', 'Errore critico')}`, 'error');
|
||||
setProgress(100, tl('critical_error_short', 'Errore critico'), 'error');
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
const errLines = errors.map(e => {
|
||||
const hint = e.c.hint || (e.c.error ? e.c.error : null);
|
||||
@@ -15029,7 +15289,7 @@ async function _runStartupCheck() {
|
||||
|
||||
// ── Warnings → amber bar + warning popup auto-close 5s ───────────────────
|
||||
if (warnings.length > 0) {
|
||||
setProgress(100, `⚠️ ${warnings.length} ${tl('warnings_found', 'avvisi')}`, 'warn');
|
||||
setProgress(100, `${warnings.length} ${tl('warnings_found', 'avvisi')}`, 'warn');
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
|
||||
// Build warning popup (auto-close 5s)
|
||||
@@ -15041,7 +15301,7 @@ async function _runStartupCheck() {
|
||||
// Hide warning popup
|
||||
warningsEl.style.display = 'none';
|
||||
} else {
|
||||
setProgress(100, `✅ ${tl('all_ok', 'Sistema OK')}`);
|
||||
setProgress(100, tl('all_ok', 'Sistema OK'), 'ok');
|
||||
await new Promise(r => setTimeout(r, 600));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"2026-05": {
|
||||
"input_tokens": 4438300,
|
||||
"output_tokens": 1286760,
|
||||
"calls": 8374,
|
||||
"by_action": {},
|
||||
"by_model": {}
|
||||
}
|
||||
}
|
||||
@@ -58,8 +58,10 @@ import javax.net.ssl.X509TrustManager
|
||||
* 2 — Permissions rationale + grant
|
||||
* 3 — Server URL + auto-discovery + connection test
|
||||
* 4 — Smart scale question → gateway info + install
|
||||
* 5 — Screensaver toggle (NEW)
|
||||
* 6 — Done
|
||||
* 5 — Features (screensaver / prices / meal-plan / zero-waste)
|
||||
* 6 — Gemini AI key (optional, auto-skipped if already set)
|
||||
* 7 — Bring! credentials (optional, auto-skipped if already set)
|
||||
* 8 — Done
|
||||
*/
|
||||
class SetupActivity : AppCompatActivity() {
|
||||
|
||||
@@ -73,6 +75,8 @@ class SetupActivity : AppCompatActivity() {
|
||||
private lateinit var stepServer: LinearLayout
|
||||
private lateinit var stepScale: LinearLayout
|
||||
private lateinit var stepScreensaver: LinearLayout
|
||||
private lateinit var stepGemini: LinearLayout
|
||||
private lateinit var stepBring: LinearLayout
|
||||
private lateinit var stepDone: LinearLayout
|
||||
|
||||
// Progress dots
|
||||
@@ -114,6 +118,11 @@ class SetupActivity : AppCompatActivity() {
|
||||
private lateinit var setupSwitchMealPlan: SwitchMaterial
|
||||
private lateinit var setupSwitchZeroWaste: SwitchMaterial
|
||||
|
||||
// Gemini + Bring steps
|
||||
private lateinit var setupGeminiKeyEdit: EditText
|
||||
private lateinit var setupBringEmailEdit: EditText
|
||||
private lateinit var setupBringPasswordEdit: EditText
|
||||
|
||||
// Done step
|
||||
private lateinit var summaryText: TextView
|
||||
|
||||
@@ -134,6 +143,9 @@ class SetupActivity : AppCompatActivity() {
|
||||
private const val KEY_PRICE_ENABLED = "price_enabled"
|
||||
private const val KEY_MEAL_PLAN = "meal_plan_enabled"
|
||||
private const val KEY_ZEROWASTE_TIPS = "zerowaste_tips_enabled"
|
||||
private const val KEY_GEMINI_KEY = "gemini_api_key"
|
||||
private const val KEY_BRING_EMAIL = "bring_email"
|
||||
private const val KEY_BRING_PASSWORD = "bring_password"
|
||||
private const val PERMISSION_REQUEST_CODE = 2004
|
||||
private const val BLE_PERMISSION_REQUEST = 2006
|
||||
|
||||
@@ -184,8 +196,11 @@ class SetupActivity : AppCompatActivity() {
|
||||
|
||||
override fun onBackPressed() {
|
||||
when (currentStep) {
|
||||
0 -> confirmExit()
|
||||
1 -> showStep(0) // back to language
|
||||
0 -> confirmExit()
|
||||
1 -> showStep(0) // back to language
|
||||
8 -> showStep(7) // done → bring
|
||||
7 -> showStep(6) // bring → gemini
|
||||
6 -> showStep(5) // gemini → features
|
||||
else -> showStep(currentStep - 1)
|
||||
}
|
||||
}
|
||||
@@ -221,8 +236,18 @@ class SetupActivity : AppCompatActivity() {
|
||||
stepServer = findViewById(R.id.stepServer)
|
||||
stepScale = findViewById(R.id.stepScale)
|
||||
stepScreensaver = findViewById(R.id.stepScreensaver)
|
||||
stepGemini = findViewById(R.id.stepGemini)
|
||||
stepBring = findViewById(R.id.stepBring)
|
||||
stepDone = findViewById(R.id.stepDone)
|
||||
|
||||
// Gemini + Bring fields
|
||||
setupGeminiKeyEdit = findViewById(R.id.setupGeminiKeyEdit)
|
||||
setupBringEmailEdit = findViewById(R.id.setupBringEmailEdit)
|
||||
setupBringPasswordEdit = findViewById(R.id.setupBringPasswordEdit)
|
||||
// Pre-fill from saved prefs
|
||||
(prefs.getString(KEY_GEMINI_KEY, "") ?: "").takeIf { it.isNotEmpty() }?.let { setupGeminiKeyEdit.setText(it) }
|
||||
(prefs.getString(KEY_BRING_EMAIL, "") ?: "").takeIf { it.isNotEmpty() }?.let { setupBringEmailEdit.setText(it) }
|
||||
|
||||
// Server step
|
||||
urlEdit = findViewById(R.id.setupUrlEdit)
|
||||
urlStatus = findViewById(R.id.setupUrlStatus)
|
||||
@@ -273,6 +298,8 @@ class SetupActivity : AppCompatActivity() {
|
||||
findViewById<MaterialButton>(R.id.btnLangIt).setOnClickListener { selectLanguage("it") }
|
||||
findViewById<MaterialButton>(R.id.btnLangEn).setOnClickListener { selectLanguage("en") }
|
||||
findViewById<MaterialButton>(R.id.btnLangDe).setOnClickListener { selectLanguage("de") }
|
||||
findViewById<MaterialButton>(R.id.btnLangEs).setOnClickListener { selectLanguage("es") }
|
||||
findViewById<MaterialButton>(R.id.btnLangFr).setOnClickListener { selectLanguage("fr") }
|
||||
|
||||
// ── Welcome ──────────────────────────────────────────────────────
|
||||
findViewById<MaterialButton>(R.id.btnSetupExit).setOnClickListener { confirmExit() }
|
||||
@@ -375,10 +402,10 @@ class SetupActivity : AppCompatActivity() {
|
||||
bleSetupCard.visibility = View.VISIBLE
|
||||
tvSelectedScale.text = ""
|
||||
tvSelectedScale.visibility = View.GONE
|
||||
tvScanStatus.text = "Bilancia non confermata. Riprova la scansione."
|
||||
tvScanStatus.text = getString(R.string.ble_not_confirmed)
|
||||
tvScanStatus.setTextColor(0xFFfbbf24.toInt())
|
||||
btnScanBle.isEnabled = true
|
||||
btnScanBle.text = "🔍 Cerca bilancia"
|
||||
btnScanBle.text = getString(R.string.ble_scan_again)
|
||||
findViewById<MaterialButton>(R.id.btnScaleNext).isEnabled = false
|
||||
}
|
||||
findViewById<MaterialButton>(R.id.btnTestSkip).setOnClickListener {
|
||||
@@ -398,14 +425,34 @@ class SetupActivity : AppCompatActivity() {
|
||||
findViewById<MaterialButton>(R.id.btnScreensaverBack).setOnClickListener { showStep(4) }
|
||||
findViewById<MaterialButton>(R.id.btnScreensaverNext).setOnClickListener {
|
||||
prefs.edit()
|
||||
.putBoolean(KEY_SCREENSAVER, setupSwitchScreensaver.isChecked)
|
||||
.putBoolean(KEY_PRICE_ENABLED, setupSwitchPrices.isChecked)
|
||||
.putBoolean(KEY_MEAL_PLAN, setupSwitchMealPlan.isChecked)
|
||||
.putBoolean(KEY_SCREENSAVER, setupSwitchScreensaver.isChecked)
|
||||
.putBoolean(KEY_PRICE_ENABLED, setupSwitchPrices.isChecked)
|
||||
.putBoolean(KEY_MEAL_PLAN, setupSwitchMealPlan.isChecked)
|
||||
.putBoolean(KEY_ZEROWASTE_TIPS, setupSwitchZeroWaste.isChecked)
|
||||
.apply()
|
||||
showStep(6)
|
||||
}
|
||||
|
||||
// ── Gemini step ───────────────────────────────────────────────────
|
||||
findViewById<MaterialButton>(R.id.btnGeminiBack).setOnClickListener { showStep(5) }
|
||||
findViewById<MaterialButton>(R.id.btnGeminiSkip).setOnClickListener { showStep(7) }
|
||||
findViewById<MaterialButton>(R.id.btnGeminiNext).setOnClickListener {
|
||||
val key = setupGeminiKeyEdit.text.toString().trim()
|
||||
if (key.isNotEmpty()) prefs.edit().putString(KEY_GEMINI_KEY, key).apply()
|
||||
showStep(7)
|
||||
}
|
||||
|
||||
// ── Bring step ────────────────────────────────────────────────────
|
||||
findViewById<MaterialButton>(R.id.btnBringBack).setOnClickListener { showStep(6) }
|
||||
findViewById<MaterialButton>(R.id.btnBringSkip).setOnClickListener { showStep(8) }
|
||||
findViewById<MaterialButton>(R.id.btnBringNext).setOnClickListener {
|
||||
val email = setupBringEmailEdit.text.toString().trim()
|
||||
val pass = setupBringPasswordEdit.text.toString().trim()
|
||||
if (email.isNotEmpty()) prefs.edit().putString(KEY_BRING_EMAIL, email).apply()
|
||||
if (pass.isNotEmpty()) prefs.edit().putString(KEY_BRING_PASSWORD, pass).apply()
|
||||
showStep(8)
|
||||
}
|
||||
|
||||
// ── Done ──────────────────────────────────────────────────────────
|
||||
findViewById<MaterialButton>(R.id.btnLaunch).setOnClickListener { finishSetup() }
|
||||
}
|
||||
@@ -421,20 +468,27 @@ class SetupActivity : AppCompatActivity() {
|
||||
|
||||
private fun highlightSelectedLang() {
|
||||
val saved = prefs.getString(KEY_LANGUAGE, null) ?: return
|
||||
val (btnIt, btnEn, btnDe) = Triple(
|
||||
findViewById<MaterialButton>(R.id.btnLangIt),
|
||||
findViewById<MaterialButton>(R.id.btnLangEn),
|
||||
findViewById<MaterialButton>(R.id.btnLangDe)
|
||||
)
|
||||
val btnIt = findViewById<MaterialButton>(R.id.btnLangIt)
|
||||
val btnEn = findViewById<MaterialButton>(R.id.btnLangEn)
|
||||
val btnDe = findViewById<MaterialButton>(R.id.btnLangDe)
|
||||
val btnEs = findViewById<MaterialButton>(R.id.btnLangEs)
|
||||
val btnFr = findViewById<MaterialButton>(R.id.btnLangFr)
|
||||
// Add checkmark to selected
|
||||
btnIt.text = if (saved == "it") "✅ 🇮🇹 Italiano" else "🇮🇹 Italiano"
|
||||
btnEn.text = if (saved == "en") "✅ 🇬🇧 English" else "🇬🇧 English"
|
||||
btnDe.text = if (saved == "de") "✅ 🇩🇪 Deutsch" else "🇩🇪 Deutsch"
|
||||
btnEs.text = if (saved == "es") "✅ 🇪🇸 Español" else "🇪🇸 Español"
|
||||
btnFr.text = if (saved == "fr") "✅ 🇫🇷 Français" else "🇫🇷 Français"
|
||||
}
|
||||
|
||||
// ── Step navigation ───────────────────────────────────────────────────
|
||||
|
||||
private fun showStep(step: Int) {
|
||||
// Auto-skip Gemini step if already configured
|
||||
if (step == 6 && !(prefs.getString(KEY_GEMINI_KEY, "") ?: "").isNullOrEmpty()) { showStep(7); return }
|
||||
// Auto-skip Bring step if already configured
|
||||
if (step == 7 && !(prefs.getString(KEY_BRING_EMAIL, "") ?: "").isNullOrEmpty()) { showStep(8); return }
|
||||
|
||||
currentStep = step
|
||||
stepLanguage.visibility = if (step == 0) View.VISIBLE else View.GONE
|
||||
stepWelcome.visibility = if (step == 1) View.VISIBLE else View.GONE
|
||||
@@ -442,7 +496,9 @@ class SetupActivity : AppCompatActivity() {
|
||||
stepServer.visibility = if (step == 3) View.VISIBLE else View.GONE
|
||||
stepScale.visibility = if (step == 4) View.VISIBLE else View.GONE
|
||||
stepScreensaver.visibility = if (step == 5) View.VISIBLE else View.GONE
|
||||
stepDone.visibility = if (step == 6) View.VISIBLE else View.GONE
|
||||
stepGemini.visibility = if (step == 6) View.VISIBLE else View.GONE
|
||||
stepBring.visibility = if (step == 7) View.VISIBLE else View.GONE
|
||||
stepDone.visibility = if (step == 8) View.VISIBLE else View.GONE
|
||||
|
||||
updateProgressDots()
|
||||
|
||||
@@ -478,7 +534,7 @@ class SetupActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
// Build summary when entering done step
|
||||
if (step == 6) buildSummary()
|
||||
if (step == 8) buildSummary()
|
||||
|
||||
// Cancel auto-discover when leaving server step
|
||||
if (step != 3) discoverCancelled.set(true)
|
||||
@@ -489,11 +545,11 @@ class SetupActivity : AppCompatActivity() {
|
||||
|
||||
private fun updateProgressDots() {
|
||||
progressDots.removeAllViews()
|
||||
// Show 5 dots for steps 1-5; step 0 (language) and step 6 (done) have no dots
|
||||
if (currentStep == 0 || currentStep == 6) return
|
||||
val active = currentStep // 1..5
|
||||
// Show 7 dots for steps 1-7; step 0 (language) and step 8 (done) have no dots
|
||||
if (currentStep == 0 || currentStep == 8) return
|
||||
val active = currentStep // 1..7
|
||||
val density = resources.displayMetrics.density
|
||||
for (i in 1..5) {
|
||||
for (i in 1..7) {
|
||||
val dot = View(this)
|
||||
val sizeDp = if (i == active) 10 else 7
|
||||
val px = (sizeDp * density).toInt()
|
||||
@@ -837,7 +893,7 @@ class SetupActivity : AppCompatActivity() {
|
||||
}
|
||||
discoveredDevices.clear()
|
||||
deviceAdapter?.notifyDataSetChanged()
|
||||
tvScanStatus.text = "🔍 Scansione in corso…"
|
||||
tvScanStatus.text = getString(R.string.ble_scanning)
|
||||
tvScanStatus.setTextColor(0xFF94a3b8.toInt())
|
||||
btnScanBle.isEnabled = false
|
||||
mgr.startScan()
|
||||
@@ -850,7 +906,7 @@ class SetupActivity : AppCompatActivity() {
|
||||
tvSelectedScale.text = "✅ ${info.name}"
|
||||
tvSelectedScale.visibility = View.VISIBLE
|
||||
btnScanBle.isEnabled = true
|
||||
btnScanBle.text = "🔄 Scansiona di nuovo"
|
||||
btnScanBle.text = getString(R.string.ble_scan_again)
|
||||
// Start connection test
|
||||
startScaleTest(info)
|
||||
}
|
||||
@@ -863,7 +919,7 @@ class SetupActivity : AppCompatActivity() {
|
||||
scaleTestCard.visibility = View.VISIBLE
|
||||
testWeightBox.visibility = View.GONE
|
||||
step3NextButtons.visibility = View.GONE
|
||||
tvTestStatus.text = "🔗 Connessione a ${info.name}…"
|
||||
tvTestStatus.text = getString(R.string.ble_connecting_to).format(info.name)
|
||||
tvTestStatus.setTextColor(0xFF94a3b8.toInt())
|
||||
tvTestWeight.text = "— g"
|
||||
// Disable confirm/retry until we have data
|
||||
@@ -887,19 +943,19 @@ class SetupActivity : AppCompatActivity() {
|
||||
}
|
||||
override fun onConnecting(device: BluetoothDevice) {
|
||||
if (!isInTestMode) return
|
||||
tvTestStatus.text = "🔗 Connessione in corso…"
|
||||
tvTestStatus.text = getString(R.string.ble_connecting)
|
||||
tvTestStatus.setTextColor(0xFF94a3b8.toInt())
|
||||
}
|
||||
override fun onConnected(deviceName: String) {
|
||||
if (!isInTestMode) return
|
||||
tvTestStatus.text = "⚖️ Connesso! Posiziona un oggetto sulla bilancia…"
|
||||
tvTestStatus.text = getString(R.string.ble_connected)
|
||||
tvTestStatus.setTextColor(0xFF34d399.toInt())
|
||||
testWeightBox.visibility = View.VISIBLE
|
||||
findViewById<MaterialButton>(R.id.btnTestRetry).isEnabled = true
|
||||
}
|
||||
override fun onDisconnected() {
|
||||
if (!isInTestMode) return
|
||||
tvTestStatus.text = "⚠️ Connessione persa. Riprova."
|
||||
tvTestStatus.text = getString(R.string.ble_disconnected)
|
||||
tvTestStatus.setTextColor(0xFFfbbf24.toInt())
|
||||
testWeightBox.visibility = View.GONE
|
||||
testHasWeight = false
|
||||
@@ -914,7 +970,7 @@ class SetupActivity : AppCompatActivity() {
|
||||
"%g ${reading.unit}".format(reading.value)
|
||||
tvTestWeight.text = display
|
||||
testWeightBox.visibility = View.VISIBLE
|
||||
tvTestStatus.text = "Peso ricevuto — coincide con quello sulla bilancia?"
|
||||
tvTestStatus.text = getString(R.string.ble_weight_received)
|
||||
tvTestStatus.setTextColor(0xFF94a3b8.toInt())
|
||||
findViewById<MaterialButton>(R.id.btnTestConfirm).isEnabled = true
|
||||
findViewById<MaterialButton>(R.id.btnTestRetry).isEnabled = true
|
||||
@@ -936,10 +992,10 @@ class SetupActivity : AppCompatActivity() {
|
||||
override fun onScanStopped() {
|
||||
btnScanBle.isEnabled = true
|
||||
if (discoveredDevices.isEmpty()) {
|
||||
tvScanStatus.text = "Nessuna bilancia trovata. Assicurati che sia accesa e vicina, poi riprova."
|
||||
tvScanStatus.text = getString(R.string.ble_no_scale_found)
|
||||
tvScanStatus.setTextColor(0xFFfbbf24.toInt())
|
||||
} else {
|
||||
tvScanStatus.text = "Seleziona la tua bilancia dall'elenco."
|
||||
tvScanStatus.text = getString(R.string.ble_select_from_list)
|
||||
tvScanStatus.setTextColor(0xFF94a3b8.toInt())
|
||||
}
|
||||
}
|
||||
@@ -998,19 +1054,23 @@ class SetupActivity : AppCompatActivity() {
|
||||
val scaleName = bleManager?.getSavedDeviceName()
|
||||
val scaleOk = hasScale && scaleName != null
|
||||
val lang = prefs.getString(KEY_LANGUAGE, "it") ?: "it"
|
||||
val langLabel = when (lang) { "en" -> "English 🇬🇧"; "de" -> "Deutsch 🇩🇪"; else -> "Italiano 🇮🇹" }
|
||||
val langLabel = when (lang) { "en" -> "English 🇬🇧"; "de" -> "Deutsch 🇩🇪"; "es" -> "Español 🇪🇸"; "fr" -> "Français 🇫🇷"; else -> "Italiano 🇮🇹" }
|
||||
val sb = StringBuilder()
|
||||
sb.appendLine("🌐 ${getString(R.string.summary_lang)}: $langLabel")
|
||||
if (url.isNotEmpty()) sb.appendLine("🖥️ Server: $url")
|
||||
sb.appendLine(when {
|
||||
scaleOk -> "✅ Bilancia: $scaleName"
|
||||
hasScale -> "⚠️ Bilancia: da configurare"
|
||||
scaleOk -> getString(R.string.summary_scale_ok).format(scaleName)
|
||||
hasScale -> "⚠️ ${getString(R.string.summary_scale_warn)}"
|
||||
else -> "⏭ ${getString(R.string.summary_scale_skip)}"
|
||||
})
|
||||
sb.appendLine(if (screensOn) getString(R.string.summary_screensaver_on) else getString(R.string.summary_screensaver_off))
|
||||
if (pricesOn) sb.appendLine(getString(R.string.summary_prices_on))
|
||||
if (mealPlanOn) sb.appendLine(getString(R.string.summary_mealplan_on))
|
||||
if (zeroWasteOn) sb.appendLine(getString(R.string.summary_zerowaste_on))
|
||||
val geminiSet = !(prefs.getString(KEY_GEMINI_KEY, "") ?: "").isNullOrEmpty()
|
||||
val bringSet = !(prefs.getString(KEY_BRING_EMAIL, "") ?: "").isNullOrEmpty()
|
||||
sb.appendLine(if (geminiSet) getString(R.string.summary_gemini_set) else getString(R.string.summary_gemini_skip))
|
||||
sb.appendLine(if (bringSet) getString(R.string.summary_bring_set) else getString(R.string.summary_bring_skip))
|
||||
summaryText.text = sb.toString().trimEnd()
|
||||
}
|
||||
|
||||
@@ -1026,6 +1086,9 @@ class SetupActivity : AppCompatActivity() {
|
||||
Thread {
|
||||
try {
|
||||
val url = "$baseUrl/api/index.php?action=save_settings"
|
||||
val geminiKey = prefs.getString(KEY_GEMINI_KEY, "") ?: ""
|
||||
val bringEmail = prefs.getString(KEY_BRING_EMAIL, "") ?: ""
|
||||
val bringPassword = prefs.getString(KEY_BRING_PASSWORD, "") ?: ""
|
||||
val body = buildString {
|
||||
append("{\"screensaver_enabled\":$screensaver")
|
||||
append(",\"price_enabled\":$priceEnabled")
|
||||
@@ -1035,6 +1098,9 @@ class SetupActivity : AppCompatActivity() {
|
||||
val lanIp = getDeviceLanIp() ?: "127.0.0.1"
|
||||
append(",\"scale_enabled\":true,\"scale_gateway_url\":\"ws://$lanIp:8765\"")
|
||||
}
|
||||
if (geminiKey.isNotEmpty()) append(",\"gemini_api_key\":\"${geminiKey.replace("\"", "\\\"\")}\"")
|
||||
if (bringEmail.isNotEmpty()) append(",\"bring_email\":\"${bringEmail.replace("\"", "\\\"\")}\"")
|
||||
if (bringPassword.isNotEmpty()) append(",\"bring_password\":\"${bringPassword.replace("\"", "\\\"\")}\"")
|
||||
append("}")
|
||||
}
|
||||
val conn = (java.net.URL(url).openConnection() as java.net.HttpURLConnection).apply {
|
||||
|
||||
@@ -78,11 +78,11 @@
|
||||
android:layout_marginBottom="24dp"
|
||||
android:contentDescription="EverShelf" />
|
||||
|
||||
<!-- Title shown in all 3 languages so it's always readable -->
|
||||
<!-- Title shown in all 5 languages so it's always readable -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Scegli la lingua\nChoose your language\nSprache wählen"
|
||||
android:text="Scegli la lingua · Choose your language\nSprache wählen · Elige el idioma\nChoisissez votre langue"
|
||||
android:textColor="#f1f5f9"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold"
|
||||
@@ -117,7 +117,27 @@
|
||||
android:text="🇩🇪 Deutsch"
|
||||
android:textSize="18sp"
|
||||
android:textAllCaps="false"
|
||||
android:backgroundTint="#b91c1c" />
|
||||
android:backgroundTint="#b91c1c"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnLangEs"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="64dp"
|
||||
android:text="🇪🇸 Español"
|
||||
android:textSize="18sp"
|
||||
android:textAllCaps="false"
|
||||
android:backgroundTint="#c2410c"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnLangFr"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="64dp"
|
||||
android:text="🇫🇷 Français"
|
||||
android:textSize="18sp"
|
||||
android:textAllCaps="false"
|
||||
android:backgroundTint="#1d4ed8" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
@@ -1241,7 +1261,7 @@
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_weight="1"
|
||||
android:text="← Indietro"
|
||||
android:text="@string/setup_step_back"
|
||||
android:textSize="14sp"
|
||||
android:textAllCaps="false"
|
||||
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
|
||||
@@ -1254,7 +1274,7 @@
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_weight="2"
|
||||
android:text="Avanti →"
|
||||
android:text="@string/setup_step_next"
|
||||
android:textSize="15sp"
|
||||
android:textAllCaps="false"
|
||||
android:backgroundTint="#7c3aed" />
|
||||
@@ -1262,7 +1282,230 @@
|
||||
</LinearLayout>
|
||||
|
||||
<!-- ════════════════════════════════════════════
|
||||
STEP 6 — Done
|
||||
STEP 6 — Gemini AI key
|
||||
════════════════════════════════════════════ -->
|
||||
<LinearLayout
|
||||
android:id="@+id/stepGemini"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center_horizontal"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="🤖"
|
||||
android:textSize="52sp"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/setup_gemini_title"
|
||||
android:textColor="#f1f5f9"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/setup_gemini_desc"
|
||||
android:textColor="#94a3b8"
|
||||
android:textSize="15sp"
|
||||
android:gravity="center"
|
||||
android:lineSpacingExtra="4dp"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<!-- How-to card -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:background="@drawable/card_background"
|
||||
android:padding="14dp"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="16dp">
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="💡"
|
||||
android:textSize="20sp"
|
||||
android:layout_marginEnd="10dp" />
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/setup_gemini_how"
|
||||
android:textColor="#7dd3fc"
|
||||
android:textSize="13sp"
|
||||
android:lineSpacingExtra="3dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<EditText
|
||||
android:id="@+id/setupGeminiKeyEdit"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/setup_gemini_hint"
|
||||
android:textColor="#f1f5f9"
|
||||
android:textColorHint="#475569"
|
||||
android:backgroundTint="#334155"
|
||||
android:inputType="textVisiblePassword"
|
||||
android:textSize="14sp"
|
||||
android:fontFamily="monospace"
|
||||
android:layout_marginBottom="20dp" />
|
||||
|
||||
<!-- Navigation -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnGeminiBack"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/setup_step_back"
|
||||
android:textSize="14sp"
|
||||
android:textAllCaps="false"
|
||||
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
|
||||
android:strokeColor="#334155"
|
||||
android:textColor="#64748b"
|
||||
android:layout_marginEnd="8dp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnGeminiSkip"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/setup_skip_later"
|
||||
android:textSize="13sp"
|
||||
android:textAllCaps="false"
|
||||
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
|
||||
android:strokeColor="#475569"
|
||||
android:textColor="#94a3b8"
|
||||
android:layout_marginEnd="8dp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnGeminiNext"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/setup_confirm"
|
||||
android:textSize="14sp"
|
||||
android:textAllCaps="false"
|
||||
android:backgroundTint="#7c3aed" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- ════════════════════════════════════════════
|
||||
STEP 7 — Bring! credentials
|
||||
════════════════════════════════════════════ -->
|
||||
<LinearLayout
|
||||
android:id="@+id/stepBring"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center_horizontal"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="🛒"
|
||||
android:textSize="52sp"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/setup_bring_title"
|
||||
android:textColor="#f1f5f9"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/setup_bring_desc"
|
||||
android:textColor="#94a3b8"
|
||||
android:textSize="15sp"
|
||||
android:gravity="center"
|
||||
android:lineSpacingExtra="4dp"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/setupBringEmailEdit"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/setup_bring_email_hint"
|
||||
android:textColor="#f1f5f9"
|
||||
android:textColorHint="#475569"
|
||||
android:backgroundTint="#334155"
|
||||
android:inputType="textEmailAddress"
|
||||
android:textSize="15sp"
|
||||
android:layout_marginBottom="10dp" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/setupBringPasswordEdit"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/setup_bring_pass_hint"
|
||||
android:textColor="#f1f5f9"
|
||||
android:textColorHint="#475569"
|
||||
android:backgroundTint="#334155"
|
||||
android:inputType="textPassword"
|
||||
android:textSize="15sp"
|
||||
android:layout_marginBottom="20dp" />
|
||||
|
||||
<!-- Navigation -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnBringBack"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/setup_step_back"
|
||||
android:textSize="14sp"
|
||||
android:textAllCaps="false"
|
||||
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
|
||||
android:strokeColor="#334155"
|
||||
android:textColor="#64748b"
|
||||
android:layout_marginEnd="8dp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnBringSkip"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/setup_skip_later"
|
||||
android:textSize="13sp"
|
||||
android:textAllCaps="false"
|
||||
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
|
||||
android:strokeColor="#475569"
|
||||
android:textColor="#94a3b8"
|
||||
android:layout_marginEnd="8dp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnBringNext"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/setup_confirm"
|
||||
android:textSize="14sp"
|
||||
android:textAllCaps="false"
|
||||
android:backgroundTint="#059669" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- ════════════════════════════════════════════
|
||||
STEP 8 — Done
|
||||
════════════════════════════════════════════ -->
|
||||
<LinearLayout
|
||||
android:id="@+id/stepDone"
|
||||
@@ -1282,7 +1525,7 @@
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Tutto pronto!"
|
||||
android:text="@string/setup_done_title"
|
||||
android:textColor="#f1f5f9"
|
||||
android:textSize="28sp"
|
||||
android:textStyle="bold"
|
||||
@@ -1291,7 +1534,7 @@
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="La configurazione è completa. Premi il pulsante per avviare EverShelf in modalità kiosk."
|
||||
android:text="@string/setup_done_desc"
|
||||
android:textColor="#94a3b8"
|
||||
android:textSize="15sp"
|
||||
android:gravity="center"
|
||||
@@ -1310,10 +1553,10 @@
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Riepilogo configurazione"
|
||||
android:text="@string/setup_done_summary_label"
|
||||
android:textColor="#94a3b8"
|
||||
android:textSize="13sp"
|
||||
android:textAllCaps="true"
|
||||
android:textAllCaps="false"
|
||||
android:letterSpacing="0.08"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
@@ -1331,7 +1574,7 @@
|
||||
android:id="@+id/btnLaunch"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="60dp"
|
||||
android:text="🚀 Avvia EverShelf"
|
||||
android:text="@string/btn_launch"
|
||||
android:textSize="18sp"
|
||||
android:textAllCaps="false"
|
||||
android:backgroundTint="#059669" />
|
||||
|
||||
@@ -1,29 +1,37 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">EverShelf Kiosk</string>
|
||||
|
||||
<!-- Setup-Assistent Zeichenfolgen -->
|
||||
<string name="setup_enter_url">Bitte zuerst eine URL eingeben</string>
|
||||
<string name="setup_testing">Verbindung wird getestet…</string>
|
||||
<string name="setup_server_found">EverShelf-Server gefunden und API aktiv!</string>
|
||||
<string name="setup_api_not_found">Server erreichbar, aber EverShelf-API nicht gefunden. Pfad prüfen.</string>
|
||||
<string name="setup_unreachable">Server nicht erreichbar</string>
|
||||
<string name="setup_discover_btn">🔍 Lokales Netzwerk durchsuchen</string> <string name="setup_perms_granted_next">✅ Berechtigungen erteilt — Weiter →</string> <string name="setup_discovering">Suche läuft…</string>
|
||||
<string name="setup_discover_btn">🔍 Lokales Netzwerk durchsuchen</string>
|
||||
<string name="setup_perms_granted_next">✅ Berechtigungen erteilt — Weiter →</string>
|
||||
<string name="setup_discovering">Suche läuft…</string>
|
||||
<string name="setup_discovering_detail">Suche nach EverShelf-Servern im lokalen Netzwerk…</string>
|
||||
<string name="setup_discover_not_found">Kein EverShelf-Server automatisch gefunden. URL manuell eingeben.</string>
|
||||
<string name="setup_exit_title">Setup beenden?</string>
|
||||
<string name="setup_exit_message">Die Einrichtung kann später beim erneuten Öffnen der App abgeschlossen werden.</string>
|
||||
<string name="setup_exit_confirm">Beenden</string>
|
||||
<string name="setup_exit_cancel">Weiter</string>
|
||||
|
||||
<!-- Wizard Schritt 3: Smart-Waage -->
|
||||
<string name="setup_step_back">← Zurück</string>
|
||||
<string name="setup_step_next">Weiter →</string>
|
||||
<string name="setup_skip_later">Später einrichten</string>
|
||||
<string name="setup_confirm">Bestätigen →</string>
|
||||
<string name="wizard_step3_title">Smart-Waage (Optional)</string>
|
||||
<string name="wizard_step3_description">Um eine Bluetooth-Küchenwaage zu verwenden, musst du die EverShelf Scale Gateway App separat installieren.</string>
|
||||
<string name="wizard_step3_question">Hast du eine Bluetooth-Küchenwaage?</string>
|
||||
<string name="wizard_step3_yes">✅ Ja, ich habe eine Waage</string>
|
||||
<string name="wizard_step3_no">➡️ Nein, überspringen</string>
|
||||
|
||||
<!-- Gateway-Statusmeldungen -->
|
||||
<string name="ble_scanning">🔍 Suche läuft…</string>
|
||||
<string name="ble_connected">Verbunden! Gegenstand auf die Waage legen…</string>
|
||||
<string name="ble_disconnected">Verbindung getrennt. Erneut versuchen.</string>
|
||||
<string name="ble_no_scale_found">Keine Waage gefunden. Sicherstellen, dass sie eingeschaltet und in der Nähe ist, und erneut versuchen.</string>
|
||||
<string name="ble_select_from_list">Waage aus der Liste auswählen.</string>
|
||||
<string name="ble_not_confirmed">Waage nicht bestätigt. Erneut scannen.</string>
|
||||
<string name="ble_scan_again">🔄 Erneut scannen</string>
|
||||
<string name="ble_weight_received">Gewicht empfangen — Stimmt es mit der Anzeige überein?</string>
|
||||
<string name="wizard_gateway_installed">Scale Gateway installiert ✅</string>
|
||||
<string name="wizard_gateway_installed_detail">Wird beim Fortfahren im Hintergrund gestartet.</string>
|
||||
<string name="wizard_gateway_not_installed">Scale Gateway nicht installiert</string>
|
||||
@@ -32,8 +40,6 @@
|
||||
<string name="wizard_gateway_up_to_date">Scale Gateway ist aktuell.</string>
|
||||
<string name="wizard_gateway_update_available">Update für Scale Gateway verfügbar</string>
|
||||
<string name="wizard_gateway_update_detail">Tippe auf den Button, um jetzt zu aktualisieren.</string>
|
||||
|
||||
<!-- Download- / Installationsfortschritt -->
|
||||
<string name="install_downloading">Download läuft…</string>
|
||||
<string name="install_downloading_detail">Bitte warten, die Datei wird heruntergeladen.</string>
|
||||
<string name="install_installing">Installation läuft…</string>
|
||||
@@ -43,31 +49,56 @@
|
||||
<string name="install_error_download">Download fehlgeschlagen</string>
|
||||
<string name="install_error_download_detail">Verbindung prüfen und erneut versuchen.</string>
|
||||
<string name="install_error_install">Installation fehlgeschlagen</string>
|
||||
<string name="install_perm_detail">Aktiviere \'Unbekannte Apps installieren\' in den Einstellungen, dann komm zurück.</string>
|
||||
<string name="install_perm_detail">Aktiviere 'Unbekannte Apps installieren' in den Einstellungen, dann komm zurück.</string>
|
||||
<string name="install_btn_retry">↩ Nochmal versuchen</string>
|
||||
|
||||
<!-- Schaltflächen -->
|
||||
<string name="btn_back">Zurück</string>
|
||||
<string name="btn_launch">🚀 EverShelf starten</string>
|
||||
<string name="btn_launch_no_scale">🚀 Ohne Waage starten</string>
|
||||
<string name="btn_download_gateway">📥 Scale Gateway installieren</string>
|
||||
<string name="btn_update_gateway">📥 Scale Gateway aktualisieren</string>
|
||||
|
||||
<!-- Server-Erreichbarkeit prüfen (Wizard Schritt 3) -->
|
||||
<string name="wizard_server_checking">Server-Verbindung wird geprüft…</string>
|
||||
<string name="wizard_server_ok">Server erreichbar ✅</string>
|
||||
<string name="wizard_server_ok_detail">Fehlerberichterstattung aktiv — Installationsfehler werden automatisch an GitHub Issues gesendet.</string>
|
||||
<string name="wizard_server_error">Server nicht erreichbar ⚠️</string>
|
||||
<string name="wizard_server_error_detail">Fehler werden GitHub Issues nicht erreichen. URL in Schritt 2 prüfen.</string>
|
||||
<!-- Bildschirmschoner-Schritt -->
|
||||
<string name="setup_screensaver_title">Bildschirmschoner</string>
|
||||
<string name="setup_screensaver_desc">Zeigt nach 5 Minuten Inaktivität eine Uhr mit nützlichen Fakten. Standardmäßig deaktiviert (Bildschirm bleibt immer an).</string>
|
||||
<string name="setup_screensaver_toggle_label">Bildschirmschoner aktivieren</string>
|
||||
<string name="setup_screensaver_toggle_hint">Wenn deaktiviert, bleibt der Bildschirm immer an.</string>
|
||||
<string name="setup_features_title">Funktionen</string>
|
||||
<string name="setup_features_desc">Aktiviere die gewünschten Funktionen. Du kannst sie später jederzeit in den Servereinstellungen ändern.</string>
|
||||
<string name="setup_screensaver_toggle_label">Uhr-Bildschirmschoner</string>
|
||||
<string name="setup_screensaver_toggle_hint">Zeigt eine Uhranzeige nach 5 Min. Inaktivität.</string>
|
||||
<string name="setup_prices_toggle_label">Einkaufslisten-Preise</string>
|
||||
<string name="setup_prices_toggle_hint">KI-gestützte automatische Kostensätzung für jeden Artikel.</string>
|
||||
<string name="setup_mealplan_toggle_label">Mahlzeitenplan</string>
|
||||
<string name="setup_mealplan_toggle_hint">Plane die Wöchentliche Mahlzeiten mit Rezepten aus deiner Vorratskammer.</string>
|
||||
<string name="setup_zerowaste_toggle_label">Zero-Waste-Tipps</string>
|
||||
<string name="setup_zerowaste_toggle_hint">Beim Kochen Tipps zur Wiederverwendung von Resten anzeigen (Schalen, Kochwasser usw.).</string>
|
||||
<string name="setup_gemini_title">Google Gemini AI</string>
|
||||
<string name="setup_gemini_desc">EverShelf nutzt Google Gemini AI für Rezeptvorschläge, smarte Einkaufsschätzungen und mehr.
|
||||
|
||||
<!-- Zusammenfassung -->
|
||||
Zum Aktivieren den kostenlosen Gemini API-Schlüssel eingeben.</string>
|
||||
<string name="setup_gemini_how">Kostenlosen Schlüssel unter: aistudio.google.com → "API-Schlüssel erhalten"</string>
|
||||
<string name="setup_gemini_hint">API-Schlüssel einfügen (beginnt mit AIza…)</string>
|
||||
<string name="setup_bring_title">Bring! Einkaufsliste</string>
|
||||
<string name="setup_bring_desc">EverShelf kann die Einkaufsliste mit der Bring!-App synchronisieren.
|
||||
|
||||
Bring!-Zugangsdaten eingeben, um die Integration zu aktivieren.</string>
|
||||
<string name="setup_bring_email_hint">Bring!-E-Mail-Adresse</string>
|
||||
<string name="setup_bring_pass_hint">Bring!-Passwort</string>
|
||||
<string name="setup_done_title">Alles bereit!</string>
|
||||
<string name="setup_done_desc">Die Einrichtung ist abgeschlossen. Auf den Button tippen, um EverShelf im Kiosk-Modus zu starten.</string>
|
||||
<string name="setup_done_summary_label">KONFIGURATIONSSÜBERSICHT</string>
|
||||
<string name="summary_lang">Sprache</string>
|
||||
<string name="summary_scale_skip">Waage: nicht konfiguriert</string>
|
||||
<string name="summary_screensaver_on">Bildschirmschoner: aktiv</string>
|
||||
<string name="summary_screensaver_off">Bildschirm immer an (Bildschirmschoner deaktiviert)</string>
|
||||
</resources>
|
||||
<string name="summary_prices_on">Einkaufslisten-Preise: aktiviert</string>
|
||||
<string name="summary_mealplan_on">Mahlzeitenplan: aktiviert</string>
|
||||
<string name="summary_zerowaste_on">Zero-Waste-Tipps: aktiviert</string>
|
||||
<string name="summary_gemini_set">Gemini AI: aktiviert</string>
|
||||
<string name="summary_gemini_skip">Gemini AI: nicht konfiguriert</string>
|
||||
<string name="summary_bring_set">Bring!: verbunden</string>
|
||||
<string name="summary_bring_skip">Bring!: nicht konfiguriert</string>
|
||||
<string name="ble_connecting_to">🔗 Verbinde mit %s…</string>
|
||||
<string name="ble_connecting">🔗 Verbindung wird hergestellt…</string>
|
||||
<string name="summary_scale_ok">Waage: %s</string>
|
||||
<string name="summary_scale_warn">Waage: nicht bestätigt</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,104 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">EverShelf Kiosk</string>
|
||||
<string name="setup_enter_url">Introduce primero una URL</string>
|
||||
<string name="setup_testing">Probando conexión…</string>
|
||||
<string name="setup_server_found">¡Servidor EverShelf encontrado y API activa!</string>
|
||||
<string name="setup_api_not_found">Servidor accesible pero API EverShelf no encontrada. Comprueba la ruta.</string>
|
||||
<string name="setup_unreachable">No se puede alcanzar el servidor</string>
|
||||
<string name="setup_discover_btn">🔍 Buscar en la red local</string>
|
||||
<string name="setup_perms_granted_next">✅ Permisos concedidos — Continuar →</string>
|
||||
<string name="setup_discovering">Escaneando…</string>
|
||||
<string name="setup_discovering_detail">Buscando servidores EverShelf en la red local…</string>
|
||||
<string name="setup_discover_not_found">Ningún servidor EverShelf encontrado automáticamente. Introduce la URL manualmente.</string>
|
||||
<string name="setup_exit_title">¿Salir de la configuración?</string>
|
||||
<string name="setup_exit_message">Puedes completar la configuración más tarde cuando vuelvas a abrir la app.</string>
|
||||
<string name="setup_exit_confirm">Salir</string>
|
||||
<string name="setup_exit_cancel">Continuar</string>
|
||||
<string name="setup_step_back">← Atrás</string>
|
||||
<string name="setup_step_next">Siguiente →</string>
|
||||
<string name="setup_skip_later">Configurar después</string>
|
||||
<string name="setup_confirm">Confirmar →</string>
|
||||
<string name="wizard_step3_title">Báscula inteligente</string>
|
||||
<string name="wizard_step3_description">EverShelf Kiosk incluye una pasarela Bluetooth integrada — no necesitas ninguna app externa. Selecciona tu báscula abajo.</string>
|
||||
<string name="wizard_step3_question">¿Tienes una báscula inteligente Bluetooth?</string>
|
||||
<string name="wizard_step3_yes">✅ Sí, tengo una báscula</string>
|
||||
<string name="wizard_step3_no">➡️ No, saltar este paso</string>
|
||||
<string name="ble_scanning">🔍 Escaneando…</string>
|
||||
<string name="ble_connected">¡Conectado! Coloca un objeto en la báscula…</string>
|
||||
<string name="ble_disconnected">Conexión perdida. Reintentar.</string>
|
||||
<string name="ble_no_scale_found">No se encontró ninguna báscula. Asegúrate de que esté encendida y cerca, e inténtalo de nuevo.</string>
|
||||
<string name="ble_select_from_list">Selecciona tu báscula de la lista.</string>
|
||||
<string name="ble_not_confirmed">Báscula no confirmada. Vuelve a escanear.</string>
|
||||
<string name="ble_scan_again">🔄 Volver a escanear</string>
|
||||
<string name="ble_weight_received">Peso recibido — ¿coincide con el mostrado en la báscula?</string>
|
||||
<string name="wizard_gateway_installed">Báscula guardada ✅</string>
|
||||
<string name="wizard_gateway_installed_detail">La pasarela BLE integrada se conectará automáticamente al inicio.</string>
|
||||
<string name="wizard_gateway_not_installed">Ninguna báscula seleccionada</string>
|
||||
<string name="wizard_gateway_not_installed_detail">Escanea las básculas BLE cercanas y toca una para seleccionarla.</string>
|
||||
<string name="wizard_gateway_checking">Escaneando básculas BLE…</string>
|
||||
<string name="wizard_gateway_up_to_date">Servicio BLE de báscula listo.</string>
|
||||
<string name="wizard_gateway_update_available">Báscula BLE encontrada</string>
|
||||
<string name="wizard_gateway_update_detail">Toca la báscula en la lista para conectarte.</string>
|
||||
<string name="install_downloading">Descargando…</string>
|
||||
<string name="install_downloading_detail">Por favor, espera mientras se descarga el archivo.</string>
|
||||
<string name="install_installing">Instalando…</string>
|
||||
<string name="install_confirm_detail">Confirma la instalación en el diálogo que se ha abierto.</string>
|
||||
<string name="install_success">¡Instalado correctamente!</string>
|
||||
<string name="install_success_detail">La app ha sido actualizada.</string>
|
||||
<string name="install_error_download">Descarga fallida</string>
|
||||
<string name="install_error_download_detail">Comprueba la conexión e inténtalo de nuevo.</string>
|
||||
<string name="install_error_install">Instalación fallida</string>
|
||||
<string name="install_perm_detail">Habilita 'Instalar apps desconocidas' en los ajustes y vuelve aquí.</string>
|
||||
<string name="install_btn_retry">↩ Reintentar</string>
|
||||
<string name="btn_back">Atrás</string>
|
||||
<string name="btn_launch">🚀 Iniciar EverShelf</string>
|
||||
<string name="btn_launch_no_scale">🚀 Iniciar sin báscula</string>
|
||||
<string name="btn_download_gateway">📥 Instalar Scale Gateway</string>
|
||||
<string name="btn_update_gateway">📥 Actualizar Scale Gateway</string>
|
||||
<string name="wizard_server_checking">Comprobando conexión al servidor…</string>
|
||||
<string name="wizard_server_ok">Servidor accesible ✅</string>
|
||||
<string name="wizard_server_ok_detail">Informe de errores activo — los fallos de instalación se enviarán automáticamente a GitHub Issues.</string>
|
||||
<string name="wizard_server_error">Servidor no accesible ⚠️</string>
|
||||
<string name="wizard_server_error_detail">Los errores no llegarán a GitHub Issues. Comprueba la URL introducida en el paso 2.</string>
|
||||
<string name="setup_features_title">Funcionalidades</string>
|
||||
<string name="setup_features_desc">Activa las funciones que quieras usar. Puedes cambiarlas en cualquier momento desde los ajustes del servidor.</string>
|
||||
<string name="setup_screensaver_toggle_label">Salvapantallas reloj</string>
|
||||
<string name="setup_screensaver_toggle_hint">Muestra un reloj después de 5 min de inactividad.</string>
|
||||
<string name="setup_prices_toggle_label">Precios lista de la compra</string>
|
||||
<string name="setup_prices_toggle_hint">Estimación automática del coste de cada artículo mediante IA.</string>
|
||||
<string name="setup_mealplan_toggle_label">Plan de comidas</string>
|
||||
<string name="setup_mealplan_toggle_hint">Planifica las comidas de la semana con recetas basadas en tu despensa.</string>
|
||||
<string name="setup_zerowaste_toggle_label">Consejos zero-waste</string>
|
||||
<string name="setup_zerowaste_toggle_hint">Muestra consejos para reutilizar restos (cáscaras, agua de cocción, etc.) al cocinar.</string>
|
||||
<string name="setup_gemini_title">Google Gemini AI</string>
|
||||
<string name="setup_gemini_desc">EverShelf usa Google Gemini AI para sugerencias de recetas, estimaciones inteligentes de la compra y más.
|
||||
|
||||
Para activarla, introduce tu clave API de Gemini gratuita.</string>
|
||||
<string name="setup_gemini_how">Obtén tu clave gratuita en: aistudio.google.com → "Obtener clave API"</string>
|
||||
<string name="setup_gemini_hint">Pega la clave API aquí (empieza por AIza…)</string>
|
||||
<string name="setup_bring_title">Bring! Lista de la compra</string>
|
||||
<string name="setup_bring_desc">EverShelf puede sincronizar tu lista de la compra con la app Bring!.
|
||||
|
||||
Introduce tus credenciales de Bring! para activar la integración.</string>
|
||||
<string name="setup_bring_email_hint">Correo electrónico de Bring!</string>
|
||||
<string name="setup_bring_pass_hint">Contraseña de Bring!</string>
|
||||
<string name="setup_done_title">¡Todo listo!</string>
|
||||
<string name="setup_done_desc">La configuración está completa. Pulsa el botón para iniciar EverShelf en modo quiosco.</string>
|
||||
<string name="setup_done_summary_label">RESUMEN DE CONFIGURACIÓN</string>
|
||||
<string name="summary_lang">Idioma</string>
|
||||
<string name="summary_scale_skip">Báscula: no configurada</string>
|
||||
<string name="summary_screensaver_on">Salvapantallas: activo</string>
|
||||
<string name="summary_screensaver_off">Pantalla siempre encendida (salvapantallas desactivado)</string>
|
||||
<string name="summary_prices_on">Precios lista de la compra: activados</string>
|
||||
<string name="summary_mealplan_on">Plan de comidas: activado</string>
|
||||
<string name="summary_zerowaste_on">Consejos zero-waste: activados</string>
|
||||
<string name="summary_gemini_set">Gemini AI: activada</string>
|
||||
<string name="summary_gemini_skip">Gemini AI: no configurada</string>
|
||||
<string name="summary_bring_set">Bring!: conectada</string>
|
||||
<string name="summary_bring_skip">Bring!: no configurada</string>
|
||||
<string name="ble_connecting_to">🔗 Conectando con %s…</string>
|
||||
<string name="ble_connecting">🔗 Estableciendo conexión…</string>
|
||||
<string name="summary_scale_ok">Báscula: %s</string>
|
||||
<string name="summary_scale_warn">Báscula: no confirmada</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,104 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">EverShelf Kiosk</string>
|
||||
<string name="setup_enter_url">Veuillez d'abord saisir une URL</string>
|
||||
<string name="setup_testing">Test de connexion…</string>
|
||||
<string name="setup_server_found">Serveur EverShelf trouvé et API active !</string>
|
||||
<string name="setup_api_not_found">Serveur accessible mais API EverShelf introuvable. Vérifiez le chemin.</string>
|
||||
<string name="setup_unreachable">Impossible d'atteindre le serveur</string>
|
||||
<string name="setup_discover_btn">🔍 Rechercher sur le réseau local</string>
|
||||
<string name="setup_perms_granted_next">✅ Permissions accordées — Continuer →</string>
|
||||
<string name="setup_discovering">Analyse en cours…</string>
|
||||
<string name="setup_discovering_detail">Recherche de serveurs EverShelf sur le réseau local…</string>
|
||||
<string name="setup_discover_not_found">Aucun serveur EverShelf trouvé automatiquement. Entrez l'URL manuellement.</string>
|
||||
<string name="setup_exit_title">Quitter la configuration ?</string>
|
||||
<string name="setup_exit_message">Vous pouvez terminer la configuration plus tard en rouvrant l'app.</string>
|
||||
<string name="setup_exit_confirm">Quitter</string>
|
||||
<string name="setup_exit_cancel">Continuer</string>
|
||||
<string name="setup_step_back">← Retour</string>
|
||||
<string name="setup_step_next">Suivant →</string>
|
||||
<string name="setup_skip_later">Configurer plus tard</string>
|
||||
<string name="setup_confirm">Confirmer →</string>
|
||||
<string name="wizard_step3_title">Balance intelligente</string>
|
||||
<string name="wizard_step3_description">EverShelf Kiosk inclut une passerelle Bluetooth intégrée — aucune app externe nécessaire. Sélectionnez votre balance ci-dessous.</string>
|
||||
<string name="wizard_step3_question">Avez-vous une balance intelligente Bluetooth ?</string>
|
||||
<string name="wizard_step3_yes">✅ Oui, j'ai une balance</string>
|
||||
<string name="wizard_step3_no">➡️ Non, ignorer cette étape</string>
|
||||
<string name="ble_scanning">🔍 Scan en cours…</string>
|
||||
<string name="ble_connected">Connecté ! Posez un objet sur la balance…</string>
|
||||
<string name="ble_disconnected">Connexion perdue. Réessayer.</string>
|
||||
<string name="ble_no_scale_found">Aucune balance trouvée. Vérifiez qu'elle est allumée et à proximité, puis réessayez.</string>
|
||||
<string name="ble_select_from_list">Sélectionnez votre balance dans la liste.</string>
|
||||
<string name="ble_not_confirmed">Balance non confirmée. Relancer le scan.</string>
|
||||
<string name="ble_scan_again">🔄 Scanner à nouveau</string>
|
||||
<string name="ble_weight_received">Poids reçu — correspond-il à l'affichage de la balance ?</string>
|
||||
<string name="wizard_gateway_installed">Balance enregistrée ✅</string>
|
||||
<string name="wizard_gateway_installed_detail">La passerelle BLE intégrée se connectera automatiquement au démarrage.</string>
|
||||
<string name="wizard_gateway_not_installed">Aucune balance sélectionnée</string>
|
||||
<string name="wizard_gateway_not_installed_detail">Scannez les balances BLE à proximité et appuyez sur l'une d'elles pour la sélectionner.</string>
|
||||
<string name="wizard_gateway_checking">Scan des balances BLE en cours…</string>
|
||||
<string name="wizard_gateway_up_to_date">Service BLE de la balance prêt.</string>
|
||||
<string name="wizard_gateway_update_available">Balance BLE trouvée</string>
|
||||
<string name="wizard_gateway_update_detail">Appuyez sur la balance dans la liste pour vous connecter.</string>
|
||||
<string name="install_downloading">Téléchargement en cours…</string>
|
||||
<string name="install_downloading_detail">Veuillez patienter, le fichier est en cours de téléchargement.</string>
|
||||
<string name="install_installing">Installation en cours…</string>
|
||||
<string name="install_confirm_detail">Confirmez l'installation dans la boîte de dialogue ouverte.</string>
|
||||
<string name="install_success">Installé avec succès !</string>
|
||||
<string name="install_success_detail">L'app a été mise à jour.</string>
|
||||
<string name="install_error_download">Téléchargement échoué</string>
|
||||
<string name="install_error_download_detail">Vérifiez la connexion et réessayez.</string>
|
||||
<string name="install_error_install">Installation échouée</string>
|
||||
<string name="install_perm_detail">Activez 'Installer des apps inconnues' dans les paramètres, puis revenez ici.</string>
|
||||
<string name="install_btn_retry">↩ Réessayer</string>
|
||||
<string name="btn_back">Retour</string>
|
||||
<string name="btn_launch">🚀 Lancer EverShelf</string>
|
||||
<string name="btn_launch_no_scale">🚀 Lancer sans balance</string>
|
||||
<string name="btn_download_gateway">📥 Installer Scale Gateway</string>
|
||||
<string name="btn_update_gateway">📥 Mettre à jour Scale Gateway</string>
|
||||
<string name="wizard_server_checking">Vérification de la connexion au serveur…</string>
|
||||
<string name="wizard_server_ok">Serveur accessible ✅</string>
|
||||
<string name="wizard_server_ok_detail">Rapport d'erreurs actif — les échecs d'installation seront envoyés automatiquement aux GitHub Issues.</string>
|
||||
<string name="wizard_server_error">Serveur inaccessible ⚠️</string>
|
||||
<string name="wizard_server_error_detail">Les erreurs n'atteindront pas GitHub Issues. Vérifiez l'URL saisie à l'étape 2.</string>
|
||||
<string name="setup_features_title">Fonctionnalités</string>
|
||||
<string name="setup_features_desc">Activez les fonctions que vous souhaitez utiliser. Vous pourrez les modifier plus tard dans les paramètres du serveur.</string>
|
||||
<string name="setup_screensaver_toggle_label">Horloge écran de veille</string>
|
||||
<string name="setup_screensaver_toggle_hint">Affiche une horloge après 5 min d'inactivité.</string>
|
||||
<string name="setup_prices_toggle_label">Prix liste de courses</string>
|
||||
<string name="setup_prices_toggle_hint">Estimation automatique du coût de chaque article via IA.</string>
|
||||
<string name="setup_mealplan_toggle_label">Plan de repas</string>
|
||||
<string name="setup_mealplan_toggle_hint">Planifiez les repas de la semaine avec des recettes basées sur votre garde-manger.</string>
|
||||
<string name="setup_zerowaste_toggle_label">Conseils zéro déchet</string>
|
||||
<string name="setup_zerowaste_toggle_hint">Affiche des conseils pour réutiliser les restes (peaux, eau de cuisson, etc.) pendant la cuisson.</string>
|
||||
<string name="setup_gemini_title">Google Gemini AI</string>
|
||||
<string name="setup_gemini_desc">EverShelf utilise Google Gemini AI pour les suggestions de recettes, les estimations intelligentes des courses et plus encore.
|
||||
|
||||
Pour l'activer, entrez votre clé API Gemini gratuite.</string>
|
||||
<string name="setup_gemini_how">Obtenez votre clé gratuite sur : aistudio.google.com → "Obtenir une clé API"</string>
|
||||
<string name="setup_gemini_hint">Collez la clé API ici (commence par AIza…)</string>
|
||||
<string name="setup_bring_title">Bring! Liste de courses</string>
|
||||
<string name="setup_bring_desc">EverShelf peut synchroniser votre liste de courses avec l'app Bring!.
|
||||
|
||||
Entrez vos identifiants Bring! pour activer l'intégration.</string>
|
||||
<string name="setup_bring_email_hint">Adresse e-mail Bring!</string>
|
||||
<string name="setup_bring_pass_hint">Mot de passe Bring!</string>
|
||||
<string name="setup_done_title">Tout est prêt !</string>
|
||||
<string name="setup_done_desc">La configuration est terminée. Appuyez sur le bouton pour lancer EverShelf en mode kiosque.</string>
|
||||
<string name="setup_done_summary_label">RÉSUMÉ DE CONFIGURATION</string>
|
||||
<string name="summary_lang">Langue</string>
|
||||
<string name="summary_scale_skip">Balance : non configurée</string>
|
||||
<string name="summary_screensaver_on">Écran de veille : actif</string>
|
||||
<string name="summary_screensaver_off">Écran toujours allumé (écran de veille désactivé)</string>
|
||||
<string name="summary_prices_on">Prix liste de courses : activés</string>
|
||||
<string name="summary_mealplan_on">Plan de repas : activé</string>
|
||||
<string name="summary_zerowaste_on">Conseils zéro déchet : activés</string>
|
||||
<string name="summary_gemini_set">Gemini AI : activée</string>
|
||||
<string name="summary_gemini_skip">Gemini AI : non configurée</string>
|
||||
<string name="summary_bring_set">Bring! : connectée</string>
|
||||
<string name="summary_bring_skip">Bring! : non configurée</string>
|
||||
<string name="ble_connecting_to">🔗 Connexion à %s…</string>
|
||||
<string name="ble_connecting">🔗 Connexion en cours…</string>
|
||||
<string name="summary_scale_ok">Balance : %s</string>
|
||||
<string name="summary_scale_warn">Balance : non confirmée</string>
|
||||
</resources>
|
||||
@@ -1,73 +1,104 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">EverShelf Kiosk</string>
|
||||
|
||||
<!-- Stringhe setup wizard -->
|
||||
<string name="setup_enter_url">Inserisci prima un URL</string>
|
||||
<string name="setup_testing">Verifica connessione…</string>
|
||||
<string name="setup_server_found">Server EverShelf trovato e API attiva!</string>
|
||||
<string name="setup_api_not_found">Server raggiungibile ma API EverShelf non trovata. Verifica il percorso.</string>
|
||||
<string name="setup_unreachable">Impossibile raggiungere il server</string>
|
||||
<string name="setup_discover_btn">🔍 Cerca nella rete locale</string> <string name="setup_perms_granted_next">✅ Permessi concessi — Continua →</string> <string name="setup_discovering">Scansione in corso…</string>
|
||||
<string name="setup_discover_btn">🔍 Cerca nella rete locale</string>
|
||||
<string name="setup_perms_granted_next">✅ Permessi concessi — Continua →</string>
|
||||
<string name="setup_discovering">Scansione in corso…</string>
|
||||
<string name="setup_discovering_detail">Ricerca server EverShelf nella rete locale…</string>
|
||||
<string name="setup_discover_not_found">Nessun server EverShelf trovato automaticamente. Inserisci l\'URL manualmente.</string>
|
||||
<string name="setup_discover_not_found">Nessun server EverShelf trovato automaticamente. Inserisci l'URL manualmente.</string>
|
||||
<string name="setup_exit_title">Uscire dalla configurazione?</string>
|
||||
<string name="setup_exit_message">Puoi completare la configurazione più tardi riaprendo l\'app.</string>
|
||||
<string name="setup_exit_message">Puoi completare la configurazione più tardi riaprendo l'app.</string>
|
||||
<string name="setup_exit_confirm">Esci</string>
|
||||
<string name="setup_exit_cancel">Continua</string>
|
||||
|
||||
<!-- Wizard Step 3: Bilancia smart -->
|
||||
<string name="setup_step_back">← Indietro</string>
|
||||
<string name="setup_step_next">Avanti →</string>
|
||||
<string name="setup_skip_later">Lo faccio dopo</string>
|
||||
<string name="setup_confirm">Conferma →</string>
|
||||
<string name="wizard_step3_title">Bilancia Smart</string>
|
||||
<string name="wizard_step3_description">EverShelf Kiosk include un gateway Bluetooth integrato — nessuna app esterna necessaria. Seleziona la tua bilancia qui sotto.</string>
|
||||
<string name="wizard_step3_question">Hai una bilancia smart Bluetooth?</string>
|
||||
<string name="wizard_step3_yes">✅ Sì, ho una bilancia</string>
|
||||
<string name="wizard_step3_no">➡️ No, salta questo passaggio</string>
|
||||
|
||||
<!-- Messaggi stato gateway -->
|
||||
<string name="ble_scanning">🔍 Scansione in corso…</string>
|
||||
<string name="ble_connected">Connesso! Posiziona un oggetto sulla bilancia…</string>
|
||||
<string name="ble_disconnected">Connessione persa. Riprova.</string>
|
||||
<string name="ble_no_scale_found">Nessuna bilancia trovata. Assicurati che sia accesa e vicina, poi riprova.</string>
|
||||
<string name="ble_select_from_list">Seleziona la tua bilancia dall'elenco.</string>
|
||||
<string name="ble_not_confirmed">Bilancia non confermata. Riprova la scansione.</string>
|
||||
<string name="ble_scan_again">🔄 Scansiona di nuovo</string>
|
||||
<string name="ble_weight_received">Peso ricevuto — coincide con quello sulla bilancia?</string>
|
||||
<string name="wizard_gateway_installed">Bilancia salvata ✅</string>
|
||||
<string name="wizard_gateway_installed_detail">Il gateway BLE integrato si collegherà automaticamente all\'avvio.</string>
|
||||
<string name="wizard_gateway_installed_detail">Il gateway BLE integrato si collegherà automaticamente all'avvio.</string>
|
||||
<string name="wizard_gateway_not_installed">Nessuna bilancia selezionata</string>
|
||||
<string name="wizard_gateway_not_installed_detail">Scansiona le bilance BLE nelle vicinanze e tocca una per selezionarla.</string>
|
||||
<string name="wizard_gateway_checking">Scansione bilance BLE in corso…</string>
|
||||
<string name="wizard_gateway_up_to_date">Servizio BLE bilancia pronto.</string>
|
||||
<string name="wizard_gateway_update_available">Bilancia BLE trovata</string>
|
||||
<string name="wizard_gateway_update_detail">Tocca la bilancia nell\'elenco per connettersi.</string>
|
||||
|
||||
<!-- Stati scaricamento / installazione -->
|
||||
<string name="wizard_gateway_update_detail">Tocca la bilancia nell'elenco per connettersi.</string>
|
||||
<string name="install_downloading">Scaricamento in corso…</string>
|
||||
<string name="install_downloading_detail">Attendi, il file viene scaricato.</string>
|
||||
<string name="install_installing">Installazione in corso…</string>
|
||||
<string name="install_confirm_detail">Conferma l\'installazione nel dialog che si è aperto.</string>
|
||||
<string name="install_confirm_detail">Conferma l'installazione nel dialog che si è aperto.</string>
|
||||
<string name="install_success">Installato con successo!</string>
|
||||
<string name="install_success_detail">L\'app è stata aggiornata.</string>
|
||||
<string name="install_success_detail">L'app è stata aggiornata.</string>
|
||||
<string name="install_error_download">Download fallito</string>
|
||||
<string name="install_error_download_detail">Controlla la connessione e riprova.</string>
|
||||
<string name="install_error_install">Installazione fallita</string>
|
||||
<string name="install_perm_detail">Abilita \'Installa app sconosciute\' nelle impostazioni, poi torna qui.</string>
|
||||
<string name="install_perm_detail">Abilita 'Installa app sconosciute' nelle impostazioni, poi torna qui.</string>
|
||||
<string name="install_btn_retry">↩ Riprova</string>
|
||||
|
||||
<!-- Pulsanti -->
|
||||
<string name="btn_back">Indietro</string>
|
||||
<string name="btn_launch">🚀 Avvia EverShelf</string>
|
||||
<string name="btn_launch_no_scale">🚀 Avvia senza bilancia</string>
|
||||
<string name="btn_download_gateway">📥 Installa Scale Gateway</string>
|
||||
<string name="btn_update_gateway">📥 Aggiorna Scale Gateway</string>
|
||||
|
||||
<!-- Verifica raggiungibilità server (step 3 wizard) -->
|
||||
<string name="wizard_server_checking">Verifica connessione server…</string>
|
||||
<string name="wizard_server_ok">Server raggiungibile ✅</string>
|
||||
<string name="wizard_server_ok_detail">Segnalazione errori attiva — i problemi di installazione vengono inviati automaticamente alle GitHub Issues.</string>
|
||||
<string name="wizard_server_error">Server non raggiungibile ⚠️</string>
|
||||
<string name="wizard_server_error_detail">Gli errori non raggiungeranno GitHub Issues. Verifica l\'URL inserito al passaggio 2.</string>
|
||||
<!-- Passo salvaschermo -->
|
||||
<string name="setup_screensaver_title">Salvaschermo</string>
|
||||
<string name="setup_screensaver_desc">Mostra un orologio con fatti utili dopo 5 minuti di inattività. Di default è disattivato (lo schermo resta sempre acceso).</string>
|
||||
<string name="setup_screensaver_toggle_label">Attiva salvaschermo</string>
|
||||
<string name="setup_screensaver_toggle_hint">Se disattivo, lo schermo resta sempre acceso.</string>
|
||||
<string name="wizard_server_error_detail">Gli errori non raggiungeranno GitHub Issues. Verifica l'URL inserito al passaggio 2.</string>
|
||||
<string name="setup_features_title">Funzionalità</string>
|
||||
<string name="setup_features_desc">Attiva le funzioni che vuoi usare. Puoi sempre cambiarle in seguito dalle impostazioni del server.</string>
|
||||
<string name="setup_screensaver_toggle_label">Salvaschermo orologio</string>
|
||||
<string name="setup_screensaver_toggle_hint">Mostra l'overlay orologio dopo 5 min di inattività.</string>
|
||||
<string name="setup_prices_toggle_label">Prezzi lista spesa</string>
|
||||
<string name="setup_prices_toggle_hint">Stima automatica del costo di ogni articolo in lista tramite AI.</string>
|
||||
<string name="setup_mealplan_toggle_label">Piano pasti</string>
|
||||
<string name="setup_mealplan_toggle_hint">Pianifica i pasti della settimana suggerendo ricette basate sulla dispensa.</string>
|
||||
<string name="setup_zerowaste_toggle_label">Suggerimenti zero-waste</string>
|
||||
<string name="setup_zerowaste_toggle_hint">Durante la cottura mostra consigli per riutilizzare scarti (bucce, acqua di cottura, ecc.).</string>
|
||||
<string name="setup_gemini_title">Google Gemini AI</string>
|
||||
<string name="setup_gemini_desc">EverShelf usa Google Gemini AI per suggerimenti di ricette, stime intelligenti della spesa e altro ancora.
|
||||
|
||||
<!-- Riepilogo -->
|
||||
Per abilitarla, inserisci la tua chiave API Gemini gratuita.</string>
|
||||
<string name="setup_gemini_how">Ottieni la chiave gratuita su: aistudio.google.com → "Ottieni chiave API"</string>
|
||||
<string name="setup_gemini_hint">Incolla la chiave API (inizia con AIza…)</string>
|
||||
<string name="setup_bring_title">Bring! Lista della spesa</string>
|
||||
<string name="setup_bring_desc">EverShelf può sincronizzare la lista della spesa con l'app Bring!.
|
||||
|
||||
Inserisci le credenziali del tuo account Bring! per abilitare l'integrazione.</string>
|
||||
<string name="setup_bring_email_hint">Email Bring!</string>
|
||||
<string name="setup_bring_pass_hint">Password Bring!</string>
|
||||
<string name="setup_done_title">Tutto pronto!</string>
|
||||
<string name="setup_done_desc">La configurazione è completa. Premi il pulsante per avviare EverShelf in modalità kiosk.</string>
|
||||
<string name="setup_done_summary_label">RIEPILOGO CONFIGURAZIONE</string>
|
||||
<string name="summary_lang">Lingua</string>
|
||||
<string name="summary_scale_skip">Bilancia: non configurata</string>
|
||||
<string name="summary_screensaver_on">Salvaschermo: attivo</string>
|
||||
<string name="summary_screensaver_off">Schermo sempre acceso (salvaschermo disattivato)</string>
|
||||
</resources>
|
||||
<string name="summary_prices_on">Prezzi lista spesa: abilitati</string>
|
||||
<string name="summary_mealplan_on">Piano pasti: abilitato</string>
|
||||
<string name="summary_zerowaste_on">Suggerimenti zero-waste: abilitati</string>
|
||||
<string name="summary_gemini_set">Gemini AI: abilitata</string>
|
||||
<string name="summary_gemini_skip">Gemini AI: non configurata</string>
|
||||
<string name="summary_bring_set">Bring!: connessa</string>
|
||||
<string name="summary_bring_skip">Bring!: non configurata</string>
|
||||
<string name="ble_connecting_to">🔗 Connessione a %s…</string>
|
||||
<string name="ble_connecting">🔗 Connessione in corso…</string>
|
||||
<string name="summary_scale_ok">Bilancia: %s</string>
|
||||
<string name="summary_scale_warn">Bilancia: da configurare</string>
|
||||
</resources>
|
||||
@@ -1,28 +1,45 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">EverShelf Kiosk</string>
|
||||
|
||||
<!-- Setup wizard strings -->
|
||||
<!-- ── Setup wizard ─────────────────────────────────────────────────── -->
|
||||
<string name="setup_enter_url">Please enter a URL first</string>
|
||||
<string name="setup_testing">Testing connection…</string>
|
||||
<string name="setup_server_found">EverShelf server found and API active!</string>
|
||||
<string name="setup_api_not_found">Server reachable but EverShelf API not found. Check the path.</string>
|
||||
<string name="setup_unreachable">Cannot reach server</string>
|
||||
<string name="setup_discover_btn">🔍 Search local network</string> <string name="setup_perms_granted_next">✅ Permissions granted — Continue →</string> <string name="setup_discovering">Scanning…</string>
|
||||
<string name="setup_discover_btn">🔍 Search local network</string>
|
||||
<string name="setup_perms_granted_next">✅ Permissions granted — Continue →</string>
|
||||
<string name="setup_discovering">Scanning…</string>
|
||||
<string name="setup_discovering_detail">Searching for EverShelf servers on the local network…</string>
|
||||
<string name="setup_discover_not_found">No EverShelf server found automatically. Enter the URL manually.</string>
|
||||
<string name="setup_exit_title">Exit setup?</string>
|
||||
<string name="setup_exit_message">You can complete setup later when you reopen the app.</string>
|
||||
<string name="setup_exit_confirm">Exit</string>
|
||||
<string name="setup_exit_cancel">Continue</string>
|
||||
<string name="setup_step_back">← Back</string>
|
||||
<string name="setup_step_next">Next →</string>
|
||||
<string name="setup_skip_later">Set up later</string>
|
||||
<string name="setup_confirm">Confirm →</string>
|
||||
|
||||
<!-- Wizard Step 3: Smart scale -->
|
||||
<!-- ── Wizard Step 4: Smart scale ───────────────────────────────────── -->
|
||||
<string name="wizard_step3_title">Smart Scale</string>
|
||||
<string name="wizard_step3_description">EverShelf Kiosk includes a built-in Bluetooth gateway — no external app needed. Select your scale below.</string>
|
||||
<string name="wizard_step3_question">Do you have a Bluetooth smart scale?</string>
|
||||
<string name="wizard_step3_yes">✅ Yes, I have a scale</string>
|
||||
<string name="wizard_step3_no">➡️ No, skip this step</string>
|
||||
|
||||
<!-- Gateway status messages -->
|
||||
<!-- BLE scan / test feedback (previously hardcoded) -->
|
||||
<string name="ble_scanning">🔍 Scanning…</string>
|
||||
<string name="ble_connected">Connected! Place an object on the scale…</string>
|
||||
<string name="ble_disconnected">Connection lost. Retry.</string>
|
||||
<string name="ble_no_scale_found">No scale found. Make sure it is on and nearby, then retry.</string>
|
||||
<string name="ble_select_from_list">Select your scale from the list.</string>
|
||||
<string name="ble_not_confirmed">Scale not confirmed. Retry scan.</string>
|
||||
<string name="ble_scan_again">🔄 Scan again</string>
|
||||
<string name="ble_weight_received">Weight received — does it match the display?</string>
|
||||
|
||||
<!-- ── Gateway status messages ──────────────────────────────────────── -->
|
||||
<string name="wizard_gateway_installed">Scale device saved ✅</string>
|
||||
<string name="wizard_gateway_installed_detail">The integrated BLE gateway will connect automatically on startup.</string>
|
||||
<string name="wizard_gateway_not_installed">No scale selected</string>
|
||||
@@ -32,52 +49,76 @@
|
||||
<string name="wizard_gateway_update_available">BLE scale found</string>
|
||||
<string name="wizard_gateway_update_detail">Tap the scale in the list to connect.</string>
|
||||
|
||||
<!-- Install / download progress states -->
|
||||
<string name="install_downloading">Scaricamento in corso…</string>
|
||||
<string name="install_downloading_detail">Attendi, il file viene scaricato.</string>
|
||||
<string name="install_installing">Installazione in corso…</string>
|
||||
<string name="install_confirm_detail">Conferma l\'installazione nel dialog che si è aperto.</string>
|
||||
<string name="install_success">Installato con successo!</string>
|
||||
<string name="install_success_detail">L\'app è stata aggiornata.</string>
|
||||
<string name="install_error_download">Download fallito</string>
|
||||
<string name="install_error_download_detail">Controlla la connessione e riprova.</string>
|
||||
<string name="install_error_install">Installazione fallita</string>
|
||||
<string name="install_perm_detail">Abilita \'Installa app sconosciute\' nelle impostazioni, poi torna qui.</string>
|
||||
<string name="install_btn_retry">↩ Riprova</string>
|
||||
<!-- ── Install / download progress states ───────────────────────────── -->
|
||||
<string name="install_downloading">Downloading…</string>
|
||||
<string name="install_downloading_detail">Please wait, the file is being downloaded.</string>
|
||||
<string name="install_installing">Installing…</string>
|
||||
<string name="install_confirm_detail">Confirm the installation in the dialog that has opened.</string>
|
||||
<string name="install_success">Installed successfully!</string>
|
||||
<string name="install_success_detail">The app has been updated.</string>
|
||||
<string name="install_error_download">Download failed</string>
|
||||
<string name="install_error_download_detail">Check your connection and try again.</string>
|
||||
<string name="install_error_install">Installation failed</string>
|
||||
<string name="install_perm_detail">Enable \'Install unknown apps\' in settings, then come back here.</string>
|
||||
<string name="install_btn_retry">↩ Retry</string>
|
||||
|
||||
<!-- Buttons -->
|
||||
<!-- ── Buttons ───────────────────────────────────────────────────────── -->
|
||||
<string name="btn_back">Back</string>
|
||||
<string name="btn_launch">🚀 Launch EverShelf</string>
|
||||
<string name="btn_launch_no_scale">🚀 Launch without scale</string>
|
||||
<string name="btn_download_gateway">📥 Install Scale Gateway</string>
|
||||
<string name="btn_update_gateway">📥 Update Scale Gateway</string>
|
||||
|
||||
<!-- Server reachability check (wizard step 3) -->
|
||||
<!-- ── Server reachability check ────────────────────────────────────── -->
|
||||
<string name="wizard_server_checking">Checking server connection…</string>
|
||||
<string name="wizard_server_ok">Server reachable ✅</string>
|
||||
<string name="wizard_server_ok_detail">Error reporting is active — install failures will be sent to GitHub Issues automatically.</string>
|
||||
<string name="wizard_server_error">Server not reachable ⚠️</string>
|
||||
<string name="wizard_server_error_detail">Install errors won\'t reach GitHub Issues. Check the URL entered in step 2.</string>
|
||||
<!-- Features step (step 5) -->
|
||||
<string name="setup_features_title">Funzionalità</string>
|
||||
<string name="setup_features_desc">Attiva le funzioni che vuoi usare. Puoi sempre cambiarle in seguito dalle impostazioni del server.</string>
|
||||
<string name="setup_screensaver_title">Salvaschermo in-app</string>
|
||||
<string name="setup_screensaver_desc">Shows a clock with useful facts after 5 minutes of inactivity. Off by default (screen stays always on).</string>
|
||||
<string name="setup_screensaver_toggle_label">Salvaschermo orologio</string>
|
||||
<string name="setup_screensaver_toggle_hint">Mostra l\'overlay orologio dopo 5 min di inattività.</string>
|
||||
<string name="setup_prices_toggle_label">Prezzi lista spesa</string>
|
||||
<string name="setup_prices_toggle_hint">Stima automatica del costo di ogni articolo in lista tramite AI.</string>
|
||||
<string name="setup_mealplan_toggle_label">Piano pasti</string>
|
||||
<string name="setup_mealplan_toggle_hint">Pianifica i pasti della settimana suggerendo ricette basate sulla dispensa.</string>
|
||||
<string name="setup_zerowaste_toggle_label">Suggerimenti zero-waste</string>
|
||||
<string name="setup_zerowaste_toggle_hint">Durante la cottura mostra consigli per riutilizzare scarti (bucce, acqua di cottura, ecc.).</string>
|
||||
|
||||
<!-- Summary -->
|
||||
<!-- ── Step 5 — Features ─────────────────────────────────────────────── -->
|
||||
<string name="setup_features_title">Features</string>
|
||||
<string name="setup_features_desc">Enable the features you want to use. You can always change them later in the server settings.</string>
|
||||
<string name="setup_screensaver_toggle_label">Clock screensaver</string>
|
||||
<string name="setup_screensaver_toggle_hint">Shows a clock overlay after 5 min of inactivity.</string>
|
||||
<string name="setup_prices_toggle_label">Shopping list prices</string>
|
||||
<string name="setup_prices_toggle_hint">AI-powered automatic cost estimate for each item in the list.</string>
|
||||
<string name="setup_mealplan_toggle_label">Meal plan</string>
|
||||
<string name="setup_mealplan_toggle_hint">Plan the week\'s meals with recipes based on your pantry.</string>
|
||||
<string name="setup_zerowaste_toggle_label">Zero-waste tips</string>
|
||||
<string name="setup_zerowaste_toggle_hint">Show tips for reusing scraps (peels, cooking water, etc.) while cooking.</string>
|
||||
|
||||
<!-- ── Step 6 — Gemini AI key ─────────────────────────────────────────── -->
|
||||
<string name="setup_gemini_title">Google Gemini AI</string>
|
||||
<string name="setup_gemini_desc">EverShelf uses Google Gemini AI for recipe suggestions, smart shopping estimates and more.\n\nTo enable it, enter your free Gemini API key below.</string>
|
||||
<string name="setup_gemini_how">Get your free key at: aistudio.google.com → \"Get API key\"</string>
|
||||
<string name="setup_gemini_hint">Paste your API key here (starts with AIza…)</string>
|
||||
|
||||
<!-- ── Step 7 — Bring! credentials ──────────────────────────────────── -->
|
||||
<string name="setup_bring_title">Bring! Shopping List</string>
|
||||
<string name="setup_bring_desc">EverShelf can sync your shopping list with the Bring! app.\n\nEnter your Bring! account credentials to enable this integration.</string>
|
||||
<string name="setup_bring_email_hint">Bring! email address</string>
|
||||
<string name="setup_bring_pass_hint">Bring! password</string>
|
||||
|
||||
<!-- ── Step 8 — Done ─────────────────────────────────────────────────── -->
|
||||
<string name="setup_done_title">All set!</string>
|
||||
<string name="setup_done_desc">Setup is complete. Press the button below to launch EverShelf in kiosk mode.</string>
|
||||
<string name="setup_done_summary_label">CONFIGURATION SUMMARY</string>
|
||||
|
||||
<!-- ── Summary lines ─────────────────────────────────────────────────── -->
|
||||
<string name="summary_lang">Language</string>
|
||||
<string name="summary_scale_skip">Scale: not configured</string>
|
||||
<string name="summary_screensaver_on">Screensaver: abilitato</string>
|
||||
<string name="summary_screensaver_off">Screensaver: disabilitato</string>
|
||||
<string name="summary_prices_on">Prezzi lista spesa: abilitati</string>
|
||||
<string name="summary_mealplan_on">Piano pasti: abilitato</string>
|
||||
<string name="summary_zerowaste_on">Suggerimenti zero-waste: abilitati</string>
|
||||
<string name="summary_screensaver_on">Screensaver: enabled</string>
|
||||
<string name="summary_screensaver_off">Screen always on (screensaver disabled)</string>
|
||||
<string name="summary_prices_on">Shopping list prices: enabled</string>
|
||||
<string name="summary_mealplan_on">Meal plan: enabled</string>
|
||||
<string name="summary_zerowaste_on">Zero-waste tips: enabled</string>
|
||||
<string name="summary_gemini_set">Gemini AI: enabled</string>
|
||||
<string name="summary_gemini_skip">Gemini AI: not configured</string>
|
||||
<string name="summary_bring_set">Bring!: connected</string>
|
||||
<string name="summary_bring_skip">Bring!: not configured</string>
|
||||
<string name="ble_connecting_to">🔗 Connecting to %s…</string>
|
||||
<string name="ble_connecting">🔗 Connecting…</string>
|
||||
<string name="summary_scale_ok">Scale: %s</string>
|
||||
<string name="summary_scale_warn">Scale: not confirmed</string>
|
||||
</resources>
|
||||
|
||||
+124
-85
@@ -59,12 +59,12 @@
|
||||
<div class="preloader-bar-track">
|
||||
<div id="preloader-bar" class="preloader-bar"></div>
|
||||
</div>
|
||||
<div id="preloader-check-label" class="preloader-check-label"> </div>
|
||||
<div id="check-ticker" class="check-ticker"></div>
|
||||
</div>
|
||||
<div id="preloader-warnings" class="preloader-warnings" style="display:none"></div>
|
||||
<div id="preloader-error-msg" class="preloader-error-msg" style="display:none"></div>
|
||||
<button id="preloader-retry-btn" class="preloader-retry-btn" style="display:none" onclick="_startupRetry()">🔄 <span data-i18n="startup.retry">Riprova</span></button>
|
||||
<span class="app-preloader-version" id="preloader-version">v1.7.22</span>
|
||||
<span class="app-preloader-version" id="preloader-version">v1.7.23</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
<!-- Title — left-aligned; grows to fill space -->
|
||||
<div class="header-title-wrap">
|
||||
<h1 class="header-title" onclick="showPage('dashboard')">
|
||||
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.22</span>
|
||||
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.23</span>
|
||||
</h1>
|
||||
<!-- Update badge — shown alongside title, never replaces it -->
|
||||
<span class="header-update-badge" id="header-update-badge" style="display:none"></span>
|
||||
@@ -831,7 +831,8 @@
|
||||
<h2 data-i18n="settings.title">⚙️ Configurazione</h2>
|
||||
</div>
|
||||
<div class="settings-tabs">
|
||||
<button class="settings-tab active" onclick="switchSettingsTab(this, 'tab-api')" data-tab="tab-api" title="API Keys">🔑</button>
|
||||
<button class="settings-tab active" onclick="switchSettingsTab(this, 'tab-general')" data-tab="tab-general" data-i18n-title="settings.tab_general" title="Generali">⚙️</button>
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-api')" data-tab="tab-api" title="API Keys">🔑</button>
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-bring')" data-tab="tab-bring" title="Bring!">🛒</button>
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-recipe')" data-tab="tab-recipe" title="Ricette">🍳</button>
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-mealplan')" data-tab="tab-mealplan" title="Piano Settimanale">📅</button>
|
||||
@@ -839,12 +840,108 @@
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-camera')" data-tab="tab-camera" title="Fotocamera">📷</button>
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-security')" data-tab="tab-security" title="Sicurezza">🔒</button>
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-tts')" data-tab="tab-tts" title="Voce (TTS)" data-i18n-title="settings.tab_tts">🔊</button>
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-language')" data-tab="tab-language" title="Lingua" data-i18n-title="settings.tab_language">🌐</button>
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-scale')" data-tab="tab-scale" title="Bilancia Smart" data-i18n-title="settings.scale.tab">⚖️</button>
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-info'); _loadInfoTab();" data-tab="tab-info" data-i18n-title="settings.info.tab" title="Info">ℹ️</button>
|
||||
</div>
|
||||
<div class="settings-panels">
|
||||
<!-- Generali Tab -->
|
||||
<div class="settings-panel active" id="tab-general">
|
||||
<div class="settings-card">
|
||||
<h4 data-i18n="settings.language.title">🌐 Lingua / Language</h4>
|
||||
<p class="settings-hint" data-i18n="settings.language.hint">Seleziona la lingua dell'interfaccia. Select the interface language.</p>
|
||||
<div class="form-group">
|
||||
<select id="setting-language" class="form-input" onchange="changeLanguage(this.value)">
|
||||
</select>
|
||||
<p class="settings-hint mt-2" data-i18n="settings.language.restart_notice">La pagina verrà ricaricata per applicare la nuova lingua.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-card">
|
||||
<h4 data-i18n="settings.info.currency_title">💱 Valuta</h4>
|
||||
<p class="settings-hint" data-i18n="settings.info.currency_hint">La valuta usata per tutti i costi e i prezzi nell'app.</p>
|
||||
<div class="form-group" style="margin-top:8px">
|
||||
<select id="setting-price-currency" class="form-input">
|
||||
<option value="EUR">€ Euro (EUR)</option>
|
||||
<option value="USD">$ Dollaro USA (USD)</option>
|
||||
<option value="GBP">£ Sterlina (GBP)</option>
|
||||
<option value="CHF">CHF Franco Svizzero</option>
|
||||
<option value="CAD">CA$ Dollaro Canadese</option>
|
||||
<option value="AUD">A$ Dollaro Australiano</option>
|
||||
<option value="BRL">R$ Real Brasiliano</option>
|
||||
<option value="JPY">¥ Yen Giapponese</option>
|
||||
<option value="SEK">kr Corona Svedese</option>
|
||||
<option value="NOK">kr Corona Norvegese</option>
|
||||
<option value="DKK">kr Corona Danese</option>
|
||||
<option value="PLN">zł Zloty Polacco</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top:10px">
|
||||
<button class="btn btn-primary" onclick="saveSettings()" data-i18n="btn.save">Salva</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-card">
|
||||
<h4 data-i18n="settings.theme.title">🌙 Tema / Aspetto</h4>
|
||||
<p class="settings-hint" data-i18n="settings.theme.hint">Scegli il tema dell'interfaccia.</p>
|
||||
<div class="form-group">
|
||||
<select id="setting-dark-mode" class="form-input" onchange="_setThemeMode(this.value)">
|
||||
<option value="off" data-i18n="settings.theme.off">☀️ Chiaro</option>
|
||||
<option value="auto" selected data-i18n="settings.theme.auto">🔄 Automatico (orario)</option>
|
||||
<option value="on" data-i18n="settings.theme.on">🌙 Scuro</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-card">
|
||||
<h4 data-i18n="settings.screensaver.card_title">🌙 Salvaschermo</h4>
|
||||
<p class="settings-hint" data-i18n="settings.screensaver.card_hint">Mostra un orologio con fatti utili dopo un periodo di inattività. Di default è disattivato.</p>
|
||||
<div class="form-group">
|
||||
<label class="toggle-row">
|
||||
<span data-i18n="settings.screensaver.label">Attiva salvaschermo</span>
|
||||
<span class="toggle-switch">
|
||||
<input type="checkbox" id="setting-screensaver-enabled">
|
||||
<span class="toggle-slider"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group" id="screensaver-timeout-row" style="margin-top:10px">
|
||||
<label for="setting-screensaver-timeout" style="font-size:0.85rem;font-weight:600;color:var(--text-secondary)" data-i18n="settings.screensaver.start_after">⏱️ Avvia dopo</label>
|
||||
<select id="setting-screensaver-timeout" class="form-input" style="margin-top:6px;max-width:200px">
|
||||
<option value="1" data-i18n="settings.screensaver.timeout_1">1 minuto</option>
|
||||
<option value="2" data-i18n="settings.screensaver.timeout_2">2 minuti</option>
|
||||
<option value="5" selected data-i18n="settings.screensaver.timeout_5">5 minuti</option>
|
||||
<option value="10" data-i18n="settings.screensaver.timeout_10">10 minuti</option>
|
||||
<option value="15" data-i18n="settings.screensaver.timeout_15">15 minuti</option>
|
||||
<option value="30" data-i18n="settings.screensaver.timeout_30">30 minuti</option>
|
||||
<option value="60" data-i18n="settings.screensaver.timeout_60">1 ora</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-card">
|
||||
<h4 data-i18n="settings.zerowaste.card_title">♻️ Suggerimenti zero-waste</h4>
|
||||
<p class="settings-hint" data-i18n="settings.zerowaste.card_hint">Durante la cottura, mostra consigli su come riutilizzare gli scarti prodotti in ogni passo (bucce, acqua di cottura, ecc.). Disattivo per impostazione predefinita.</p>
|
||||
<div class="form-group">
|
||||
<label class="toggle-row">
|
||||
<span data-i18n="settings.zerowaste.label">Mostra suggerimenti durante la cottura</span>
|
||||
<span class="toggle-switch">
|
||||
<input type="checkbox" id="setting-zerowaste-tips">
|
||||
<span class="toggle-slider"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-card">
|
||||
<h4 data-i18n="export.title">📤 Esporta inventario</h4>
|
||||
<p class="settings-hint" data-i18n="export.hint">Scarica l'inventario corrente in CSV o apri una versione stampabile (PDF).</p>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<button class="btn btn-outline" onclick="exportInventory('csv')" style="flex:1;min-width:120px">
|
||||
📊 <span data-i18n="export.btn_csv">CSV</span>
|
||||
</button>
|
||||
<button class="btn btn-outline" onclick="exportInventory('html')" style="flex:1;min-width:120px">
|
||||
🖨️ <span data-i18n="export.btn_pdf">PDF / Stampa</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- API Keys Tab -->
|
||||
<div class="settings-panel active" id="tab-api">
|
||||
<div class="settings-panel" id="tab-api">
|
||||
<div class="settings-card">
|
||||
<h4 data-i18n="settings.gemini.title">🤖 Google Gemini AI</h4>
|
||||
<p class="settings-hint" data-i18n="settings.gemini.hint">Chiave API per identificazione prodotti, scadenze e ricette.</p>
|
||||
@@ -903,23 +1000,6 @@
|
||||
<option value="Japan">🇯🇵 Giappone</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.price.currency_label">💱 Valuta</label>
|
||||
<select id="setting-price-currency" class="form-input">
|
||||
<option value="EUR">€ Euro (EUR)</option>
|
||||
<option value="USD">$ Dollaro USA (USD)</option>
|
||||
<option value="GBP">£ Sterlina (GBP)</option>
|
||||
<option value="CHF">CHF Franco Svizzero</option>
|
||||
<option value="CAD">CA$ Dollaro Canadese</option>
|
||||
<option value="AUD">A$ Dollaro Australiano</option>
|
||||
<option value="BRL">R$ Real Brasiliano</option>
|
||||
<option value="JPY">¥ Yen Giapponese</option>
|
||||
<option value="SEK">kr Corona Svedese</option>
|
||||
<option value="NOK">kr Corona Norvegese</option>
|
||||
<option value="DKK">kr Corona Danese</option>
|
||||
<option value="PLN">zł Zloty Polacco</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.price.update_label">🔄 Aggiorna prezzi ogni</label>
|
||||
<div class="qty-control">
|
||||
@@ -1261,77 +1341,36 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- Language Tab -->
|
||||
<div class="settings-panel" id="tab-language">
|
||||
|
||||
<!-- Info Tab -->
|
||||
<div class="settings-panel" id="tab-info">
|
||||
<!-- Gemini AI Usage card -->
|
||||
<div class="settings-card">
|
||||
<h4 data-i18n="settings.language.title">🌐 Lingua / Language</h4>
|
||||
<p class="settings-hint" data-i18n="settings.language.hint">Seleziona la lingua dell'interfaccia. Select the interface language.</p>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.language.label">🌐 Lingua</label>
|
||||
<select id="setting-language" class="form-input" onchange="changeLanguage(this.value)">
|
||||
</select>
|
||||
<p class="settings-hint mt-2" data-i18n="settings.language.restart_notice">La pagina verrà ricaricata per applicare la nuova lingua.</p>
|
||||
<h4 data-i18n="settings.info.ai_title">Gemini AI — Token Usage</h4>
|
||||
<p class="settings-hint info-ai-subtitle" data-i18n="settings.info.ai_overview">Utilizzo AI, inventario e sistema</p>
|
||||
<div id="info-ai-content" style="margin-top:10px">
|
||||
<p class="settings-hint" data-i18n="settings.info.loading">Caricamento…</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Inventory card -->
|
||||
<div class="settings-card">
|
||||
<h4 data-i18n="settings.screensaver.card_title">🌙 Salvaschermo</h4>
|
||||
<p class="settings-hint" data-i18n="settings.screensaver.card_hint">Mostra un orologio con fatti utili dopo un periodo di inattività. Di default è disattivato.</p>
|
||||
<div class="form-group">
|
||||
<label class="toggle-row">
|
||||
<span data-i18n="settings.screensaver.label">Attiva salvaschermo</span>
|
||||
<span class="toggle-switch">
|
||||
<input type="checkbox" id="setting-screensaver-enabled">
|
||||
<span class="toggle-slider"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group" id="screensaver-timeout-row" style="margin-top:10px">
|
||||
<label for="setting-screensaver-timeout" style="font-size:0.85rem;font-weight:600;color:var(--text-secondary)" data-i18n="settings.screensaver.start_after">⏱️ Avvia dopo</label>
|
||||
<select id="setting-screensaver-timeout" class="form-control" style="margin-top:6px;max-width:200px">
|
||||
<option value="1" data-i18n="settings.screensaver.timeout_1">1 minuto</option>
|
||||
<option value="2" data-i18n="settings.screensaver.timeout_2">2 minuti</option>
|
||||
<option value="5" selected data-i18n="settings.screensaver.timeout_5">5 minuti</option>
|
||||
<option value="10" data-i18n="settings.screensaver.timeout_10">10 minuti</option>
|
||||
<option value="15" data-i18n="settings.screensaver.timeout_15">15 minuti</option>
|
||||
<option value="30" data-i18n="settings.screensaver.timeout_30">30 minuti</option>
|
||||
<option value="60" data-i18n="settings.screensaver.timeout_60">1 ora</option>
|
||||
</select>
|
||||
<h4 data-i18n="settings.info.inv_title">Inventario</h4>
|
||||
<div id="info-inv-content" style="margin-top:10px">
|
||||
<p class="settings-hint" data-i18n="settings.info.loading">Caricamento…</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Activity card -->
|
||||
<div class="settings-card">
|
||||
<h4 data-i18n="settings.theme.title">🌙 Tema / Aspetto</h4>
|
||||
<p class="settings-hint" data-i18n="settings.theme.hint">Scegli il tema dell'interfaccia.</p>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.theme.label">🌙 Tema</label>
|
||||
<select id="setting-dark-mode" class="form-input" onchange="_setThemeMode(this.value)">
|
||||
<option value="off" data-i18n="settings.theme.off">☀️ Chiaro</option>
|
||||
<option value="auto" selected data-i18n="settings.theme.auto">🔄 Automatico (sistema)</option>
|
||||
<option value="on" data-i18n="settings.theme.on">🌙 Scuro</option>
|
||||
</select>
|
||||
<h4 data-i18n="settings.info.act_title">Attività del mese</h4>
|
||||
<div id="info-act-content" style="margin-top:10px">
|
||||
<p class="settings-hint" data-i18n="settings.info.loading">Caricamento…</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- System Info card -->
|
||||
<div class="settings-card">
|
||||
<h4 data-i18n="settings.zerowaste.card_title">♻️ Suggerimenti zero-waste</h4>
|
||||
<p class="settings-hint" data-i18n="settings.zerowaste.card_hint">Durante la cottura, mostra consigli su come riutilizzare gli scarti prodotti in ogni passo (bucce, acqua di cottura, ecc.). Disattivo per impostazione predefinita.</p>
|
||||
<div class="form-group">
|
||||
<label class="toggle-row">
|
||||
<span data-i18n="settings.zerowaste.label">Mostra suggerimenti durante la cottura</span>
|
||||
<span class="toggle-switch">
|
||||
<input type="checkbox" id="setting-zerowaste-tips">
|
||||
<span class="toggle-slider"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-card">
|
||||
<h4 data-i18n="export.title">📤 Esporta inventario</h4>
|
||||
<p class="settings-hint" data-i18n="export.hint">Scarica l'inventario corrente in CSV o apri una versione stampabile (PDF).</p>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<button class="btn btn-outline" onclick="exportInventory('csv')" style="flex:1;min-width:120px">
|
||||
📊 <span data-i18n="export.btn_csv">CSV</span>
|
||||
</button>
|
||||
<button class="btn btn-outline" onclick="exportInventory('html')" style="flex:1;min-width:120px">
|
||||
🖨️ <span data-i18n="export.btn_pdf">PDF / Stampa</span>
|
||||
</button>
|
||||
<h4 data-i18n="settings.info.system_title">Sistema</h4>
|
||||
<div id="info-system-content" style="margin-top:10px">
|
||||
<p class="settings-hint" data-i18n="settings.info.loading">Caricamento…</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1613,6 +1652,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="assets/js/app.js?v=20260517a"></script>
|
||||
<script src="assets/js/app.js?v=20260518c"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# logs/
|
||||
|
||||
This directory contains EverShelf runtime log files.
|
||||
|
||||
Files are generated automatically by `api/logger.php` and follow the naming pattern:
|
||||
|
||||
```
|
||||
evershelf_YYYY-MM-DD_HH.log
|
||||
```
|
||||
|
||||
The directory is tracked in git (via this README) but `.log` files are ignored via `.gitignore`.
|
||||
|
||||
## Configuration (`.env`)
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `LOG_LEVEL` | `INFO` | Minimum log level: `DEBUG`, `INFO`, `WARN`, `ERROR` |
|
||||
| `LOG_ROTATE_HOURS` | `24` | Hours per file before rotating |
|
||||
| `LOG_MAX_FILES` | `14` | Maximum number of rotated files to keep |
|
||||
|
||||
## Format
|
||||
|
||||
```
|
||||
[2026-05-18 14:23:11] [INFO ] [rid=a1b2c3d4] [action] Message {"ctx":"value"}
|
||||
```
|
||||
|
||||
## Remote inspection
|
||||
|
||||
```
|
||||
GET /api/?action=get_logs&lines=100&level=WARN
|
||||
```
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "EverShelf",
|
||||
"short_name": "EverShelf",
|
||||
"description": "Gestione completa della dispensa di casa con scansione barcode",
|
||||
"version": "1.7.22",
|
||||
"version": "1.7.23",
|
||||
"start_url": "/evershelf/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f0f4e8",
|
||||
|
||||
+50
-6
@@ -113,6 +113,8 @@
|
||||
"banner_expired_action_finished": "Habe ich verbraucht!",
|
||||
"banner_expired_action_throw": "Habe ich weggeworfen",
|
||||
"banner_expired_action_edit": "Datum korrigieren",
|
||||
"banner_expired_action_modify": "Bearbeiten",
|
||||
"banner_expired_action_vacuum": "Vakuumieren",
|
||||
"banner_anomaly_action_edit": "Bestand korrigieren",
|
||||
"banner_anomaly_action_dismiss": "Menge ist korrekt",
|
||||
"banner_no_expiry_title": "Ablaufdatum fehlt: {name}",
|
||||
@@ -217,7 +219,7 @@
|
||||
"title": "Was möchtest du tun?",
|
||||
"add_btn": "📥 HINZUFÜGEN",
|
||||
"add_sub": "in Vorrat/Kühlschrank",
|
||||
"use_btn": "📤 VERWENDEN / VERBRAUCHEN",
|
||||
"use_btn": "VERWENDEN",
|
||||
"use_sub": "aus Vorrat/Kühlschrank",
|
||||
"have_title": "📦 Schon auf Lager!",
|
||||
"add_more_sub": "weitere Menge",
|
||||
@@ -533,7 +535,7 @@
|
||||
"prev": "◀ Zurück",
|
||||
"next": "Weiter ▶",
|
||||
"ingredient_used": "✔️ Abgezogen",
|
||||
"ingredient_use_btn": "📦 Verwenden",
|
||||
"ingredient_use_btn": "Usa",
|
||||
"ingredient_deduct_title": "Von Vorrat abziehen",
|
||||
"timer_expired_tts": "Timer {label} abgelaufen!",
|
||||
"timer_warning_tts": "Achtung! {label}: noch 10 Sekunden!",
|
||||
@@ -753,13 +755,54 @@
|
||||
"label": "🌙 Design",
|
||||
"off": "☀️ Hell",
|
||||
"on": "🌙 Dunkel",
|
||||
"auto": "🔄 Automatisch (System)"
|
||||
"auto": "🔄 Automatisch (Tageszeit)"
|
||||
},
|
||||
"zerowaste": {
|
||||
"card_title": "♻️ Zero-Waste-Tipps",
|
||||
"card_hint": "Zeige während des Kochens Tipps zur Wiederverwendung von Abfällen (Schalen, Kochwasser usw.). Standardmäßig deaktiviert.",
|
||||
"label": "Tipps beim Kochen anzeigen"
|
||||
}
|
||||
},
|
||||
"info": {
|
||||
"tab": "Info",
|
||||
"ai_title": "Gemini AI — Token-Nutzung",
|
||||
"ai_hint": "Monatlicher Verbrauch und geschätzte Kosten für den aktuellen API-Schlüssel.",
|
||||
"loading": "Laden…",
|
||||
"total_tokens": "Token gesamt",
|
||||
"est_cost": "Gesch. Kosten",
|
||||
"input_tok": "Eingabe-Token",
|
||||
"output_tok": "Ausgabe-Token",
|
||||
"ai_calls": "Aufrufe",
|
||||
"by_action": "Aufschlüsselung nach Funktion",
|
||||
"by_model": "Aufschlüsselung nach Modell",
|
||||
"pricing_note": "Gemini Referenzpreise: 2.5-flash $0.15/M in · $0.60/M out — 2.0-flash $0.10/M in · $0.40/M out.",
|
||||
"system_title": "System",
|
||||
"db_size": "Datenbank",
|
||||
"log_size": "Protokolle",
|
||||
"log_level": "Log-Level",
|
||||
"ai_overview": "KI-Nutzungsübersicht, Inventar und Systemstatus",
|
||||
"calls_unit": "Aufrufe",
|
||||
"inv_title": "Inventar",
|
||||
"inv_active": "Aktiv",
|
||||
"inv_products": "Produkte gesamt",
|
||||
"inv_expiring": "Ablaufend (7T)",
|
||||
"inv_expired": "Abgelaufen",
|
||||
"inv_finished": "Leer",
|
||||
"act_title": "Monatliche Aktivität",
|
||||
"act_tx_month": "Bewegungen",
|
||||
"act_restock": "Einkäufe",
|
||||
"act_use": "Verbrauch",
|
||||
"act_new_products": "Neue Produkte",
|
||||
"act_tx_year": "Jährl. Bewegungen",
|
||||
"price_cache": "Preiscache",
|
||||
"cache_entries": "Produkte",
|
||||
"last_backup": "Letztes Backup",
|
||||
"bring_days": "Token läuft in {n} Tagen ab",
|
||||
"bring_expired": "Token abgelaufen",
|
||||
"year_label": "Jahr {year}",
|
||||
"currency_title": "Währung",
|
||||
"currency_hint": "Die Währung, die für alle Kosten und Preise in der App verwendet wird."
|
||||
},
|
||||
"tab_general": "Allgemein"
|
||||
},
|
||||
"expiry": {
|
||||
"today": "HEUTE",
|
||||
@@ -831,6 +874,7 @@
|
||||
"thrown_away": "🗑️ {name} weggeworfen!",
|
||||
"thrown_away_partial": "🗑️ {qty} {unit} von {name} weggeworfen",
|
||||
"finished_all": "📤 {name} aufgebraucht!",
|
||||
"vacuum_sealed": "{name} als vakuumversiegelt gespeichert",
|
||||
"product_finished_confirmed": "✅ Entfernt — wieder hinzufügen, wenn du nachkaufst",
|
||||
"appliance_added": "Gerät hinzugefügt",
|
||||
"item_added": "{name} hinzugefügt"
|
||||
@@ -1007,8 +1051,8 @@
|
||||
"thing_rest": "den Rest",
|
||||
"stay_btn": "Nein, bleibt in {location}",
|
||||
"moved_toast": "📦 Offene Packung bewegt nach {location}",
|
||||
"vacuum_restore": "🫙 Vakuum wiederherstellen",
|
||||
"vacuum_seal_rest": "🔒 Rest vakuumieren"
|
||||
"vacuum_restore": "Vakuum wiederherstellen",
|
||||
"vacuum_seal_rest": "Rest vakuumieren"
|
||||
},
|
||||
"nova": {
|
||||
"1": "Unverarbeitet",
|
||||
|
||||
+50
-6
@@ -113,6 +113,8 @@
|
||||
"banner_expired_action_finished": "I finished it!",
|
||||
"banner_expired_action_throw": "I threw it away",
|
||||
"banner_expired_action_edit": "Fix date",
|
||||
"banner_expired_action_modify": "Edit",
|
||||
"banner_expired_action_vacuum": "Put in vacuum seal",
|
||||
"banner_anomaly_action_edit": "Fix inventory",
|
||||
"banner_anomaly_action_dismiss": "Quantity is correct",
|
||||
"banner_no_expiry_title": "Missing expiry: {name}",
|
||||
@@ -217,7 +219,7 @@
|
||||
"title": "What do you want to do?",
|
||||
"add_btn": "📥 ADD",
|
||||
"add_sub": "to pantry/fridge",
|
||||
"use_btn": "📤 USE / CONSUME",
|
||||
"use_btn": "USE",
|
||||
"use_sub": "from pantry/fridge",
|
||||
"have_title": "📦 Already in stock!",
|
||||
"add_more_sub": "add more",
|
||||
@@ -533,7 +535,7 @@
|
||||
"prev": "◀ Previous",
|
||||
"next": "Next ▶",
|
||||
"ingredient_used": "✔️ Deducted",
|
||||
"ingredient_use_btn": "📦 Use",
|
||||
"ingredient_use_btn": "Use",
|
||||
"ingredient_deduct_title": "Deduct from pantry",
|
||||
"timer_expired_tts": "Timer {label} expired!",
|
||||
"timer_warning_tts": "Heads up! {label}: 10 seconds left!",
|
||||
@@ -753,13 +755,54 @@
|
||||
"label": "🌙 Theme",
|
||||
"off": "☀️ Light",
|
||||
"on": "🌙 Dark",
|
||||
"auto": "🔄 Auto (system)"
|
||||
"auto": "🔄 Automatic (time of day)"
|
||||
},
|
||||
"zerowaste": {
|
||||
"card_title": "♻️ Zero-waste tips",
|
||||
"card_hint": "During cooking, show tips on how to reuse scraps generated in each step (peels, cooking water, etc.). Disabled by default.",
|
||||
"label": "Show tips during cooking"
|
||||
}
|
||||
},
|
||||
"info": {
|
||||
"tab": "Info",
|
||||
"ai_title": "Gemini AI — Token Usage",
|
||||
"ai_hint": "Monthly consumption and estimated cost for the current API key.",
|
||||
"loading": "Loading…",
|
||||
"total_tokens": "Total tokens",
|
||||
"est_cost": "Est. cost",
|
||||
"input_tok": "Input tokens",
|
||||
"output_tok": "Output tokens",
|
||||
"ai_calls": "Calls",
|
||||
"by_action": "Breakdown by function",
|
||||
"by_model": "Breakdown by model",
|
||||
"pricing_note": "Gemini reference pricing: 2.5-flash $0.15/M in · $0.60/M out — 2.0-flash $0.10/M in · $0.40/M out.",
|
||||
"system_title": "System",
|
||||
"db_size": "Database",
|
||||
"log_size": "Logs",
|
||||
"log_level": "Log level",
|
||||
"ai_overview": "AI usage overview, inventory and system status",
|
||||
"calls_unit": "calls",
|
||||
"inv_title": "Inventory",
|
||||
"inv_active": "Active",
|
||||
"inv_products": "Total products",
|
||||
"inv_expiring": "Expiring (7d)",
|
||||
"inv_expired": "Expired",
|
||||
"inv_finished": "Finished",
|
||||
"act_title": "Monthly activity",
|
||||
"act_tx_month": "Movements",
|
||||
"act_restock": "Restocks",
|
||||
"act_use": "Usages",
|
||||
"act_new_products": "New products",
|
||||
"act_tx_year": "Yearly movements",
|
||||
"price_cache": "Price cache",
|
||||
"cache_entries": "products",
|
||||
"last_backup": "Last backup",
|
||||
"bring_days": "token expires in {n} days",
|
||||
"bring_expired": "token expired",
|
||||
"year_label": "Year {year}",
|
||||
"currency_title": "Currency",
|
||||
"currency_hint": "The currency used for all costs and prices in the app."
|
||||
},
|
||||
"tab_general": "General"
|
||||
},
|
||||
"expiry": {
|
||||
"today": "TODAY",
|
||||
@@ -831,6 +874,7 @@
|
||||
"thrown_away": "🗑️ {name} thrown away!",
|
||||
"thrown_away_partial": "🗑️ Thrown away {qty} {unit} of {name}",
|
||||
"finished_all": "📤 {name} finished!",
|
||||
"vacuum_sealed": "{name} saved as vacuum sealed",
|
||||
"product_finished_confirmed": "✅ Removed — add it again when you restock",
|
||||
"appliance_added": "Appliance added",
|
||||
"item_added": "{name} added"
|
||||
@@ -1007,8 +1051,8 @@
|
||||
"thing_rest": "rest",
|
||||
"stay_btn": "No, stay in {location}",
|
||||
"moved_toast": "📦 Opened package moved to {location}",
|
||||
"vacuum_restore": "🫙 Restore vacuum sealed",
|
||||
"vacuum_seal_rest": "🔒 Vacuum seal the rest"
|
||||
"vacuum_restore": "Restore vacuum sealed",
|
||||
"vacuum_seal_rest": "Vacuum seal the rest"
|
||||
},
|
||||
"nova": {
|
||||
"1": "Unprocessed",
|
||||
|
||||
+50
-6
@@ -113,6 +113,8 @@
|
||||
"banner_expired_action_finished": "L'ho finito!",
|
||||
"banner_expired_action_throw": "L'ho buttato",
|
||||
"banner_expired_action_edit": "Correggi data",
|
||||
"banner_expired_action_modify": "Modifica",
|
||||
"banner_expired_action_vacuum": "Metti sottovuoto",
|
||||
"banner_anomaly_action_edit": "Correggi inventario",
|
||||
"banner_anomaly_action_dismiss": "La quantità è giusta",
|
||||
"banner_no_expiry_title": "Scadenza mancante: {name}",
|
||||
@@ -217,7 +219,7 @@
|
||||
"title": "Cosa vuoi fare?",
|
||||
"add_btn": "📥 AGGIUNGI",
|
||||
"add_sub": "in dispensa/frigo",
|
||||
"use_btn": "📤 USA / CONSUMA",
|
||||
"use_btn": "USA",
|
||||
"use_sub": "dalla dispensa/frigo",
|
||||
"have_title": "📦 Ce l'hai già!",
|
||||
"add_more_sub": "altra quantità",
|
||||
@@ -533,7 +535,7 @@
|
||||
"prev": "◀ Precedente",
|
||||
"next": "Successivo ▶",
|
||||
"ingredient_used": "✔️ Scalato",
|
||||
"ingredient_use_btn": "📦 Usa",
|
||||
"ingredient_use_btn": "Usa",
|
||||
"ingredient_deduct_title": "Scala dalla dispensa",
|
||||
"timer_expired_tts": "Timer {label} scaduto!",
|
||||
"timer_warning_tts": "Attenzione! {label}: mancano 10 secondi!",
|
||||
@@ -753,13 +755,54 @@
|
||||
"label": "🌙 Tema",
|
||||
"off": "☀️ Chiaro",
|
||||
"on": "🌙 Scuro",
|
||||
"auto": "🔄 Automatico (sistema)"
|
||||
"auto": "🔄 Automatico (orario)"
|
||||
},
|
||||
"zerowaste": {
|
||||
"card_title": "♻️ Suggerimenti zero-waste",
|
||||
"card_hint": "Durante la cottura, mostra consigli su come riutilizzare gli scarti prodotti in ogni passo (bucce, acqua di cottura, ecc.). Disattivo per impostazione predefinita.",
|
||||
"label": "Mostra suggerimenti durante la cottura"
|
||||
}
|
||||
},
|
||||
"info": {
|
||||
"tab": "Info",
|
||||
"ai_title": "Gemini AI — Utilizzo Token",
|
||||
"ai_hint": "Consumo mensile e costo stimato per la chiave API corrente.",
|
||||
"loading": "Caricamento…",
|
||||
"total_tokens": "Token totali",
|
||||
"est_cost": "Costo stimato",
|
||||
"input_tok": "Token input",
|
||||
"output_tok": "Token output",
|
||||
"ai_calls": "Chiamate",
|
||||
"by_action": "Dettaglio per funzione",
|
||||
"by_model": "Dettaglio per modello",
|
||||
"pricing_note": "Prezzi di riferimento Gemini: 2.5-flash $0.15/M in · $0.60/M out — 2.0-flash $0.10/M in · $0.40/M out.",
|
||||
"system_title": "Sistema",
|
||||
"db_size": "Database",
|
||||
"log_size": "Log",
|
||||
"log_level": "Livello log",
|
||||
"ai_overview": "Prospetto utilizzo AI, inventario e stato del sistema",
|
||||
"calls_unit": "call",
|
||||
"inv_title": "Inventario",
|
||||
"inv_active": "Attivi",
|
||||
"inv_products": "Prodotti totali",
|
||||
"inv_expiring": "In scadenza (7gg)",
|
||||
"inv_expired": "Scaduti",
|
||||
"inv_finished": "Finiti",
|
||||
"act_title": "Attività del mese",
|
||||
"act_tx_month": "Movimenti",
|
||||
"act_restock": "Acquisti",
|
||||
"act_use": "Consumi",
|
||||
"act_new_products": "Nuovi prodotti",
|
||||
"act_tx_year": "Movimenti anno",
|
||||
"price_cache": "Cache prezzi",
|
||||
"cache_entries": "prodotti",
|
||||
"last_backup": "Ultimo backup",
|
||||
"bring_days": "token scade tra {n} giorni",
|
||||
"bring_expired": "token scaduto",
|
||||
"year_label": "Anno {year}",
|
||||
"currency_title": "Valuta",
|
||||
"currency_hint": "La valuta usata per tutti i costi e i prezzi nell'app."
|
||||
},
|
||||
"tab_general": "Generali"
|
||||
},
|
||||
"expiry": {
|
||||
"today": "OGGI",
|
||||
@@ -831,6 +874,7 @@
|
||||
"thrown_away": "🗑️ {name} buttato!",
|
||||
"thrown_away_partial": "🗑️ Buttato {qty} {unit} di {name}",
|
||||
"finished_all": "📤 {name} terminato!",
|
||||
"vacuum_sealed": "{name} salvato come sottovuoto",
|
||||
"product_finished_confirmed": "✅ Rimosso — riaggiungi quando ne ricompri",
|
||||
"appliance_added": "Elettrodomestico aggiunto",
|
||||
"item_added": "{name} aggiunto"
|
||||
@@ -1007,8 +1051,8 @@
|
||||
"thing_rest": "il resto",
|
||||
"stay_btn": "No, resta in {location}",
|
||||
"moved_toast": "📦 Confezione aperta spostata in {location}",
|
||||
"vacuum_restore": "🫙 Torna sotto vuoto",
|
||||
"vacuum_seal_rest": "🔒 Metti sotto vuoto il resto"
|
||||
"vacuum_restore": "Torna sotto vuoto",
|
||||
"vacuum_seal_rest": "Metti sotto vuoto il resto"
|
||||
},
|
||||
"nova": {
|
||||
"1": "Non trasformato",
|
||||
|
||||
Reference in New Issue
Block a user