Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1cb4ae13f1 |
@@ -1 +1 @@
|
||||
ko_fi: evershelfproject
|
||||
ko_fi: dadaloop82
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@v3
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
gradle-version: '8.6'
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@v3
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
gradle-version: '8.4'
|
||||
|
||||
|
||||
@@ -119,63 +119,3 @@ jobs:
|
||||
|
||||
Triggered by: $LAST"
|
||||
git push origin main
|
||||
|
||||
# ── Auto-create GitHub Release on main ───────────────────────────────────
|
||||
# Runs after auto-merge succeeds. Reads version from index.html,
|
||||
# creates a release tag vX.Y.Z if it doesn't exist yet.
|
||||
# This powers the in-app update badge for self-hosted users.
|
||||
create-release:
|
||||
name: Create GitHub Release
|
||||
needs: [auto-merge-to-main]
|
||||
if: github.ref == 'refs/heads/develop'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract version from index.html
|
||||
id: version
|
||||
run: |
|
||||
VER=$(grep -oP 'header-version">v\K[\d.]+' index.html | head -1)
|
||||
echo "version=v${VER}" >> $GITHUB_OUTPUT
|
||||
echo "Detected version: v${VER}"
|
||||
|
||||
- name: Check if tag already exists
|
||||
id: tag_check
|
||||
run: |
|
||||
if git ls-remote --tags origin "refs/tags/${{ steps.version.outputs.version }}" | grep -q .; then
|
||||
echo "exists=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Read CHANGELOG entry for this version
|
||||
id: changelog
|
||||
if: steps.tag_check.outputs.exists == 'false'
|
||||
run: |
|
||||
VER="${{ steps.version.outputs.version }}"
|
||||
# Extract the section for this version from CHANGELOG.md
|
||||
BODY=$(awk "/^## \[?${VER#v}\]?|^## ${VER}/,/^## [0-9]/" CHANGELOG.md | head -50 | tail -n +1 | grep -v "^## [0-9]" || true)
|
||||
if [ -z "$BODY" ]; then
|
||||
BODY="See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md) for details."
|
||||
fi
|
||||
# Multiline output
|
||||
echo "body<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$BODY" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create release
|
||||
if: steps.tag_check.outputs.exists == 'false'
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.version.outputs.version }}
|
||||
name: "EverShelf ${{ steps.version.outputs.version }}"
|
||||
body: ${{ steps.changelog.outputs.body }}
|
||||
target_commitish: main
|
||||
make_latest: true
|
||||
|
||||
@@ -7,7 +7,6 @@ on:
|
||||
- 'Dockerfile'
|
||||
- 'docker-compose.yml'
|
||||
- 'api/**'
|
||||
- '.github/workflows/security.yml'
|
||||
schedule:
|
||||
# Run weekly on Monday at 07:00 UTC
|
||||
- cron: '0 7 * * 1'
|
||||
@@ -28,17 +27,16 @@ jobs:
|
||||
run: docker build -t evershelf:scan .
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: aquasecurity/trivy-action@v0.36.0
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
image-ref: 'evershelf:scan'
|
||||
format: 'sarif'
|
||||
output: 'trivy-results.sarif'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
ignore-unfixed: true
|
||||
exit-code: '0' # don't fail the build, just report
|
||||
|
||||
- name: Upload Trivy SARIF to GitHub Security tab
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
with:
|
||||
sarif_file: 'trivy-results.sarif'
|
||||
category: 'trivy-docker'
|
||||
@@ -54,18 +52,17 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run Trivy filesystem scanner
|
||||
uses: aquasecurity/trivy-action@v0.36.0
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
scan-type: 'fs'
|
||||
scan-ref: '.'
|
||||
format: 'sarif'
|
||||
output: 'trivy-fs-results.sarif'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
ignore-unfixed: true
|
||||
exit-code: '0'
|
||||
|
||||
- name: Upload Trivy FS SARIF
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
with:
|
||||
sarif_file: 'trivy-fs-results.sarif'
|
||||
category: 'trivy-fs'
|
||||
|
||||
@@ -49,4 +49,3 @@ evershelf-kiosk/local.properties
|
||||
data/error_reports.log
|
||||
data/latest_release_cache.json
|
||||
data/food_facts_cache.json
|
||||
data/category_ai_cache.json
|
||||
|
||||
@@ -5,38 +5,6 @@ All notable changes to EverShelf will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased] — Ideas & Roadmap
|
||||
|
||||
> Ideas collected during development. No priority or date implied.
|
||||
|
||||
- **Recipe scraps tips** — During cooking steps, detect "waste" generated (peels, cores, bones, eggshells, coffee grounds, citrus zest, etc.) and surface AI-powered tips on how to reuse them (compost, natural cleaner, broth, candied peel, etc.). Could be shown as an optional collapsible hint card below the step that generates the scrap.
|
||||
|
||||
## [1.7.15] - 2026-05-16
|
||||
|
||||
### Added
|
||||
- **Full i18n audit** — Comprehensive sweep of all user-visible strings in `app.js` and `index.html`. 25+ new translation keys added across `it.json`, `en.json`, `de.json`, covering: vacuum toast, TTS voice controls, timer step labels, product note labels, error messages, expiry form, barcode hint, category select placeholder, cooking step fallback, `form.select_placeholder`, `btn.yes_short`/`no_short`, `add.vacuum_question`, `add.vacuum_saved`, `move.vacuum_seal_rest`, `cooking.step_fallback`, `error.prefix`/`unknown`, `product.select_variant`, and more.
|
||||
- **Splash screen redesign** — Logo displayed prominently, spinner below, app version shown at the bottom; version label injected dynamically at boot time so it never gets out of sync. Minimum 3-second display duration enforced: `_splashStart` is recorded before `DOMContentLoaded`; the fade-out is delayed by the remaining time if the app loads faster than 3 s.
|
||||
- **Demo GIF in README** — `assets/img/demo.gif` (processed at 2× speed, ~36 s) added to the `## 📸 Screenshots` section.
|
||||
- **`pz`/`conf` unit labels translated** — "pz" now shows as "pcs" in English and "Stk" in German; "conf" shows as "pkg" / "Pkg". All `unitLabels` objects in JS now use `t('units.pz')` / `t('units.conf')`.
|
||||
|
||||
### Fixed
|
||||
- **Logo white background on splash screen** — Re-processed both `logo.png` and `logo_icon.png` with fuzz 35% alpha extraction, removing the white background that was visible against the dark splash background (`#0f172a`).
|
||||
- **Recipe button label** — Shortened to "Ricetta" / "Recipe" / "Rezept" for compact display in the inventory quick-action modal.
|
||||
- **Quantity decimal precision** — `qtyNum` in recipe/cooking ingredient buttons and `conf` fallback display in inventory cards now limited to 1 decimal place (was showing 7+ decimal places from raw AI output, e.g. `0.25353223 conf`).
|
||||
- **"Errore" / "Error" fallback strings** — All remaining Italian hardcoded `'Errore'` fallbacks in `showToast()` calls replaced with `t('error.generic')`. Italian fallback strings removed from buttons that already used `t()`.
|
||||
- **README Italian phrases** — "La quantità è giusta (2 pz)", "🤖 Spiega", "Latte / Affettato / Panna da cucina", "Buon appetito!", "L'ho buttato" replaced with English equivalents in the README.
|
||||
|
||||
## [1.7.14] - 2026-05-16
|
||||
|
||||
### Added
|
||||
- **In-app bug report form** — "Segnala un problema" now opens a modal form instead of redirecting to GitHub. Users can select type (Bug / Feature / Question), write title and description, optionally add reproduction steps. A GitHub issue is created directly with labels and app metadata attached.
|
||||
|
||||
### Fixed
|
||||
- **Kiosk settings button** — "Apri configurazione kiosk" in webapp settings was showing a toast asking to tap a gear icon that no longer exists. Now calls `openNativeSettings()` bridge directly (opens Android SettingsActivity). Fallback for old APKs shows a proper "update the kiosk app" hint.
|
||||
- **False update badge** — `manifest.json` version was `1.7.12` while the app header showed `v1.7.13`, causing the server to report an older deployed version and triggering a spurious update notification.
|
||||
- **Kiosk settings gear disappeared** — Race condition where Kotlin's `onPageFinished` injects `#_kiosk_overlay` before JS runs; JS found the element already present and returned early without ever restoring the native gear button. Fixed: JS no longer hides the native gear on load; `closeModal()` restores it with `setNativeSettingsVisible(true)`.
|
||||
- **`openNativeSettings()` fragile typeof check** — Android `@JavascriptInterface` methods are not always detected as `'function'` by typeof; replaced with try/catch.
|
||||
|
||||
## [1.7.13] - 2026-05-16
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
FROM php:8.2-apache-bookworm
|
||||
FROM php:8.2-apache
|
||||
|
||||
# Install required PHP extensions + Tesseract OCR for offline expiry date reading
|
||||
RUN apt-get update && apt-get upgrade -y && apt-get install -y \
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libsqlite3-dev \
|
||||
libcurl4-openssl-dev \
|
||||
libonig-dev \
|
||||
|
||||
@@ -25,15 +25,13 @@
|
||||
[](https://www.sqlite.org/)
|
||||
[](Dockerfile)
|
||||
[](translations/)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://github.com/dadaloop82/EverShelf/stargazers)
|
||||
[](https://github.com/dadaloop82/EverShelf/commits/main)
|
||||
[](https://github.com/dadaloop82/EverShelf/graphs/contributors)
|
||||
[](https://github.com/dadaloop82/EverShelf/discussions)
|
||||
[](https://github.com/dadaloop82/EverShelf/actions/workflows/ci.yml)
|
||||
|
||||
[](https://ko-fi.com/J3J01ZNETZ)
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
@@ -45,7 +43,7 @@
|
||||
- **Expiry tracking** — Automatic shelf-life estimation based on product type and storage
|
||||
- **Opened product tracking** — Reduced shelf-life calculation when packages are opened; opened-product expiry is now also checked when building banner alerts (not just the dashboard section)
|
||||
- **Vacuum-sealed support** — Extended expiry dates for vacuum-sealed items
|
||||
- **Anomaly detection** — Banner alerts for suspicious quantities and consumption predictions with inline correction; dismiss button now shows the current inventory quantity so the action is unambiguous ("Quantity is correct (2 pcs)")
|
||||
- **Anomaly detection** — Banner alerts for suspicious quantities and consumption predictions with inline correction; dismiss button now shows the current inventory quantity so the action is unambiguous ("La quantità è giusta (2 pz)")
|
||||
|
||||
### 🤖 AI-Powered (Google Gemini)
|
||||
- **Expiry date reading** — Photograph a label and extract the expiry date automatically
|
||||
@@ -55,13 +53,13 @@
|
||||
- **Recipe generation** — Get personalized recipes based on what's in your pantry; streams live via Server-Sent Events so results appear as they are generated
|
||||
- **Smart chat assistant** — Ask questions about your inventory, get cooking tips
|
||||
- **Shopping suggestions with tips** — AI-powered purchase recommendations, each enriched with a short practical buying/storing tip
|
||||
- **Anomaly explanation** — "Explain" button on anomaly banners explains in plain language why a discrepancy likely occurred and what to do
|
||||
- **Anomaly explanation** — "🤖 Spiega" button on anomaly banners explains in plain language why a discrepancy likely occurred and what to do
|
||||
- **Model fallback** — All AI endpoints try `gemini-2.5-flash` first and fall back to `gemini-2.0-flash` automatically
|
||||
- **Graceful no-key state** — When no Gemini key is configured, AI entry points show a friendly message; the header button is visually greyed with an amber dot
|
||||
|
||||
### 🛒 Shopping List
|
||||
- **Bring! integration** — Sync with the [Bring!](https://www.getbring.com/) shopping list app
|
||||
- **Generic shopping names** — Products are grouped by type (e.g. "Milk", "Cold cuts", "Cooking cream") rather than brand, keeping the Bring! list clean and consolidated
|
||||
- **Generic shopping names** — Products are grouped by type ("Latte", "Affettato", "Panna da cucina") rather than brand, keeping the Bring! list clean and consolidated
|
||||
- **Smart predictions** — Know what you'll need before you run out
|
||||
- **Auto-add on depletion** — When a product reaches zero the app adds it to Bring! automatically, no confirmation needed
|
||||
- **Auto-remove on scan** — Products are removed from the shopping list when scanned in - **Auto-migration** — Items already on the Bring! list are silently renamed to their generic name in the background (throttled, runs on list load)
|
||||
@@ -72,7 +70,7 @@
|
||||
- **Text-to-Speech** — Voice readout of recipe steps; supports browser Web Speech API, native Android TTS (kiosk), or a custom REST endpoint (Home Assistant, etc.); retries voice loading for up to 10 seconds with a fallback refresh button; TTS activates automatically without requiring the global TTS setting to be enabled
|
||||
- **Auto-read on navigate** — Each step is read aloud automatically when you tap Next or Previous; the first step is read when entering cooking mode
|
||||
- **Timer voice alerts** — 10-second countdown warning spoken aloud before each timer expires; expiry announced vocally when time is up
|
||||
- **Recipe completion** — "Bon appétit!" announced via TTS when the last step is confirmed
|
||||
- **Recipe completion** — "Buon appetito!" spoken when the last step is confirmed
|
||||
- **Built-in timer** — Automatic timer suggestions based on recipe instructions
|
||||
- **Ingredient tracking** — Mark ingredients as used during cooking; leftover quantities prompt a "move to another location" flow
|
||||
|
||||
@@ -82,7 +80,7 @@
|
||||
- **Expiry alerts** — Visual warnings for expired and soon-to-expire items
|
||||
- **Opened products panel** — Tracks partially-used items; expiry is recalculated from the opening date using AI (Gemini) + per-category rule fallback; whole sealed packages always keep their original manufacturer expiry; conf items with mixed whole + fractional units are shown as two separate entries
|
||||
- **Freezer shelf-life** — Granular per-product estimates (USDA/EFSA): fish 120 d, poultry 270 d, whole red-meat cuts 365 d, mince 120 d, vegetables/fruit 270 d, generic 180 d; AI + cache still take priority over rules
|
||||
- **Safety ratings** — Smart assessment of expired product safety (by category and location); expired unsafe items shown with a red danger banner and a discard action as the primary action
|
||||
- **Safety ratings** — Smart assessment of expired product safety (by category and location); expired unsafe items shown with a red danger banner and "L'ho buttato" as the primary action
|
||||
- **Expired product banner** — Products that have passed their effective shelf-life (including opened-product reduced expiry) appear in the top notification banner; icon, colour and title adapt to the actual safety level (✅ green for safe, 👀 amber to check, 🚫 red for danger); high-risk items get a prominent discard action
|
||||
- **Quick recipe bar** — One-tap recipe suggestion using expiring products
|
||||
- **Anomaly banner** — Scrollable banner with suspicious quantities and consumption prediction mismatches, with one-tap correction or inline edit
|
||||
@@ -104,7 +102,7 @@
|
||||
- **Stability + auto-confirm** — 10s stable wait + 5s countdown before confirming
|
||||
- **Real-time status** — Scale connection indicator always visible in the header
|
||||
- **Multi-protocol** — Supports Bluetooth SIG Weight Scale, Body Composition, Xiaomi Mi Scale 2 and 100+ models
|
||||
- **Built into kiosk (v1.6.0+)** — BLE gateway runs as an integrated foreground service inside the [EverShelf Kiosk](evershelf-kiosk/) app; no separate APK needed.
|
||||
- **Built into kiosk (v1.6.0+)** — BLE gateway runs as an integrated foreground service inside the [EverShelf Kiosk](evershelf-kiosk/) app; no separate APK needed. The standalone gateway app in [`evershelf-scale-gateway/`](evershelf-scale-gateway/) is deprecated but kept for non-kiosk use cases.
|
||||
|
||||
### 📺 Android Kiosk Mode (Add-on)
|
||||
- **Dedicated tablet app** — Full-screen WebView wrapper for wall-mounted kitchen tablets
|
||||
@@ -352,35 +350,10 @@ The application uses no build tools — edit files directly and refresh.
|
||||
|
||||
## 📋 Roadmap
|
||||
|
||||
### High Priority
|
||||
- [ ] **Cooking mode — 3D wheel JS** — swipe navigation, gyroscope tilt, haptic feedback
|
||||
- [ ] **Cooking mode — step timers** — auto-detect "X minutes" in recipe steps, countdown + alert
|
||||
- [ ] **Push notifications** — daily expiry alerts via PWA Service Worker + VAPID
|
||||
- [ ] **Quick search / quick-add bar** — always-visible search above the nav, PWA shortcuts
|
||||
|
||||
### Medium Priority
|
||||
- [ ] **Receipt OCR → bulk add** — photo of receipt → Gemini Vision → auto-fill inventory
|
||||
- [ ] **CSV/JSON export & import** — download/upload inventory from Settings
|
||||
- [ ] **Custom storage locations** — user-defined locations beyond Fridge/Freezer/Pantry
|
||||
- [ ] **Multi-user support** — PIN-based user distinction, action log with user label
|
||||
- [ ] **AI optimal purchase prediction** — suggest "buy X units of Y within Z days"
|
||||
- [ ] **Price history sparklines** — per-product price chart from the AI cache data
|
||||
|
||||
### Low Priority / Nice to Have
|
||||
- [ ] **Dark mode** — CSS custom properties are already structured to support it
|
||||
- [ ] **Full offline mode** — Service Worker cache to show inventory read-only when server is down
|
||||
- [ ] **French & Spanish translations** (`fr.json`, `es.json`)
|
||||
- [ ] **Swipe actions on inventory rows** — swipe left to use/discard, right to edit
|
||||
- [ ] **PHP unit tests** — PHPUnit coverage for shelf-life, price calc, and key helpers
|
||||
|
||||
### Completed ✅
|
||||
- ✅ AI price estimation in shopping list
|
||||
- ✅ Server heartbeat + offline banner
|
||||
- ✅ In-app bug reporter → automatic GitHub issue creation
|
||||
- ✅ Cooking mode (start, steps, 3D wheel CSS)
|
||||
- ✅ Kiosk ⚙️ Settings overlay button (replaces Android native button)
|
||||
- ✅ Adaptive consumption anomaly detection
|
||||
- ✅ CI/CD pipeline (PHP lint, JS lint, Docker build, Trivy security scan)
|
||||
- [ ] User authentication / multi-user support
|
||||
- [ ] Offline mode with service worker
|
||||
- [ ] Export/import inventory data
|
||||
- [ ] Notification system (Telegram, email) for expiring products
|
||||
|
||||
---
|
||||
|
||||
@@ -427,12 +400,6 @@ This project is licensed under the **MIT License** — see the [LICENSE](LICENSE
|
||||
|
||||
## 📸 Screenshots
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
For a live walkthrough with real data and full AI enabled, visit the **[live demo](https://evershelfproject.dadaloop.it/demo)** — no installation required.
|
||||
|
||||
> Want to contribute additional screenshots? See [CONTRIBUTING.md](CONTRIBUTING.md) — PRs welcome!
|
||||
> Want to contribute a GIF or screenshots? See [CONTRIBUTING.md](CONTRIBUTING.md) — PRs welcome!
|
||||
|
||||
@@ -138,14 +138,11 @@ function migrateDB(PDO $db): void {
|
||||
quantity REAL NOT NULL,
|
||||
location TEXT NOT NULL DEFAULT 'dispensa',
|
||||
notes TEXT DEFAULT '',
|
||||
undone INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE
|
||||
)
|
||||
");
|
||||
// Insert with explicit columns: transactions_old may lack 'undone' (pre-v1.7.x DB)
|
||||
$db->exec("INSERT INTO transactions (id, product_id, type, quantity, location, notes, created_at)
|
||||
SELECT id, product_id, type, quantity, location, notes, created_at FROM transactions_old");
|
||||
$db->exec("INSERT INTO transactions SELECT * FROM transactions_old");
|
||||
$db->exec("DROP TABLE transactions_old");
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_product ON transactions(product_id)");
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(created_at)");
|
||||
@@ -363,10 +360,6 @@ function estimateOpenedExpiryDaysPHP(string $name, string $category, string $loc
|
||||
if (preg_match('/\b(yogurt|yaourt|yoghurt)\b/', $n)) return 2;
|
||||
if (preg_match('/\blatte\b/', $n)) return 1;
|
||||
if (preg_match('/\bformaggio\b/', $n)) return 2;
|
||||
// Root vegetables / tubers in pantry: sfusi in un sacchetto, durano 3-5 settimane
|
||||
if (preg_match('/\b(patata|patate|tubero)\b/', $n)) return 30;
|
||||
if (preg_match('/\b(cipolla|cipolle|aglio|scalogno|porro)\b/', $n)) return 30;
|
||||
if (preg_match('/\b(carota|carote)\b/', $n)) return 14;
|
||||
return 60; // generic pantry fallback
|
||||
}
|
||||
|
||||
@@ -416,7 +409,7 @@ function estimateOpenedExpiryDaysPHP(string $name, string $category, string $loc
|
||||
if (preg_match('/\b(senape|mustard)\b/', $n)) return 90;
|
||||
if (preg_match('/salsa\s+di\s+soia|soy\s*sauce/', $n)) return 90;
|
||||
if (preg_match('/\b(tabasco|worcestershire|sriracha)\b/', $n)) return 180;
|
||||
if (preg_match('/confettura|marmellata/', $n)) return 180;
|
||||
if (preg_match('/confettura|marmellata/', $n)) return 60;
|
||||
if (preg_match('/nutella|cioccolat/', $n)) return 60;
|
||||
|
||||
// ── H: Category fallbacks ────────────────────────────────────────────
|
||||
@@ -474,7 +467,7 @@ function estimateSealedExpiryDaysPHP(string $name, string $category, string $loc
|
||||
elseif (preg_match('/carota|carote|zucchina|zucchine|peperoni|melanzane/', $n)) $days = 7;
|
||||
elseif (preg_match('/broccoli|cavolfiore|cavolo|spinaci|bietola/', $n)) $days = 5;
|
||||
elseif (preg_match('/cipolla|cipolle/', $n)) $days = 10;
|
||||
elseif (preg_match('/patata|patate/', $n)) $days = 30; // whole tubers in a bag, pantry: 3-5 weeks
|
||||
elseif (preg_match('/patata|patate/', $n)) $days = 14;
|
||||
elseif (preg_match('/biscott|cracker|grissini|fette\s+biscott/', $n)) $days = 180;
|
||||
elseif (preg_match('/nutella|marmellata|miele/', $n)) $days = 365;
|
||||
elseif (preg_match('/passata|pelati|pomodor/', $n)) $days = 730;
|
||||
|
||||
@@ -438,10 +438,6 @@ try {
|
||||
reportError();
|
||||
break;
|
||||
|
||||
case 'report_bug':
|
||||
reportBugManual();
|
||||
break;
|
||||
|
||||
case 'check_update':
|
||||
checkUpdate();
|
||||
break;
|
||||
@@ -2869,8 +2865,6 @@ function geminiChat(PDO $db): void {
|
||||
$history = $input['history'] ?? [];
|
||||
$appliances = $input['appliances'] ?? [];
|
||||
$dietaryRestrictions = $input['dietary_restrictions'] ?? '';
|
||||
$lang = recipeNormalizeLang($input['lang'] ?? 'it');
|
||||
$langName = recipeLangName($lang);
|
||||
|
||||
if (empty($message)) {
|
||||
echo json_encode(['success' => false, 'error' => 'Messaggio vuoto']);
|
||||
@@ -2918,29 +2912,27 @@ function geminiChat(PDO $db): void {
|
||||
|
||||
$dietaryText = '';
|
||||
if (!empty($dietaryRestrictions)) {
|
||||
$dietaryText = "\nUser dietary restrictions: {$dietaryRestrictions}. Always respect these restrictions.";
|
||||
$dietaryText = "\nRestrizioni alimentari dell'utente: {$dietaryRestrictions}. Rispetta SEMPRE queste restrizioni.";
|
||||
}
|
||||
|
||||
$langName = recipeLangName($lang);
|
||||
$systemPrompt = <<<PROMPT
|
||||
You are an expert kitchen assistant, friendly and concise. The user has a pantry and asks you for advice on what to prepare.
|
||||
IMPORTANT: Always respond in {$langName}, using a colloquial and friendly tone.
|
||||
Sei un assistente cucina italiano esperto, amichevole e conciso. L'utente ha una dispensa e ti chiede consigli su cosa preparare.
|
||||
|
||||
CONTEXT - AVAILABLE PANTRY INGREDIENTS:
|
||||
CONTESTO - INGREDIENTI DISPONIBILI IN DISPENSA:
|
||||
{$ingredientsText}
|
||||
{$appliancesText}{$dietaryText}
|
||||
|
||||
RULES:
|
||||
1. Always respond in {$langName}
|
||||
2. Use ONLY ingredients from the user's pantry (plus water, salt, pepper, oil which are assumed always available)
|
||||
3. Prioritize ingredients that expire soon
|
||||
4. Be concise: no lengthy lists, get to the point
|
||||
5. If the user asks for a recipe or preparation, give clear instructions with quantities
|
||||
6. If there are no suitable ingredients for the request, say so honestly and suggest alternatives
|
||||
7. You can suggest creative combinations
|
||||
8. When mentioning quantities, use the same units as in the pantry
|
||||
9. Remember the context of the previous conversation
|
||||
10. If the user explicitly asks for a recipe for a specific appliance (e.g. bread machine, Cookeo, air fryer), provide the recipe ONLY for that appliance, with device-specific instructions (programs, ingredient order, times, temperatures)
|
||||
REGOLE:
|
||||
1. Rispondi SEMPRE in italiano, in modo colloquiale e amichevole
|
||||
2. Usa SOLO gli ingredienti dalla dispensa dell'utente (più acqua, sale, pepe, olio che si presumono sempre disponibili)
|
||||
3. Dai priorità agli ingredienti in scadenza
|
||||
4. Sii conciso: non fare liste chilometriche, vai al sodo
|
||||
5. Se l'utente chiede una ricetta o preparazione, dai istruzioni chiare con quantità
|
||||
6. Se non ci sono ingredienti adatti per la richiesta, dillo onestamente e suggerisci alternative
|
||||
7. Puoi suggerire combinazioni creative
|
||||
8. Quando menzioni quantità, usa le stesse unità di misura della dispensa
|
||||
9. Ricorda il contesto della conversazione precedente
|
||||
10. Se l'utente chiede esplicitamente una ricetta per un apparecchio specifico (es. macchina del pane, Cookeo, friggitrice ad aria), fornisci la ricetta SOLO per quell'apparecchio, con istruzioni specifiche per quel dispositivo (programmi, ordine degli ingredienti, tempi, temperature)
|
||||
PROMPT;
|
||||
|
||||
// Build conversation for Gemini
|
||||
@@ -3028,7 +3020,6 @@ PROMPT;
|
||||
'error_empty_reply' => 'Risposta vuota da Gemini',
|
||||
'prompt_lang_rule' => 'IMPORTANTE: scrivi tutti i campi testuali della ricetta in Italiano.',
|
||||
'prompt_step_example' => 'Passo 1…',
|
||||
'tools_title' => 'Strumenti necessari',
|
||||
],
|
||||
'en' => [
|
||||
'status_analyze_pantry' => '📦 Analyzing pantry...',
|
||||
@@ -3051,7 +3042,6 @@ PROMPT;
|
||||
'error_empty_reply' => 'Empty response from Gemini',
|
||||
'prompt_lang_rule' => 'IMPORTANT: write all textual recipe fields in English only. Do not use Italian or German.',
|
||||
'prompt_step_example' => 'Step 1…',
|
||||
'tools_title' => 'Equipment needed',
|
||||
],
|
||||
'de' => [
|
||||
'status_analyze_pantry' => '📦 Vorrat wird analysiert...',
|
||||
@@ -3074,7 +3064,6 @@ PROMPT;
|
||||
'error_empty_reply' => 'Leere Antwort von Gemini',
|
||||
'prompt_lang_rule' => 'WICHTIG: schreibe alle textuellen Rezeptfelder nur auf Deutsch. Verwende kein Italienisch oder Englisch.',
|
||||
'prompt_step_example' => 'Schritt 1…',
|
||||
'tools_title' => 'Benötigte Geräte',
|
||||
],
|
||||
];
|
||||
$text = $dict[$lang][$key] ?? $dict['it'][$key] ?? $key;
|
||||
@@ -3452,15 +3441,14 @@ REGOLE:
|
||||
4. "qty_number": valore NUMERICO nella STESSA unità della dispensa (g/ml/pz/conf, MAI kg o litri). Per non-dispensa: 0. IMPORTANTE: per ingredienti con unità "pz" scrivi qty_number come numero di PEZZI (es. 2, non 200g).
|
||||
5. "name": usa ESATTAMENTE il nome dalla lista (il sistema lo usa per scalare l'inventario).
|
||||
6. Includi nella lista ingredienti TUTTI quelli citati nei passi (tranne acqua/sale/pepe/olio).
|
||||
7. Language rule: {$recipeLangName} only for all textual fields (`title`, `tags`, `expiry_note`, `ingredients.qty`, `steps`, `nutrition_note`, `tools_needed`). Keep `meal` unchanged.
|
||||
8. `tools_needed`: array of kitchen tools/appliances actually required by this recipe (e.g. ["Forno","Frullatore"]). Use the same language as all other text fields. Empty array [] if only stovetop/knife/pan needed.
|
||||
7. Language rule: {$recipeLangName} only for all textual fields (`title`, `tags`, `expiry_note`, `ingredients.qty`, `steps`, `nutrition_note`). Keep `meal` unchanged.
|
||||
|
||||
DISPENSA:
|
||||
$ingredientsText
|
||||
|
||||
Rispondi SOLO JSON valido (no markdown):
|
||||
{$promptLanguageRule}
|
||||
{"title":"…","meal":"$mealType","persons":$persons,"prep_time":"…","cook_time":"…","tags":["…"],"expiry_note":"…","tools_needed":["…"],"ingredients":[{"name":"…","qty":"200 g","qty_number":200,"from_pantry":true}],"steps":["{$promptStepExample}"],"nutrition_note":"…"}
|
||||
{"title":"…","meal":"$mealType","persons":$persons,"prep_time":"…","cook_time":"…","tags":["…"],"expiry_note":"…","ingredients":[{"name":"…","qty":"200 g","qty_number":200,"from_pantry":true}],"steps":["{$promptStepExample}"],"nutrition_note":"…"}
|
||||
PROMPT;
|
||||
|
||||
$payload = [
|
||||
@@ -3814,8 +3802,6 @@ function recipeFromIngredient(PDO $db): void {
|
||||
echo json_encode(['success' => false, 'error' => 'empty_ingredient']);
|
||||
return;
|
||||
}
|
||||
$lang = recipeNormalizeLang($input['lang'] ?? 'it');
|
||||
$langName = recipeLangName($lang);
|
||||
|
||||
// Fetch inventory (same as generateRecipe)
|
||||
$stmt = $db->query("
|
||||
@@ -3831,18 +3817,18 @@ function recipeFromIngredient(PDO $db): void {
|
||||
$safeName = htmlspecialchars($ingredientName, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
$prompt = <<<PROMPT
|
||||
Generate a recipe in {$langName} that uses "{$safeName}" as a main ingredient.
|
||||
Generate a recipe in Italian that uses "{$safeName}" as a main ingredient.
|
||||
Return ONLY a JSON object, no markdown.
|
||||
|
||||
Fields:
|
||||
- title: string (recipe name in {$langName})
|
||||
- title: string (Italian recipe name)
|
||||
- meal: null (do NOT categorize)
|
||||
- persons: 2
|
||||
- prep_time: string or null
|
||||
- cook_time: string or null
|
||||
- ingredients: array of {"name":"...","qty":"...","qty_number":0.0,"unit":"g|ml|pz|conf|kg|l","from_pantry":true}
|
||||
— "{$safeName}" MUST be the first ingredient; set from_pantry=true for ALL
|
||||
- steps: array of strings (step text only, no numbers, in {$langName})
|
||||
- steps: array of strings (step text only, no numbers)
|
||||
- nutrition_note: string or null
|
||||
PROMPT;
|
||||
|
||||
@@ -4331,15 +4317,14 @@ REGOLE:
|
||||
4. "qty_number": valore NUMERICO nella STESSA unità della dispensa (g/ml/pz/conf, MAI kg o litri). Per non-dispensa: 0. IMPORTANTE: per ingredienti con unità "pz" scrivi qty_number come numero di PEZZI (es. 2, non 200g).
|
||||
5. "name": usa ESATTAMENTE il nome dalla lista (il sistema lo usa per scalare l'inventario).
|
||||
6. Includi nella lista ingredienti TUTTI quelli citati nei passi (tranne acqua/sale/pepe/olio).
|
||||
7. Language rule: {$recipeLangName} only for all textual fields (`title`, `tags`, `expiry_note`, `ingredients.qty`, `steps`, `nutrition_note`, `tools_needed`). Keep `meal` unchanged.
|
||||
8. `tools_needed`: array of kitchen tools/appliances actually required by this recipe (e.g. ["Forno","Frullatore"]). Use the same language as all other text fields. Empty array [] if only stovetop/knife/pan needed.
|
||||
7. Language rule: {$recipeLangName} only for all textual fields (`title`, `tags`, `expiry_note`, `ingredients.qty`, `steps`, `nutrition_note`). Keep `meal` unchanged.
|
||||
|
||||
DISPENSA:
|
||||
$ingredientsText
|
||||
|
||||
Rispondi SOLO JSON valido (no markdown):
|
||||
{$promptLanguageRule}
|
||||
{"title":"…","meal":"$mealType","persons":$persons,"prep_time":"…","cook_time":"…","tags":["…"],"expiry_note":"…","tools_needed":["…"],"ingredients":[{"name":"…","qty":"200 g","qty_number":200,"from_pantry":true}],"steps":["{$promptStepExample}"],"nutrition_note":"…"}
|
||||
{"title":"…","meal":"$mealType","persons":$persons,"prep_time":"…","cook_time":"…","tags":["…"],"expiry_note":"…","ingredients":[{"name":"…","qty":"200 g","qty_number":200,"from_pantry":true}],"steps":["{$promptStepExample}"],"nutrition_note":"…"}
|
||||
PROMPT;
|
||||
|
||||
$genConfig = [
|
||||
@@ -6907,86 +6892,6 @@ function reportError(): void {
|
||||
echo json_encode(['ok' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/?action=report_bug
|
||||
*
|
||||
* Manual bug/feature/question report submitted by the user via the in-app form.
|
||||
* Creates a GitHub issue directly with the provided title and description.
|
||||
*
|
||||
* Expected JSON body:
|
||||
* type string 'bug'|'feature'|'question'
|
||||
* title string Issue title (required, max 150 chars)
|
||||
* description string Main description (required, max 3000 chars)
|
||||
* steps string? Steps to reproduce (optional, max 2000 chars)
|
||||
* lang string? UI language the user is running
|
||||
* url string? Page URL
|
||||
* user_agent string? Navigator UA
|
||||
* version string? App version
|
||||
*/
|
||||
function reportBugManual(): void {
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
|
||||
$allowedTypes = ['bug', 'feature', 'question'];
|
||||
$type = in_array($input['type'] ?? '', $allowedTypes, true) ? $input['type'] : 'bug';
|
||||
$title = substr(trim($input['title'] ?? ''), 0, 150);
|
||||
$desc = substr(trim($input['description'] ?? ''), 0, 3000);
|
||||
$steps = substr(trim($input['steps'] ?? ''), 0, 2000);
|
||||
$ua = substr(trim($input['user_agent'] ?? ($_SERVER['HTTP_USER_AGENT'] ?? '')), 0, 300);
|
||||
$url = substr(trim($input['url'] ?? ''), 0, 300);
|
||||
$ver = substr(trim($input['version'] ?? ''), 0, 50);
|
||||
$lang = preg_replace('/[^a-z\-]/', '', strtolower($input['lang'] ?? 'it'));
|
||||
|
||||
if (empty($title) || empty($desc)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'title and description required']);
|
||||
return;
|
||||
}
|
||||
|
||||
$token = _ghToken();
|
||||
if (!$token) {
|
||||
// No GitHub token configured — log locally and return ok so the UX is not broken
|
||||
_appendErrorLog('pwa', 'manual_report', $title, $desc, $url, $ua, ['type' => $type, 'version' => $ver, 'lang' => $lang]);
|
||||
echo json_encode(['ok' => true, 'issue' => null]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Labels: always 'user-report' + type-specific label
|
||||
$labelMap = [
|
||||
'bug' => ['bug', 'user-report'],
|
||||
'feature' => ['enhancement', 'user-report'],
|
||||
'question' => ['question', 'user-report'],
|
||||
];
|
||||
$labels = $labelMap[$type];
|
||||
|
||||
$typeEmoji = ['bug' => '🐛', 'feature' => '💡', 'question' => '❓'][$type];
|
||||
$ts = date('Y-m-d H:i:s T');
|
||||
|
||||
$body = "## {$typeEmoji} User Report\n\n";
|
||||
$body .= "**Description:**\n{$desc}\n\n";
|
||||
if ($steps) {
|
||||
$body .= "**Steps to reproduce:**\n{$steps}\n\n";
|
||||
}
|
||||
$body .= "---\n";
|
||||
$body .= "**Version:** `{$ver}` \n";
|
||||
$body .= "**Language:** `{$lang}` \n";
|
||||
if ($url) $body .= "**URL:** `{$url}` \n";
|
||||
if ($ua) $body .= "**User-Agent:** `{$ua}` \n";
|
||||
$body .= "**Reported at:** {$ts}\n\n";
|
||||
$body .= "_This issue was submitted via the in-app bug report form._";
|
||||
|
||||
$res = _githubRequest($token, 'POST',
|
||||
'https://api.github.com/repos/' . GH_REPO . '/issues',
|
||||
['title' => $title, 'body' => $body, 'labels' => $labels]
|
||||
);
|
||||
|
||||
$issueNum = $res['body']['number'] ?? null;
|
||||
$issueUrl = $res['body']['html_url'] ?? null;
|
||||
if ($issueNum) {
|
||||
echo json_encode(['ok' => true, 'issue' => $issueNum, 'url' => $issueUrl]);
|
||||
} else {
|
||||
echo json_encode(['ok' => false, 'error' => 'github_api_error']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append to data/error_reports.log (local safety net, max 500 KB)
|
||||
*/
|
||||
|
||||
@@ -104,17 +104,10 @@ body {
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.app-preloader-logo {
|
||||
height: 160px;
|
||||
height: 120px;
|
||||
width: auto;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 4px 16px rgba(74,222,128,0.2));
|
||||
}
|
||||
.app-preloader-version {
|
||||
color: rgba(255,255,255,0.35);
|
||||
font-size: 0.72rem;
|
||||
font-family: monospace;
|
||||
letter-spacing: 0.5px;
|
||||
margin-top: -8px;
|
||||
filter: drop-shadow(0 2px 8px rgba(0,0,0,0.4));
|
||||
}
|
||||
.header-logo-icon {
|
||||
height: 28px;
|
||||
@@ -1274,16 +1267,6 @@ body.server-offline .bottom-nav {
|
||||
color: white;
|
||||
}
|
||||
|
||||
#btn-report-bug {
|
||||
background: #f97316;
|
||||
color: #fff;
|
||||
border-color: #ea580c;
|
||||
}
|
||||
#btn-report-bug:hover {
|
||||
background: #ea580c;
|
||||
border-color: #c2410c;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
@@ -3120,36 +3103,6 @@ body.server-offline .bottom-nav {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ── Bug report form ── */
|
||||
.bug-type-pills {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.bug-type-pill {
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
padding: 7px 10px;
|
||||
border: 1.5px solid #cbd5e1;
|
||||
border-radius: 20px;
|
||||
background: #fff;
|
||||
color: #475569;
|
||||
font-size: 0.88rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s, color 0.15s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.bug-type-pill.active {
|
||||
border-color: var(--primary, #2d5016);
|
||||
background: var(--primary, #2d5016);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.bug-type-pill:not(.active):hover {
|
||||
border-color: var(--primary, #2d5016);
|
||||
color: var(--primary, #2d5016);
|
||||
}
|
||||
|
||||
.modal-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -3965,29 +3918,6 @@ body.server-offline .bottom-nav {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.recipe-tools-banner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: #f0f4ff;
|
||||
border: 1px solid #c7d2fe;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 12px;
|
||||
font-size: 0.85rem;
|
||||
color: #3730a3;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.recipe-tool-chip {
|
||||
background: #e0e7ff;
|
||||
border-radius: 20px;
|
||||
padding: 2px 10px;
|
||||
font-size: 0.8rem;
|
||||
color: #3730a3;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Recipe ingredient use buttons */
|
||||
.recipe-ingredients {
|
||||
list-style: none;
|
||||
@@ -4364,153 +4294,14 @@ body.cooking-mode-active .app-header {
|
||||
transform: scale(1.35);
|
||||
}
|
||||
|
||||
.cooking-wheel {
|
||||
position: relative;
|
||||
--wheel-tilt-x: 0deg;
|
||||
--wheel-tilt-y: 0deg;
|
||||
--wheel-glow: 0.45;
|
||||
width: min(90vw, 680px);
|
||||
max-width: 680px;
|
||||
min-height: clamp(240px, 36vh, 340px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
perspective: 1100px;
|
||||
overflow: hidden;
|
||||
border-radius: 24px;
|
||||
background: radial-gradient(circle at 50% 50%, rgba(255,255,255,0.07), rgba(255,255,255,0.02) 58%, transparent 100%);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
box-shadow: inset 0 0 48px rgba(0,0,0,0.35);
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
.cooking-wheel::before,
|
||||
.cooking-wheel::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.cooking-wheel::before {
|
||||
background: radial-gradient(circle at 50% 50%, rgba(56, 189, 248, 0.18), rgba(251, 191, 36, 0.08) 36%, transparent 72%);
|
||||
opacity: var(--wheel-glow);
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.cooking-wheel::after {
|
||||
background: linear-gradient(180deg, rgba(0,0,0,0.34) 0%, transparent 20%, transparent 80%, rgba(0,0,0,0.34) 100%);
|
||||
}
|
||||
|
||||
.cooking-step-text,
|
||||
.cooking-step-ghost {
|
||||
width: min(88vw, 620px);
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
padding: 18px 20px;
|
||||
border-radius: 20px;
|
||||
transform-style: preserve-3d;
|
||||
transition: transform 0.22s ease, opacity 0.22s ease;
|
||||
}
|
||||
|
||||
.cooking-step-text {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
font-size: clamp(1.35rem, 4.6vw, 2.1rem);
|
||||
font-weight: 600;
|
||||
font-size: clamp(1.4rem, 5vw, 2.2rem);
|
||||
line-height: 1.5;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.14), rgba(255,255,255,0.06));
|
||||
border: 1px solid rgba(255,255,255,0.20);
|
||||
box-shadow: 0 18px 35px rgba(0,0,0,0.45), inset 0 1px 0 rgba(255,255,255,0.24);
|
||||
transform: rotateX(var(--wheel-tilt-x)) rotateY(var(--wheel-tilt-y));
|
||||
animation: cookingCardFloat 4.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.cooking-step-ghost {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 0;
|
||||
transform-origin: center center;
|
||||
font-size: clamp(1.08rem, 3.9vw, 1.52rem);
|
||||
font-weight: 560;
|
||||
color: rgba(255,255,255,0.95);
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.18), rgba(255,255,255,0.08));
|
||||
border: 1px solid rgba(255,255,255,0.22);
|
||||
box-shadow: 0 12px 28px rgba(0,0,0,0.38), inset 0 1px 0 rgba(255,255,255,0.22);
|
||||
text-shadow: 0 1px 2px rgba(0,0,0,0.34);
|
||||
opacity: 0.66;
|
||||
max-height: 42%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cooking-step-prev {
|
||||
color: rgba(255, 243, 210, 0.97);
|
||||
background: linear-gradient(180deg, rgba(251, 191, 36, 0.24), rgba(251, 191, 36, 0.10));
|
||||
border-color: rgba(251, 191, 36, 0.36);
|
||||
transform: translateX(-50%) rotateX(56deg) rotateY(calc(var(--wheel-tilt-y) * 0.28)) scale(0.9);
|
||||
}
|
||||
|
||||
.cooking-step-next {
|
||||
color: rgba(220, 243, 255, 0.97);
|
||||
background: linear-gradient(180deg, rgba(56, 189, 248, 0.24), rgba(56, 189, 248, 0.10));
|
||||
border-color: rgba(56, 189, 248, 0.36);
|
||||
transform: translateX(-50%) rotateX(-56deg) rotateY(calc(var(--wheel-tilt-y) * 0.28)) scale(0.9);
|
||||
}
|
||||
|
||||
.cooking-step-ghost.is-empty {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.cooking-wheel.turn-next .cooking-step-text {
|
||||
animation: cookingWheelCenterNext 0.34s ease;
|
||||
}
|
||||
|
||||
.cooking-wheel.turn-prev .cooking-step-text {
|
||||
animation: cookingWheelCenterPrev 0.34s ease;
|
||||
}
|
||||
|
||||
.cooking-wheel.turn-next .cooking-step-prev {
|
||||
animation: cookingWheelGhostNext 0.34s ease;
|
||||
}
|
||||
|
||||
.cooking-wheel.turn-prev .cooking-step-next {
|
||||
animation: cookingWheelGhostPrev 0.34s ease;
|
||||
}
|
||||
|
||||
.cooking-wheel.snap .cooking-step-text {
|
||||
animation: cookingWheelSnap 0.28s ease;
|
||||
}
|
||||
|
||||
@keyframes cookingWheelCenterNext {
|
||||
from { transform: translateY(20px) rotateX(-10deg); opacity: 0.75; }
|
||||
to { transform: translateY(0) rotateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes cookingWheelCenterPrev {
|
||||
from { transform: translateY(-20px) rotateX(10deg); opacity: 0.75; }
|
||||
to { transform: translateY(0) rotateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes cookingWheelGhostNext {
|
||||
from { opacity: 0.1; transform: translateX(-50%) rotateX(68deg) scale(0.84); }
|
||||
to { opacity: 0.66; transform: translateX(-50%) rotateX(56deg) scale(0.9); }
|
||||
}
|
||||
|
||||
@keyframes cookingWheelGhostPrev {
|
||||
from { opacity: 0.1; transform: translateX(-50%) rotateX(-68deg) scale(0.84); }
|
||||
to { opacity: 0.66; transform: translateX(-50%) rotateX(-56deg) scale(0.9); }
|
||||
}
|
||||
|
||||
@keyframes cookingCardFloat {
|
||||
0%, 100% { transform: rotateX(var(--wheel-tilt-x)) rotateY(var(--wheel-tilt-y)) translateY(0); }
|
||||
50% { transform: rotateX(var(--wheel-tilt-x)) rotateY(var(--wheel-tilt-y)) translateY(-3px); }
|
||||
}
|
||||
|
||||
@keyframes cookingWheelSnap {
|
||||
0% { transform: rotateX(var(--wheel-tilt-x)) rotateY(var(--wheel-tilt-y)) scale(0.97); }
|
||||
70% { transform: rotateX(var(--wheel-tilt-x)) rotateY(var(--wheel-tilt-y)) scale(1.018); }
|
||||
100% { transform: rotateX(var(--wheel-tilt-x)) rotateY(var(--wheel-tilt-y)) scale(1); }
|
||||
text-align: center;
|
||||
max-width: 560px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cooking-replay-btn {
|
||||
@@ -4550,28 +4341,6 @@ body.cooking-mode-active .app-header {
|
||||
}
|
||||
.cooking-timers-bar::-webkit-scrollbar { display: none; }
|
||||
|
||||
.cooking-tools-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 14px;
|
||||
background: rgba(99,102,241,0.15);
|
||||
border-bottom: 1px solid rgba(99,102,241,0.25);
|
||||
flex-shrink: 0;
|
||||
font-size: 0.78rem;
|
||||
color: #c7d2fe;
|
||||
}
|
||||
.cooking-tool-chip {
|
||||
background: rgba(99,102,241,0.25);
|
||||
border: 1px solid rgba(99,102,241,0.4);
|
||||
border-radius: 20px;
|
||||
padding: 2px 9px;
|
||||
font-size: 0.76rem;
|
||||
color: #e0e7ff;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cooking-timer-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -4807,32 +4576,6 @@ body.cooking-mode-active .app-header {
|
||||
background: rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.cooking-wheel {
|
||||
min-height: clamp(210px, 34vh, 300px);
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.cooking-step-text,
|
||||
.cooking-step-ghost {
|
||||
padding: 14px 14px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cooking-step-text,
|
||||
.cooking-step-ghost,
|
||||
.cooking-wheel.turn-next .cooking-step-text,
|
||||
.cooking-wheel.turn-prev .cooking-step-text,
|
||||
.cooking-wheel.turn-next .cooking-step-prev,
|
||||
.cooking-wheel.turn-prev .cooking-step-next,
|
||||
.cooking-wheel.snap .cooking-step-text {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Cooking button in recipe dialog */
|
||||
.btn-cooking {
|
||||
background: linear-gradient(135deg, #1e3a5f, #2d5016);
|
||||
|
||||
|
Before Width: | Height: | Size: 9.7 MiB |
|
Before Width: | Height: | Size: 289 KiB After Width: | Height: | Size: 289 KiB |
|
Before Width: | Height: | Size: 237 KiB After Width: | Height: | Size: 238 KiB |
@@ -36,12 +36,12 @@ Overview of what the wizard will configure.
|
||||
### Step 3 — Permissions
|
||||
Grant camera, microphone, and storage permissions needed by the web app.
|
||||
|
||||
The button transforms from **"Grant permissions"** to **"✅ Permissions granted — Continue →"** (green) once all permissions are granted.
|
||||
The button transforms from "Concedi permessi" to **"✅ Permessi concessi — Continua →"** (green) once all permissions are granted.
|
||||
|
||||
### Step 4 — Server URL
|
||||
Enter your EverShelf server URL (e.g. `https://192.168.1.100/dispensa`).
|
||||
|
||||
**Or tap "Auto-discover"** to let the wizard scan your LAN:
|
||||
**Or tap "Rileva automaticamente"** to let the wizard scan your LAN:
|
||||
- 60 parallel threads, TCP pre-check, ports 80/443/8080/8443
|
||||
- Only scans your actual Wi-Fi/Ethernet subnet (VPN and cellular interfaces ignored)
|
||||
- Real-time feedback as hosts are tested
|
||||
@@ -62,23 +62,9 @@ All done — the web app loads in full-screen kiosk mode.
|
||||
|
||||
---
|
||||
|
||||
## Header Overlay Buttons
|
||||
|
||||
Three buttons are injected into the top-left of the web header by the kiosk app:
|
||||
|
||||
| Button | Action |
|
||||
|--------|--------|
|
||||
| **✕** | Exit kiosk mode (confirmation dialog) |
|
||||
| **↻** | Hard-refresh — clears WebView cache and reloads the app |
|
||||
| **⚙️** | Open EverShelf Settings |
|
||||
|
||||
The native Android settings button is permanently hidden once the overlay is injected — the **⚙️** web button replaces it entirely.
|
||||
|
||||
---
|
||||
|
||||
## Exiting Kiosk Mode
|
||||
|
||||
Tap the **✕** button in the header overlay (top-left). A confirmation dialog appears.
|
||||
Tap the **✕** button in the header (top-left). A confirmation dialog appears.
|
||||
|
||||
---
|
||||
|
||||
@@ -147,6 +133,6 @@ Requires Android Studio or JDK 17+ with the Android SDK.
|
||||
| `CAMERA` | Barcode scanning and AI photo identification |
|
||||
| `RECORD_AUDIO` | Voice input in AI chat |
|
||||
| `WAKE_LOCK` | Keep the screen on |
|
||||
| `REQUEST_INSTALL_PACKAGES` | Over-the-air kiosk self-updates (installs new APK from GitHub releases) |
|
||||
| `REQUEST_INSTALL_PACKAGES` | Install the Scale Gateway APK |
|
||||
| `ACCESS_WIFI_STATE` | LAN auto-discovery |
|
||||
| `REORDER_TASKS` | Bring the kiosk app to foreground when needed |
|
||||
| `REORDER_TASKS` | Bring app to foreground after gateway launch |
|
||||
|
||||
@@ -43,13 +43,15 @@ docker compose up -d
|
||||
|
||||
## AI Features
|
||||
|
||||
### "AI not available" error
|
||||
### AI features don't work / "AI non disponibile"
|
||||
|
||||
1. Check that `GEMINI_API_KEY` is set in `.env`
|
||||
2. Verify the key is valid at [aistudio.google.com](https://aistudio.google.com)
|
||||
3. Check that you haven't exceeded the free tier quota (15 req/min, 1500 req/day)
|
||||
4. Look for errors in the PHP error log
|
||||
|
||||
### Recipe generation stops midway
|
||||
|
||||
This is usually a Gemini API timeout. The app streams results via SSE — if the server PHP timeout is too low, the stream is cut short. Increase `max_execution_time` in `php.ini`:
|
||||
|
||||
```ini
|
||||
@@ -60,7 +62,7 @@ max_execution_time = 120
|
||||
|
||||
## Shopping List (Bring!)
|
||||
|
||||
### "Bring! not configured" message in the shopping tab
|
||||
### "Bring! non configurato" message in the shopping tab
|
||||
|
||||
Add your Bring! credentials to `.env`:
|
||||
|
||||
@@ -88,7 +90,7 @@ BRING_PASSWORD=yourpassword
|
||||
### Scale shows weight but form doesn't auto-fill
|
||||
|
||||
- The auto-fill only triggers for products with unit `g` or `ml`
|
||||
- Make sure you tapped **"⚖️ Read Scale"** first to activate the scale modal
|
||||
- Make sure you tapped "⚖️ Leggi bilancia" first to activate the scale modal
|
||||
- The weight must stabilize (stay within 10g) for the countdown to start
|
||||
|
||||
### Bluetooth scale not appearing in the gateway app
|
||||
@@ -107,19 +109,19 @@ BRING_PASSWORD=yourpassword
|
||||
- Try entering the URL manually instead of using auto-discovery
|
||||
- Check that the server responds on the expected port (80/443/8080/8443)
|
||||
|
||||
### Kiosk app update fails
|
||||
### Gateway install fails with an error dialog
|
||||
|
||||
The kiosk checks for a new release every 6 hours and downloads it from GitHub. If the install fails:
|
||||
The dialog shows the exact failure code. Common causes:
|
||||
|
||||
| Symptom | Fix |
|
||||
|---------|-----|
|
||||
| "Install from unknown sources" dialog | Enable the setting for the EverShelf Kiosk app in Android Settings |
|
||||
| Persistent failure after download | Force-stop the app, clear its data, and relaunch the update flow |
|
||||
| Not enough space | Free up storage on the device |
|
||||
| Code | Cause | Fix |
|
||||
|------|-------|-----|
|
||||
| `STATUS_FAILURE` (1) | Generic install failure — often OEM restriction | Enable "Install from unknown sources" for the kiosk app in Android Settings |
|
||||
| `STATUS_FAILURE_CONFLICT` (3) | Signature mismatch with existing install | Uninstall the old gateway app, then retry |
|
||||
| `STATUS_FAILURE_STORAGE` (6) | Not enough storage | Free up space on the device |
|
||||
|
||||
### Exit button (✕) is not visible
|
||||
|
||||
Three buttons are always visible in the kiosk header overlay: **✕** (exit), **↻** (refresh), **⚙️** (settings). If the page failed to load entirely, tap **↻** first. If nothing is visible, restart the device.
|
||||
The ✕ button is injected into the header by the kiosk app. If the web app's header is covered or the page failed to load, try the hard refresh (↻) button. If neither is visible, triple-tap the page title area to access the developer settings.
|
||||
|
||||
### App is stuck in kiosk mode after a crash
|
||||
|
||||
@@ -137,7 +139,7 @@ The version is cached by the browser. Do a hard refresh:
|
||||
|
||||
### Transactions are missing from the log
|
||||
|
||||
The log shows the last 50 entries by default. Tap **"Load more"** to load more. Entries older than the database creation date won't appear.
|
||||
The log shows the last 50 entries by default. Tap "Carica altri" to load more. Entries older than the database creation date won't appear.
|
||||
|
||||
### "Can only undo transactions within 24 hours"
|
||||
|
||||
|
||||
@@ -83,9 +83,9 @@ Recipes stream live via Server-Sent Events so results appear as they are generat
|
||||
### AI Chat Assistant
|
||||
|
||||
Open **💬 Chat** to ask questions like:
|
||||
- "What can I make with eggs and pasta?"
|
||||
- "How long does cooked ham last once opened in the fridge?"
|
||||
- "Suggest a quick snack"
|
||||
- "Cosa posso fare con le uova e la pasta?"
|
||||
- "Quanti giorni dura il prosciutto cotto aperto in frigo?"
|
||||
- "Suggeriscimi uno spuntino veloce"
|
||||
|
||||
The assistant knows your current inventory.
|
||||
|
||||
@@ -121,7 +121,7 @@ Configure `BRING_EMAIL` and `BRING_PASSWORD` in `.env` to enable.
|
||||
|
||||
## 🍳 Cooking Mode
|
||||
|
||||
Start cooking mode from any recipe by tapping **▶ Start Cooking**.
|
||||
Start cooking mode from any recipe by tapping **▶ Avvia cottura**.
|
||||
|
||||
### Features
|
||||
|
||||
@@ -132,7 +132,7 @@ Start cooking mode from any recipe by tapping **▶ Start Cooking**.
|
||||
- Custom REST endpoint (e.g. Home Assistant)
|
||||
- **Built-in timers** — automatic timer suggestions based on recipe text; 10-second vocal countdown warning before expiry
|
||||
- **Ingredient tracking** — mark ingredients as used; leftover quantities prompt a "move to another location" flow
|
||||
- **Recipe completion** — "Buon appetito!" *(Enjoy your meal!)* spoken on the last step
|
||||
- **Recipe completion** — "Buon appetito!" spoken on the last step
|
||||
|
||||
---
|
||||
|
||||
@@ -155,8 +155,8 @@ Actions per item: Use, Throw away, Edit, Dismiss. Swipe or tap arrows to navigat
|
||||
Highlights suspicious quantities (e.g. "You have 0 eggs but used 12 this month"). Actions:
|
||||
- One-tap correction to the suggested quantity
|
||||
- Inline edit with free-form quantity
|
||||
- "🤖 Explain" for AI explanation
|
||||
- Dismiss (with current quantity shown: "The quantity is correct (2 pcs)")
|
||||
- "🤖 Spiega" for AI explanation
|
||||
- Dismiss (with current quantity shown: "La quantità è giusta (2 pz)")
|
||||
|
||||
### Anti-Waste Report
|
||||
|
||||
|
||||
@@ -47,11 +47,8 @@ All data stays on your server. No cloud, no subscriptions.
|
||||
## 🆕 What's New
|
||||
|
||||
### v1.7.13 (2026-05-16)
|
||||
- **Fix:** Kiosk Settings button (⚙️) added to the web overlay — tapping the camera button no longer accidentally opens kiosk settings
|
||||
- **Fix:** Opened-item expiry badge is now consistent with the top banner: low-risk items (jams, condiments) show amber ⚠️ "Check soon" instead of misleading red ⛔ "Expired"
|
||||
- **Cooking Mode:** 3D wheel UI with perspective card flip, ghost steps (prev/next), float animation, and full `prefers-reduced-motion` support
|
||||
- **CI:** `data/category_ai_cache.json` added to `.gitignore`
|
||||
- **Critical fix (DB):** Fresh-install crash resolved — `transactions` schema was missing the `undone` column
|
||||
- **Critical fix:** Fresh-install crash resolved — `transactions` schema was missing the `undone` column, causing a database failure on every new installation
|
||||
- **Fix:** Race condition in DB migrations no longer causes `duplicate column name` errors on concurrent first requests
|
||||
|
||||
### v1.7.12 (2026-05-13)
|
||||
- "Use first" banner now shows opening date and location instead of a confusing calculated expiry
|
||||
@@ -84,7 +81,7 @@ EverShelf/
|
||||
├── translations/ # i18n JSON files (it, en, de)
|
||||
├── docs/openapi.yaml # OpenAPI 3.0 spec
|
||||
├── evershelf-kiosk/ # Android kiosk app (Kotlin)
|
||||
└── evershelf-scale-gateway/ # Android BLE gateway app (Kotlin) — DEPRECATED, built into kiosk since v1.6.0
|
||||
└── evershelf-scale-gateway/ # Android BLE gateway app (Kotlin)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -75,7 +75,7 @@ In EverShelf **Settings → Scale**:
|
||||
|
||||
### 4. Connect your scale
|
||||
|
||||
Tap **"Find Bluetooth Scales"**. Make sure your scale is powered on. Tap it in the list to pair and connect.
|
||||
Tap **"Cerca Bilance Bluetooth"** (Find Bluetooth Scales). Make sure your scale is powered on. Tap it in the list to pair and connect.
|
||||
|
||||
---
|
||||
|
||||
@@ -84,7 +84,7 @@ Tap **"Find Bluetooth Scales"**. Make sure your scale is powered on. Tap it in t
|
||||
When scale integration is enabled:
|
||||
|
||||
1. Open the **Add** or **Use** form for any product with unit `g` or `ml`
|
||||
2. A **"⚖️ Read Scale"** button appears
|
||||
2. A **"⚖️ Leggi bilancia"** button appears
|
||||
3. Tap it — a live weight display appears with a stability indicator
|
||||
4. Step on or place the product on the scale
|
||||
5. When the reading stabilizes, a **5-second countdown** starts
|
||||
@@ -127,7 +127,7 @@ Every 6 hours the gateway app checks GitHub releases. If a newer version is avai
|
||||
### Weight not appearing in EverShelf
|
||||
- Confirm the Gateway URL in EverShelf Settings matches the URL shown in the gateway app
|
||||
- Check that the Android device and the EverShelf server are on the same network
|
||||
- Tap "Disconnect / Reconnect" in the gateway app to refresh the WebSocket connection
|
||||
- Tap "Disconnetti / Riconnetti" in the gateway app to refresh the WebSocket connection
|
||||
|
||||
### "Mixed content" error in browser
|
||||
- Make sure you are accessing EverShelf over HTTPS (not plain HTTP)
|
||||
|
||||
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "it.dadaloop.evershelf.kiosk"
|
||||
minSdk = 24
|
||||
targetSdk = 34
|
||||
versionCode = 15
|
||||
versionName = "1.7.14"
|
||||
versionCode = 13
|
||||
versionName = "1.7.2"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
||||
@@ -515,17 +515,6 @@ class KioskActivity : AppCompatActivity() {
|
||||
btnSettings.visibility = if (visible) View.VISIBLE else View.GONE
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Open the native SettingsActivity from the webapp settings page.
|
||||
* Allows configuring server URL, BLE scale and screensaver without
|
||||
* the user having to find the native gear button.
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun openNativeSettings() {
|
||||
runOnUiThread {
|
||||
startActivity(Intent(this@KioskActivity, SettingsActivity::class.java))
|
||||
}
|
||||
}
|
||||
}, "_kioskBridge")
|
||||
|
||||
val url = prefs.getString(KEY_URL, "http://evershelf.local") ?: "http://evershelf.local"
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
# Build trigger: versionName 1.7.13 fix (8d87494)
|
||||
# Build trigger: TTS bridge fix (95389eb)
|
||||
# Build trigger: v1.7.14 with openNativeSettings fix (834d8ef)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.gradle/
|
||||
build/
|
||||
local.properties
|
||||
*.apk
|
||||
*.aab
|
||||
*.class
|
||||
*.dex
|
||||
@@ -0,0 +1,156 @@
|
||||
# ~~EverShelf Scale Gateway~~ — DEPRECATED
|
||||
|
||||
> ⚠️ **This app is deprecated and no longer maintained.**
|
||||
>
|
||||
> As of **EverShelf Kiosk v1.6.0**, BLE scale support is fully integrated into the kiosk app itself. You no longer need to install or configure this separate gateway app.
|
||||
>
|
||||
> **If you are using the EverShelf Kiosk app** → the scale gateway runs automatically as a background service. Configure your Bluetooth scale in **step 4 of the setup wizard**.
|
||||
>
|
||||
> **If you are NOT using the kiosk app** (standalone Android tablet) → you may still use this APK, but no new releases will be published.
|
||||
|
||||
---
|
||||
|
||||
# EverShelf Scale Gateway (legacy)
|
||||
|
||||
> Android gateway app that bridges Bluetooth LE smart scales with EverShelf via WebSocket.
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
Smart Scale ──(BLE)──► Android Gateway App ──(WebSocket/LAN)──► EverShelf Server ──(SSE)──► Browser
|
||||
```
|
||||
|
||||
The app runs a local WebSocket server (port **8765**) on your Android device. The EverShelf server connects to it via a server-side relay (`api/scale_relay.php` SSE + `api/scale_ping.php` WebSocket client), avoiding mixed-content (HTTPS→WS) issues. Weight readings are streamed to the browser in real time.
|
||||
|
||||
> **Kiosk integration (v1.6.0+):** The gateway is now **built into the EverShelf Kiosk app** as a foreground service. This separate app is not needed when using the kiosk.
|
||||
|
||||
---
|
||||
|
||||
## Supported scale protocols
|
||||
|
||||
| Protocol | Service UUID | Notes |
|
||||
|---|---|---|
|
||||
| **Bluetooth SIG Weight Scale** | `0x181D` / char `0x2A9D` | Most compatible; works with most smart scales |
|
||||
| **Bluetooth SIG Body Composition** | `0x181B` / char `0x2A9C` | Reports weight + body fat %, BMI |
|
||||
| **Generic fallback** | Any notifiable characteristic | Auto-heuristic parsing for 100+ models |
|
||||
|
||||
### Verified compatible scales (community list)
|
||||
- Xiaomi Mi Body Composition Scale 2
|
||||
- Renpho Smart Body Fat Scale
|
||||
- INEVIFIT Smart Body Fat Scale
|
||||
- Any OpenScale-compatible scale (see [openScale supported devices](https://github.com/oliexdev/openScale/wiki/Supported-scales))
|
||||
|
||||
> **Your scale (B09MRXVBV6):** If it implements the standard BLE Weight Scale or Body Composition profile (very likely for modern Amazon smart scales), the gateway will connect automatically. If not, check the [openScale wiki](https://github.com/oliexdev/openScale/wiki/Supported-scales) and open an issue.
|
||||
|
||||
---
|
||||
|
||||
## Download
|
||||
|
||||
Download the latest APK directly: **[evershelf-scale-gateway.apk](https://github.com/dadaloop82/EverShelf/releases/latest/download/evershelf-scale-gateway.apk)**
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- Android **7.0** (API 24) or later
|
||||
- Bluetooth LE (BLE) support
|
||||
- Both the Android device and the device running EverShelf must be on the **same Wi-Fi network**
|
||||
|
||||
---
|
||||
|
||||
## Setup (step by step)
|
||||
|
||||
### 1. Install the APK
|
||||
Download and install the APK from the Releases page. You may need to allow "Install from unknown sources" in Android settings.
|
||||
|
||||
### 2. Launch the app
|
||||
The app starts the WebSocket gateway server immediately. You will see the **gateway URL** (e.g. `ws://192.168.1.100:8765`) at the top.
|
||||
|
||||
### 3. Connect your scale
|
||||
Tap **"Cerca Bilance Bluetooth"** (Find Bluetooth Scales). Make sure your scale is turned on. Tap it in the list to connect.
|
||||
|
||||
### 4. Configure EverShelf
|
||||
In EverShelf → ⚙️ Settings → **⚖️ Bilancia Smart**:
|
||||
1. Enable the toggle
|
||||
2. Paste the gateway URL shown in the Android app
|
||||
3. Tap **"Testa connessione"** — you should see ✅
|
||||
|
||||
### 5. Use it
|
||||
When adding or consuming a product with unit **g** or **ml**, a **"⚖️ Leggi dalla bilancia"** button appears. Tap it, place the product on the scale, and the weight is filled in automatically.
|
||||
|
||||
---
|
||||
|
||||
## WebSocket protocol reference
|
||||
|
||||
All messages are JSON. The server sends these to connected clients:
|
||||
|
||||
```json
|
||||
// Scale status update
|
||||
{"type":"status","state":"connected","device":"Mi Scale 2","battery":85}
|
||||
{"type":"status","state":"disconnected"}
|
||||
|
||||
// Weight reading (broadcast continuously while scale is active)
|
||||
{"type":"weight","value":72.50,"unit":"kg","stable":true,"timestamp":1712345678000}
|
||||
|
||||
// Response to ping
|
||||
{"type":"pong"}
|
||||
```
|
||||
|
||||
Clients can send:
|
||||
|
||||
```json
|
||||
{"type":"get_status"} // Request current status
|
||||
{"type":"get_weight"} // Request next stable weight reading
|
||||
{"type":"ping"} // Keep-alive
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Build from source
|
||||
|
||||
### Prerequisites
|
||||
- Android Studio Hedgehog (2023.1) or later
|
||||
- Java 8+
|
||||
|
||||
### Steps
|
||||
```bash
|
||||
# 1. Clone the repo
|
||||
git clone https://github.com/dadaloop82/EverShelf.git
|
||||
cd EverShelf/evershelf-scale-gateway
|
||||
|
||||
# 2. Download the Gradle wrapper (if not included)
|
||||
gradle wrapper --gradle-version 8.4
|
||||
|
||||
# 3. Build debug APK
|
||||
./gradlew assembleDebug
|
||||
|
||||
# APK is at: app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
evershelf-scale-gateway/
|
||||
├── app/src/main/
|
||||
│ ├── kotlin/it/dadaloop/evershelf/scalegate/
|
||||
│ │ ├── MainActivity.kt — UI, orchestration
|
||||
│ │ ├── BleScaleManager.kt — BLE scanning & GATT connection
|
||||
│ │ ├── ScaleProtocol.kt — Parsing for all supported protocols
|
||||
│ │ └── GatewayWebSocketServer.kt — WebSocket server (Java-WebSocket)
|
||||
│ ├── res/layout/
|
||||
│ │ ├── activity_main.xml
|
||||
│ │ └── item_device.xml
|
||||
│ └── AndroidManifest.xml
|
||||
├── build.gradle.kts
|
||||
└── settings.gradle.kts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](../LICENSE)
|
||||
@@ -0,0 +1,41 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "it.dadaloop.evershelf.scalegate"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "it.dadaloop.evershelf.scalegate"
|
||||
minSdk = 24
|
||||
targetSdk = 34
|
||||
versionCode = 8
|
||||
versionName = "2.1.1"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.core:core-ktx:1.12.0")
|
||||
implementation("androidx.appcompat:appcompat:1.6.1")
|
||||
implementation("com.google.android.material:material:1.11.0")
|
||||
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
|
||||
implementation("androidx.recyclerview:recyclerview:1.3.2")
|
||||
// WebSocket server
|
||||
implementation("org.java-websocket:Java-WebSocket:1.5.5")
|
||||
// Coroutines
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- BLE permissions for Android < 12 -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH"
|
||||
android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
|
||||
android:maxSdkVersion="30" />
|
||||
|
||||
<!-- BLE permissions for Android 12+ -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
|
||||
android:usesPermissionFlags="neverForLocation" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
|
||||
<!-- Location (required for BLE scanning on Android 6–11) -->
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
|
||||
<!-- Network (for WebSocket server) -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
|
||||
<!-- Keep screen on while gateway is active -->
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<!-- Self-update: install APK downloaded at runtime -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.MaterialComponents.Light.NoActionBar">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- FileProvider for serving the downloaded APK to the installer -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.provider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,455 @@
|
||||
package it.dadaloop.evershelf.scalegate
|
||||
|
||||
import android.Manifest
|
||||
import android.bluetooth.*
|
||||
import android.bluetooth.le.*
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
private const val TAG = "BleScaleManager"
|
||||
private const val SCAN_PERIOD_MS = 15_000L
|
||||
private const val PREFS_NAME = "evershelf_gateway"
|
||||
private const val PREF_LAST_DEVICE = "last_device_address"
|
||||
|
||||
/**
|
||||
* Represents a discovered BLE device during scan.
|
||||
*/
|
||||
data class BleDeviceInfo(
|
||||
val device: BluetoothDevice,
|
||||
val name: String,
|
||||
val rssi: Int,
|
||||
val proximity: String,
|
||||
val scaleScore: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Callback interface for BLE events dispatched back to the UI.
|
||||
*/
|
||||
interface BleScaleListener {
|
||||
fun onDeviceFound(info: BleDeviceInfo)
|
||||
fun onConnecting(device: BluetoothDevice)
|
||||
fun onConnected(deviceName: String)
|
||||
fun onDisconnected()
|
||||
fun onWeightReceived(reading: WeightReading)
|
||||
fun onBatteryReceived(level: Int)
|
||||
fun onError(message: String)
|
||||
fun onScanStopped()
|
||||
fun onDebugEvent(message: String)
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages BLE scanning and connection to a smart scale.
|
||||
* All listener callbacks are dispatched on the main thread.
|
||||
*/
|
||||
class BleScaleManager(
|
||||
private val context: Context,
|
||||
private val listener: BleScaleListener,
|
||||
) {
|
||||
private val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
private val bluetoothAdapter: BluetoothAdapter? get() = bluetoothManager.adapter
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
private var leScanner: BluetoothLeScanner? = null
|
||||
private var gatt: BluetoothGatt? = null
|
||||
private var isScanning = false
|
||||
private var connectedDeviceName: String = ""
|
||||
private var autoConnectAddress: String? = null
|
||||
|
||||
// The characteristics we will subscribe to (multiple may exist).
|
||||
private val pendingSubscriptions = ArrayDeque<BluetoothGattCharacteristic>()
|
||||
|
||||
// ─── Public state ──────────────────────────────────────────────────────────
|
||||
|
||||
val isConnected: Boolean get() = gatt != null && connectedDeviceName.isNotEmpty()
|
||||
|
||||
// ─── Saved device (auto-reconnect) ─────────────────────────────────────────
|
||||
|
||||
fun getSavedDeviceAddress(): String? {
|
||||
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.getString(PREF_LAST_DEVICE, null)
|
||||
}
|
||||
|
||||
private fun saveDeviceAddress(address: String) {
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit().putString(PREF_LAST_DEVICE, address).apply()
|
||||
}
|
||||
|
||||
fun enableAutoConnect() {
|
||||
autoConnectAddress = getSavedDeviceAddress()
|
||||
}
|
||||
|
||||
// ─── Permissions helper ────────────────────────────────────────────────────
|
||||
|
||||
fun hasRequiredPermissions(): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED
|
||||
} else {
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Scanning ──────────────────────────────────────────────────────────────
|
||||
|
||||
fun startScan() {
|
||||
val adapter = bluetoothAdapter ?: run {
|
||||
listener.onError("Bluetooth not available on this device.")
|
||||
return
|
||||
}
|
||||
if (!adapter.isEnabled) {
|
||||
listener.onError("Bluetooth is off. Enable it and try again.")
|
||||
return
|
||||
}
|
||||
if (isScanning) stopScan()
|
||||
|
||||
leScanner = adapter.bluetoothLeScanner
|
||||
val settings = ScanSettings.Builder()
|
||||
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
|
||||
.build()
|
||||
|
||||
// No service UUID filters — many consumer scales use proprietary UUIDs
|
||||
// and would be invisible with strict filtering. We show all named BLE devices.
|
||||
isScanning = true
|
||||
try {
|
||||
leScanner?.startScan(null, settings, scanCallback)
|
||||
} catch (e: Exception) {
|
||||
leScanner?.startScan(scanCallback)
|
||||
}
|
||||
|
||||
// Auto-stop after SCAN_PERIOD_MS
|
||||
mainHandler.postDelayed({
|
||||
stopScan()
|
||||
listener.onScanStopped()
|
||||
}, SCAN_PERIOD_MS)
|
||||
}
|
||||
|
||||
fun stopScan() {
|
||||
if (!isScanning) return
|
||||
isScanning = false
|
||||
try {
|
||||
leScanner?.stopScan(scanCallback)
|
||||
} catch (e: Exception) { /* ignore */ }
|
||||
leScanner = null
|
||||
}
|
||||
|
||||
private val scanCallback = object : ScanCallback() {
|
||||
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||
val device = result.device
|
||||
val name = result.scanRecord?.deviceName?.takeIf { it.isNotBlank() }
|
||||
?: getDeviceName(device)
|
||||
val proximity = rssiToProximity(result.rssi)
|
||||
val score = scoreLikelyScale(name, result.scanRecord)
|
||||
val info = BleDeviceInfo(device, name, result.rssi, proximity, score)
|
||||
mainHandler.post { listener.onDeviceFound(info) }
|
||||
|
||||
// Auto-connect to saved device
|
||||
if (autoConnectAddress != null && device.address == autoConnectAddress && !isConnected) {
|
||||
autoConnectAddress = null // prevent re-trigger
|
||||
mainHandler.post {
|
||||
listener.onDebugEvent("\uD83D\uDD04 Auto-connecting to $name (${device.address})")
|
||||
connect(device)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onScanFailed(errorCode: Int) {
|
||||
isScanning = false
|
||||
mainHandler.post { listener.onError("BLE scan failed (code: $errorCode)") }
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDeviceName(device: BluetoothDevice): String {
|
||||
return try {
|
||||
device.name?.takeIf { it.isNotBlank() } ?: "Unnamed"
|
||||
} catch (e: SecurityException) {
|
||||
"Unnamed"
|
||||
}
|
||||
}
|
||||
|
||||
private fun rssiToProximity(rssi: Int) = when {
|
||||
rssi >= -60 -> "📶 Near"
|
||||
rssi >= -80 -> "📶 Medium"
|
||||
else -> "📶 Far"
|
||||
}
|
||||
|
||||
private fun scoreLikelyScale(name: String, scanRecord: android.bluetooth.le.ScanRecord?): Int {
|
||||
var score = 0
|
||||
val lower = name.lowercase()
|
||||
// Kitchen / food scale brand and model keywords
|
||||
val foodKeywords = listOf(
|
||||
"scale", "bilancia", "kitchen", "food", "cucina",
|
||||
"coffee", "caffe", "balance", "weight", "waage",
|
||||
"arboleaf", "ck10", "ck20", "ek-",
|
||||
"acaia", "felicita", "decent", "skale",
|
||||
"timemore", "brewista", "hario",
|
||||
"greater goods", "ozeri", "etekcity", "nutri",
|
||||
"nicewell", "koios", "renpho", "eatsmart",
|
||||
)
|
||||
if (foodKeywords.any { lower.contains(it) }) score += 10
|
||||
|
||||
// Negative: body/fitness scale keywords (demote but don't hide)
|
||||
val bodyKeywords = listOf(
|
||||
"body", "fat", "bmi", "composition", "fitness",
|
||||
"mi body", "lepulse", "qardio", "garmin", "withings",
|
||||
)
|
||||
if (bodyKeywords.any { lower.contains(it) }) score -= 5
|
||||
|
||||
// Service UUID scoring
|
||||
scanRecord?.serviceUuids?.let { uuids ->
|
||||
val us = uuids.map { it.uuid.toString().lowercase() }
|
||||
// SIG Weight Scale service
|
||||
if (us.any { it.startsWith("0000181d") }) score += 15
|
||||
// Common vendor services on kitchen scales
|
||||
if (us.any { it.startsWith("0000ffe0") || it.startsWith("0000fff0") }) score += 10
|
||||
// Acaia coffee scale
|
||||
if (us.any { it.startsWith("49535343") }) score += 20
|
||||
// Body Composition service = body scale, demote
|
||||
if (us.any { it.startsWith("0000181b") }) score -= 10
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
// ─── Connection ────────────────────────────────────────────────────────────
|
||||
|
||||
fun connect(device: BluetoothDevice) {
|
||||
stopScan()
|
||||
disconnect()
|
||||
connectedDeviceName = ""
|
||||
ScaleProtocol.resetState()
|
||||
mainHandler.post { listener.onConnecting(device) }
|
||||
try {
|
||||
gatt = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
|
||||
} else {
|
||||
device.connectGatt(context, false, gattCallback)
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
mainHandler.post { listener.onError("Missing permission: ${e.message}") }
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
pendingSubscriptions.clear()
|
||||
try {
|
||||
gatt?.disconnect()
|
||||
gatt?.close()
|
||||
} catch (e: Exception) { /* ignore */ }
|
||||
gatt = null
|
||||
connectedDeviceName = ""
|
||||
}
|
||||
|
||||
// ─── GATT callbacks ────────────────────────────────────────────────────────
|
||||
|
||||
private val gattCallback = object : BluetoothGattCallback() {
|
||||
|
||||
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
|
||||
when (newState) {
|
||||
BluetoothProfile.STATE_CONNECTED -> {
|
||||
Log.d(TAG, "Connected — discovering services…")
|
||||
mainHandler.postDelayed({ gatt.discoverServices() }, 500)
|
||||
}
|
||||
BluetoothProfile.STATE_DISCONNECTED -> {
|
||||
Log.d(TAG, "Disconnected (status=$status)")
|
||||
this@BleScaleManager.gatt?.close()
|
||||
this@BleScaleManager.gatt = null
|
||||
connectedDeviceName = ""
|
||||
mainHandler.post { listener.onDisconnected() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
mainHandler.post { listener.onError("Servizi GATT non trovati (status=$status)") }
|
||||
return
|
||||
}
|
||||
|
||||
val targetChars = mutableListOf<BluetoothGattCharacteristic>()
|
||||
|
||||
// Priority 1: BLE SIG Weight Scale Service
|
||||
gatt.getService(BleUuids.WEIGHT_SCALE_SERVICE)
|
||||
?.getCharacteristic(BleUuids.WEIGHT_MEASUREMENT_CHAR)
|
||||
?.let { targetChars.add(it) }
|
||||
|
||||
// Priority 2: Common vendor service FFE0 (arboleaf, generic kitchen scales)
|
||||
gatt.getService(BleUuids.FFE0)?.let { svc ->
|
||||
svc.getCharacteristic(BleUuids.FFE1)?.let { targetChars.add(it) }
|
||||
}
|
||||
|
||||
// Priority 3: Common vendor service FFF0
|
||||
gatt.getService(BleUuids.FFF0)?.let { svc ->
|
||||
svc.getCharacteristic(BleUuids.FFF4)?.let { targetChars.add(it) }
|
||||
?: svc.getCharacteristic(BleUuids.FFF1)?.let { targetChars.add(it) }
|
||||
}
|
||||
|
||||
// Priority 4: Acaia coffee scale
|
||||
gatt.getService(BleUuids.ACAIA_SERVICE)?.let { svc ->
|
||||
svc.getCharacteristic(BleUuids.ACAIA_CHAR)?.let { targetChars.add(it) }
|
||||
}
|
||||
|
||||
// Fallback: any notifiable characteristic from remaining services
|
||||
if (targetChars.isEmpty()) {
|
||||
for (service in gatt.services) {
|
||||
if (service.uuid.toString().startsWith("00001800") ||
|
||||
service.uuid.toString().startsWith("00001801")) continue
|
||||
for (char in service.characteristics) {
|
||||
val props = char.properties
|
||||
if ((props and BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0 ||
|
||||
(props and BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0) {
|
||||
if (!targetChars.contains(char)) targetChars.add(char)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetChars.isEmpty()) {
|
||||
mainHandler.post { listener.onError("No weight characteristic found. Make sure it's a BLE kitchen scale.") }
|
||||
return
|
||||
}
|
||||
|
||||
// Battery (optional)
|
||||
gatt.getService(BleUuids.BATTERY_SERVICE)
|
||||
?.getCharacteristic(BleUuids.BATTERY_LEVEL_CHAR)
|
||||
?.let { targetChars.add(it) }
|
||||
|
||||
// Debug: log all discovered services and characteristics
|
||||
val dbg = buildString {
|
||||
append("GATT services (${gatt.services.size}):\n")
|
||||
for (svc in gatt.services) {
|
||||
append(" SVC: ${svc.uuid}\n")
|
||||
for (ch in svc.characteristics) {
|
||||
val p = ch.properties
|
||||
val flags = buildString {
|
||||
if (p and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0) append("N")
|
||||
if (p and BluetoothGattCharacteristic.PROPERTY_INDICATE != 0) append("I")
|
||||
if (p and BluetoothGattCharacteristic.PROPERTY_READ != 0) append("R")
|
||||
if (p and BluetoothGattCharacteristic.PROPERTY_WRITE != 0) append("W")
|
||||
}
|
||||
append(" CHAR: ${ch.uuid} [$flags]\n")
|
||||
}
|
||||
}
|
||||
append("Subscribed to ${targetChars.size} characteristics")
|
||||
}
|
||||
mainHandler.post { listener.onDebugEvent(dbg) }
|
||||
|
||||
// Save device for auto-reconnect
|
||||
try { gatt.device?.address?.let { saveDeviceAddress(it) } } catch (_: SecurityException) {}
|
||||
|
||||
pendingSubscriptions.clear()
|
||||
pendingSubscriptions.addAll(targetChars)
|
||||
|
||||
val deviceName = try { gatt.device?.name ?: "Scale" } catch (e: SecurityException) { "Scale" }
|
||||
connectedDeviceName = deviceName
|
||||
mainHandler.post { listener.onConnected(deviceName) }
|
||||
|
||||
// Subscribe one at a time (Android BLE requires sequential descriptor writes)
|
||||
subscribeNext(gatt)
|
||||
}
|
||||
|
||||
override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
|
||||
// Subscribe to the next characteristic
|
||||
subscribeNext(gatt)
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
override fun onCharacteristicChanged(
|
||||
gatt: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
) {
|
||||
val data = characteristic.value ?: return
|
||||
processCharacteristicData(characteristic, data)
|
||||
}
|
||||
|
||||
// Android 13+ override
|
||||
override fun onCharacteristicChanged(
|
||||
gatt: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
value: ByteArray,
|
||||
) {
|
||||
processCharacteristicData(characteristic, value)
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
override fun onCharacteristicRead(
|
||||
gatt: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
status: Int,
|
||||
) {
|
||||
if (status == BluetoothGatt.GATT_SUCCESS && characteristic.uuid == BleUuids.BATTERY_LEVEL_CHAR) {
|
||||
val level = characteristic.value?.firstOrNull()?.toInt()?.and(0xFF)
|
||||
if (level != null) mainHandler.post { listener.onBatteryReceived(level) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private fun subscribeNext(gatt: BluetoothGatt) {
|
||||
val char = pendingSubscriptions.removeFirstOrNull() ?: return
|
||||
|
||||
// Battery characteristic — read once instead of notify
|
||||
if (char.uuid == BleUuids.BATTERY_LEVEL_CHAR) {
|
||||
try { gatt.readCharacteristic(char) } catch (e: SecurityException) { /* ignore */ }
|
||||
return
|
||||
}
|
||||
|
||||
val props = char.properties
|
||||
val notifyType = when {
|
||||
(props and BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0 ->
|
||||
BluetoothGattDescriptor.ENABLE_INDICATION_VALUE
|
||||
else -> BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||
}
|
||||
|
||||
try {
|
||||
gatt.setCharacteristicNotification(char, true)
|
||||
val descriptor = char.getDescriptor(CCCD_UUID) ?: run {
|
||||
// No CCCD — skip and try next
|
||||
subscribeNext(gatt)
|
||||
return
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
gatt.writeDescriptor(descriptor, notifyType)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
descriptor.value = notifyType
|
||||
@Suppress("DEPRECATION")
|
||||
gatt.writeDescriptor(descriptor)
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "SecurityException enabling notification", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun processCharacteristicData(char: BluetoothGattCharacteristic, data: ByteArray) {
|
||||
// Battery level
|
||||
if (char.uuid == BleUuids.BATTERY_LEVEL_CHAR && data.isNotEmpty()) {
|
||||
val level = data[0].toInt() and 0xFF
|
||||
mainHandler.post { listener.onBatteryReceived(level) }
|
||||
return
|
||||
}
|
||||
|
||||
// Debug: log raw bytes received
|
||||
val hex = data.joinToString(" ") { "%02X".format(it) }
|
||||
mainHandler.post { listener.onDebugEvent("📡 ${char.uuid}\n HEX [${data.size}B]: $hex") }
|
||||
|
||||
// Parse weight data
|
||||
val reading = ScaleProtocol.parse(char, data) { msg ->
|
||||
mainHandler.post { listener.onDebugEvent(msg) }
|
||||
}
|
||||
if (reading != null && reading.value > 0f) {
|
||||
mainHandler.post { listener.onWeightReceived(reading) }
|
||||
} else {
|
||||
val rawDump = data.mapIndexed { i, b ->
|
||||
val v = b.toInt() and 0xFF
|
||||
val h = "%02X".format(v)
|
||||
"[$i]=$v(0x$h)"
|
||||
}.joinToString(" ")
|
||||
mainHandler.post { listener.onDebugEvent("\u26a0\ufe0f Weight not decoded\n RAW: $rawDump") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package it.dadaloop.evershelf.scalegate
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStreamWriter
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* Centralized error reporter for EverShelf Scale Gateway.
|
||||
*
|
||||
* Unlike the Kiosk (which relays errors through the EverShelf PHP backend),
|
||||
* the Scale Gateway has no knowledge of the EverShelf server URL, so it
|
||||
* calls the GitHub Issues REST API directly.
|
||||
*
|
||||
* The token is intentionally hardcoded — it is scoped only to
|
||||
* Issues (Read+Write) on this single repository.
|
||||
*
|
||||
* Usage:
|
||||
* ErrorReporter.init(applicationContext)
|
||||
* ErrorReporter.report(exception, "methodName", mapOf("extra" to "info"))
|
||||
* ErrorReporter.reportMessage("ble-disconnect", "Scale disconnected after 3 retries")
|
||||
*/
|
||||
object ErrorReporter {
|
||||
|
||||
private const val TAG = "ScaleGWErrorReporter"
|
||||
|
||||
// ── XOR-obfuscated GitHub token (scoped: Issues R+W on dadaloop82/EverShelf) ──
|
||||
// Stored encoded so the literal token string never appears in source or git history.
|
||||
private const val GH_TOKEN_ENC = "23580718460c2c444031290243627e7971622b29035e2a647726407d194f61440b6e05246a0c067c79730e77114b774501730043433d1866682225511b5443417170444443142941673c4046086c05737363293e7821006e470a466a1d"
|
||||
private const val GH_TOKEN_KEY = "D1sp3ns4!Ev3r#26"
|
||||
private const val GH_REPO = "dadaloop82/EverShelf"
|
||||
|
||||
private var _ghTokenCache: String? = null
|
||||
private fun ghToken(): String {
|
||||
_ghTokenCache?.let { return it }
|
||||
val enc = GH_TOKEN_ENC.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
val key = GH_TOKEN_KEY
|
||||
val out = String(ByteArray(enc.size) { i -> (enc[i].toInt() xor key[i % key.length].code).toByte() })
|
||||
_ghTokenCache = out
|
||||
return out
|
||||
}
|
||||
|
||||
// SharedPreferences key for pending (unsent) crash reports
|
||||
private const val PREFS_NAME = "evershelf_scalegw_errors"
|
||||
private const val KEY_PENDING = "pending_crash_json"
|
||||
|
||||
private val executor = Executors.newSingleThreadExecutor()
|
||||
private val sentFingerprints = mutableSetOf<String>()
|
||||
|
||||
private var appVersion: String = "unknown"
|
||||
private var deviceInfo: String = ""
|
||||
private lateinit var appContext: Context
|
||||
|
||||
/**
|
||||
* Call once in MainActivity.onCreate() or Application.onCreate().
|
||||
*/
|
||||
fun init(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
deviceInfo = "${Build.MANUFACTURER} ${Build.MODEL} (Android ${Build.VERSION.RELEASE})"
|
||||
try {
|
||||
val pi = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||
appVersion = pi.versionName ?: "unknown"
|
||||
} catch (_: Exception) {}
|
||||
|
||||
// Send any crash report that was saved from the previous session
|
||||
sendPendingCrash()
|
||||
|
||||
// Install global UncaughtExceptionHandler
|
||||
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
try {
|
||||
val crash = buildPayload(
|
||||
type = "uncaught-exception",
|
||||
message = "${throwable.javaClass.simpleName}: ${throwable.message}",
|
||||
stack = throwable.stackTraceToString(),
|
||||
context = mapOf("thread" to thread.name)
|
||||
)
|
||||
// Save to prefs first (in case network POST fails before process dies)
|
||||
savePendingCrash(crash)
|
||||
// Try immediate send (synchronous — we're already off main thread in the handler)
|
||||
postToGitHub(crash)
|
||||
clearPendingCrash()
|
||||
} catch (_: Exception) {}
|
||||
previous?.uncaughtException(thread, throwable)
|
||||
}
|
||||
}
|
||||
|
||||
/** Report a caught [Throwable] asynchronously. */
|
||||
fun report(throwable: Throwable, location: String = "", extra: Map<String, Any?> = emptyMap()) {
|
||||
val ctx = mutableMapOf<String, Any?>("device" to deviceInfo)
|
||||
if (location.isNotEmpty()) ctx["location"] = location
|
||||
ctx.putAll(extra)
|
||||
enqueue(
|
||||
type = "scale-exception",
|
||||
message = "${throwable.javaClass.simpleName}: ${throwable.message}",
|
||||
stack = throwable.stackTraceToString(),
|
||||
context = ctx
|
||||
)
|
||||
}
|
||||
|
||||
/** Report a non-exception event (e.g. BLE disconnect, WebSocket error). */
|
||||
fun reportMessage(type: String, message: String, extra: Map<String, Any?> = emptyMap()) {
|
||||
val ctx = mutableMapOf<String, Any?>("device" to deviceInfo)
|
||||
ctx.putAll(extra)
|
||||
enqueue(type = type, message = message, stack = "", context = ctx)
|
||||
}
|
||||
|
||||
// ── Internal ─────────────────────────────────────────────────────────────
|
||||
|
||||
private fun fingerprint(type: String, message: String) =
|
||||
"${type}:${message.take(120)}".hashCode().toString(16)
|
||||
|
||||
private fun enqueue(type: String, message: String, stack: String, context: Map<String, Any?>) {
|
||||
val fp = fingerprint(type, message)
|
||||
synchronized(sentFingerprints) {
|
||||
if (!sentFingerprints.add(fp)) return
|
||||
}
|
||||
val payload = buildPayload(type, message, stack, context)
|
||||
executor.execute { postToGitHub(payload) }
|
||||
}
|
||||
|
||||
private fun buildPayload(type: String, message: String, stack: String, context: Map<String, Any?>): JSONObject {
|
||||
val ctxJson = JSONObject()
|
||||
context.forEach { (k, v) -> ctxJson.put(k, v) }
|
||||
return JSONObject().apply {
|
||||
put("source", "scale")
|
||||
put("type", type)
|
||||
put("message", message)
|
||||
put("stack", stack)
|
||||
put("context", ctxJson)
|
||||
put("version", appVersion)
|
||||
put("user_agent", "EverShelf-ScaleGateway/$appVersion (Android ${Build.VERSION.RELEASE}; ${Build.MODEL})")
|
||||
put("ts", SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date()))
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist crash payload to SharedPreferences so it survives a process kill. */
|
||||
private fun savePendingCrash(payload: JSONObject) {
|
||||
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit().putString(KEY_PENDING, payload.toString()).apply()
|
||||
}
|
||||
|
||||
private fun clearPendingCrash() {
|
||||
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit().remove(KEY_PENDING).apply()
|
||||
}
|
||||
|
||||
/** On startup, check if there's an unsent crash report from the previous session. */
|
||||
private fun sendPendingCrash() {
|
||||
val json = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.getString(KEY_PENDING, null) ?: return
|
||||
clearPendingCrash() // remove before sending to prevent re-sending on next crash
|
||||
executor.execute {
|
||||
try {
|
||||
val payload = JSONObject(json)
|
||||
// Tag it as a "survived-crash" so we know it was saved and retried
|
||||
payload.put("type", "uncaught-exception-survived")
|
||||
payload.put("note", "Sent on next launch after crash")
|
||||
postToGitHub(payload)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a GitHub Issue (or add a comment to an existing one with the same fingerprint).
|
||||
* Uses the GitHub Issues Search API to deduplicate.
|
||||
*/
|
||||
private fun postToGitHub(payload: JSONObject) {
|
||||
val source = payload.optString("source", "scale")
|
||||
val type = payload.optString("type", "error")
|
||||
val message = payload.optString("message", "")
|
||||
val stack = payload.optString("stack", "")
|
||||
val version = payload.optString("version", "")
|
||||
val ua = payload.optString("user_agent", "")
|
||||
val ts = payload.optString("ts", "")
|
||||
val ctxJson = payload.optJSONObject("context") ?: JSONObject()
|
||||
|
||||
val fp = fingerprint(type, message)
|
||||
|
||||
// ── 1. Search for existing open issue ──────────────────────────────
|
||||
val searchQ = "repo:$GH_REPO is:issue is:open label:auto-report \"fp:$fp\" in:body"
|
||||
val searchUrl = "https://api.github.com/search/issues?q=${java.net.URLEncoder.encode(searchQ, "UTF-8")}&per_page=1"
|
||||
val searchResult = ghGet(searchUrl) ?: JSONObject()
|
||||
val existingNumber = searchResult.optJSONArray("items")?.optJSONObject(0)?.optInt("number", 0)?.takeIf { it > 0 }
|
||||
|
||||
// ── 2. Build body ─────────────────────────────────────────────────
|
||||
val ctxMd = if (ctxJson.length() > 0) "\n**Context:**\n```json\n${ctxJson.toString(2)}\n```\n" else ""
|
||||
val stackMd = if (stack.isNotEmpty()) "\n**Stack trace:**\n```\n$stack\n```\n" else ""
|
||||
|
||||
if (existingNumber != null) {
|
||||
// Comment on existing issue
|
||||
val body = "### 🔁 Recurrence — $ts\n**Source:** `$source` | **Type:** `$type`\n**UA:** `$ua`\n$ctxMd$stackMd\n---\n_fp:${fp}_"
|
||||
ghPost("https://api.github.com/repos/$GH_REPO/issues/$existingNumber/comments", JSONObject().put("body", body))
|
||||
} else {
|
||||
// Create new issue
|
||||
val shortMsg = if (message.length > 70) "${message.take(70)}…" else message
|
||||
val title = "[SCALE] $shortMsg"
|
||||
val body = "## 🚨 Automatic Error Report\n\n**Source:** `$source` \n**Type:** `$type` \n**Reported at:** $ts \n**UA:** `$ua` \n**Version:** `$version`\n\n**Error message:**\n> $message\n$stackMd$ctxMd\n---\n<!-- auto-report fp:$fp -->\n_This issue was created automatically by EverShelf Scale Gateway error reporter. fp:`${fp}`_"
|
||||
ghPost(
|
||||
"https://api.github.com/repos/$GH_REPO/issues",
|
||||
JSONObject()
|
||||
.put("title", title)
|
||||
.put("body", body)
|
||||
.put("labels", JSONArray().put("auto-report").put("scale-error"))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ghGet(url: String): JSONObject? = try {
|
||||
val conn = URL(url).openConnection() as HttpURLConnection
|
||||
conn.setRequestProperty("Authorization", "token ${ghToken()}")
|
||||
conn.setRequestProperty("Accept", "application/vnd.github+json")
|
||||
conn.setRequestProperty("X-GitHub-Api-Version", "2022-11-28")
|
||||
conn.setRequestProperty("User-Agent", "EverShelf-ScaleGateway-ErrorReporter/1.0")
|
||||
conn.connectTimeout = 8000
|
||||
conn.readTimeout = 8000
|
||||
val raw = BufferedReader(InputStreamReader(conn.inputStream)).readText()
|
||||
conn.disconnect()
|
||||
JSONObject(raw)
|
||||
} catch (e: Exception) { Log.w(TAG, "ghGet failed: ${e.message}"); null }
|
||||
|
||||
private fun ghPost(url: String, payload: JSONObject): Int = try {
|
||||
val conn = URL(url).openConnection() as HttpURLConnection
|
||||
conn.requestMethod = "POST"
|
||||
conn.setRequestProperty("Authorization", "token ${ghToken()}")
|
||||
conn.setRequestProperty("Accept", "application/vnd.github+json")
|
||||
conn.setRequestProperty("X-GitHub-Api-Version", "2022-11-28")
|
||||
conn.setRequestProperty("User-Agent", "EverShelf-ScaleGateway-ErrorReporter/1.0")
|
||||
conn.setRequestProperty("Content-Type", "application/json; charset=utf-8")
|
||||
conn.doOutput = true
|
||||
conn.connectTimeout = 8000
|
||||
conn.readTimeout = 8000
|
||||
OutputStreamWriter(conn.outputStream, Charsets.UTF_8).use { it.write(payload.toString()) }
|
||||
val code = conn.responseCode
|
||||
conn.disconnect()
|
||||
Log.d(TAG, "ghPost $url → HTTP $code")
|
||||
code
|
||||
} catch (e: Exception) { Log.w(TAG, "ghPost failed: ${e.message}"); -1 }
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package it.dadaloop.evershelf.scalegate
|
||||
|
||||
import android.util.Log
|
||||
import org.java_websocket.WebSocket
|
||||
import org.java_websocket.handshake.ClientHandshake
|
||||
import org.java_websocket.server.WebSocketServer
|
||||
import org.json.JSONObject
|
||||
import java.net.InetSocketAddress
|
||||
import java.util.Collections
|
||||
|
||||
private const val TAG = "GatewayWsServer"
|
||||
|
||||
/**
|
||||
* Callbacks for the WebSocket server, dispatched on the server's internal thread.
|
||||
* The caller (MainActivity) is responsible for switching to the main thread if needed.
|
||||
*/
|
||||
interface ServerEventListener {
|
||||
fun onClientConnected(address: String)
|
||||
fun onClientDisconnected(address: String)
|
||||
fun onClientRequestedWeight()
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket server that exposes smart-scale data to EverShelf running in a browser.
|
||||
*
|
||||
* Message protocol (JSON):
|
||||
*
|
||||
* Server -> Client:
|
||||
* {"type":"status","state":"connected"|"disconnected","device":"QN-KS","battery":80}
|
||||
* {"type":"weight","value":17.0,"unit":"g","stable":true,"timestamp":1712345678000}
|
||||
* {"type":"pong"}
|
||||
*
|
||||
* Client → Server:
|
||||
* {"type":"get_status"} → server responds with current status message
|
||||
* {"type":"get_weight"} → server will push the next stable weight reading
|
||||
* {"type":"ping"} → server responds with {"type":"pong"}
|
||||
*/
|
||||
class GatewayWebSocketServer(
|
||||
port: Int,
|
||||
private val eventListener: ServerEventListener?,
|
||||
) : WebSocketServer(InetSocketAddress(port)) {
|
||||
|
||||
// Thread-safe set of clients waiting for the next stable weight reading
|
||||
private val pendingWeightRequests: MutableSet<WebSocket> =
|
||||
Collections.synchronizedSet(mutableSetOf())
|
||||
|
||||
// Last known scale state (to send to new clients immediately)
|
||||
@Volatile private var lastStatusJson: String = buildStatusJson("disconnected", null, null)
|
||||
@Volatile private var lastWeightJson: String? = null
|
||||
|
||||
// ─── Server lifecycle ──────────────────────────────────────────────────────
|
||||
|
||||
override fun onStart() {
|
||||
Log.i(TAG, "WebSocket server started on port ${address.port}")
|
||||
connectionLostTimeout = 30
|
||||
}
|
||||
|
||||
override fun onOpen(conn: WebSocket, handshake: ClientHandshake) {
|
||||
val addr = conn.remoteSocketAddress?.toString() ?: "?"
|
||||
Log.d(TAG, "Client connected: $addr")
|
||||
|
||||
// Immediately send current status so the web app knows the scale state
|
||||
conn.send(lastStatusJson)
|
||||
lastWeightJson?.let { conn.send(it) }
|
||||
|
||||
eventListener?.onClientConnected(addr)
|
||||
}
|
||||
|
||||
override fun onClose(conn: WebSocket, code: Int, reason: String, remote: Boolean) {
|
||||
val addr = conn.remoteSocketAddress?.toString() ?: "?"
|
||||
Log.d(TAG, "Client disconnected: $addr (code=$code)")
|
||||
pendingWeightRequests.remove(conn)
|
||||
eventListener?.onClientDisconnected(addr)
|
||||
}
|
||||
|
||||
override fun onMessage(conn: WebSocket, message: String) {
|
||||
try {
|
||||
val json = JSONObject(message)
|
||||
when (json.optString("type")) {
|
||||
"ping" -> conn.send("""{"type":"pong"}""")
|
||||
"get_status" -> conn.send(lastStatusJson)
|
||||
"get_weight" -> {
|
||||
// Add to pending set; next stable weight will be sent to this client
|
||||
pendingWeightRequests.add(conn)
|
||||
eventListener?.onClientRequestedWeight()
|
||||
// If we already have a recent weight, send it immediately
|
||||
lastWeightJson?.let { conn.send(it) }
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Malformed message: $message")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(conn: WebSocket?, ex: Exception) {
|
||||
Log.e(TAG, "WebSocket error on ${conn?.remoteSocketAddress}", ex)
|
||||
ErrorReporter.report(ex, "GatewayWebSocketServer.onError",
|
||||
mapOf("remote_addr" to (conn?.remoteSocketAddress?.toString() ?: "null")))
|
||||
}
|
||||
|
||||
// ─── Publishing API ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Broadcast scale connection status to all connected WebSocket clients.
|
||||
*/
|
||||
fun publishStatus(state: String, deviceName: String?, battery: Int?) {
|
||||
lastStatusJson = buildStatusJson(state, deviceName, battery)
|
||||
broadcast(lastStatusJson)
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast a weight reading to all clients.
|
||||
* If [stable] is true, also fulfil pending on-demand weight requests.
|
||||
*/
|
||||
fun publishWeight(value: Float, unit: String, stable: Boolean, battery: Int? = null) {
|
||||
val json = buildWeightJson(value, unit, stable)
|
||||
lastWeightJson = json
|
||||
broadcast(json)
|
||||
|
||||
if (stable) {
|
||||
synchronized(pendingWeightRequests) {
|
||||
// Clients that requested on-demand readings are already served by broadcast;
|
||||
// just clear the pending set.
|
||||
pendingWeightRequests.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── JSON builders ─────────────────────────────────────────────────────────
|
||||
|
||||
private fun buildStatusJson(state: String, device: String?, battery: Int?): String {
|
||||
val obj = JSONObject()
|
||||
obj.put("type", "status")
|
||||
obj.put("state", state)
|
||||
if (device != null) obj.put("device", device)
|
||||
if (battery != null) obj.put("battery", battery)
|
||||
return obj.toString()
|
||||
}
|
||||
|
||||
private fun buildWeightJson(value: Float, unit: String, stable: Boolean): String {
|
||||
val obj = JSONObject()
|
||||
obj.put("type", "weight")
|
||||
// Round to 1 decimal to avoid floating point noise (e.g. 17.000001)
|
||||
val rounded = Math.round(value * 10f) / 10.0
|
||||
obj.put("value", rounded)
|
||||
obj.put("unit", unit)
|
||||
obj.put("stable", stable)
|
||||
obj.put("timestamp", System.currentTimeMillis())
|
||||
return obj.toString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,674 @@
|
||||
package it.dadaloop.evershelf.scalegate
|
||||
|
||||
import android.Manifest
|
||||
import android.app.DownloadManager
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.app.PendingIntent
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import it.dadaloop.evershelf.scalegate.databinding.ActivityMainBinding
|
||||
import java.net.Inet4Address
|
||||
import java.net.NetworkInterface
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import org.json.JSONObject
|
||||
|
||||
private const val WS_PORT = 8765
|
||||
|
||||
class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener {
|
||||
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
private lateinit var bleManager: BleScaleManager
|
||||
private var wsServer: GatewayWebSocketServer? = null
|
||||
|
||||
private val devices = mutableListOf<BleDeviceInfo>()
|
||||
private lateinit var deviceAdapter: DeviceAdapter
|
||||
|
||||
private var batteryLevel: Int? = null
|
||||
private val debugLines = mutableListOf<String>()
|
||||
private var debugVisible = false
|
||||
private var lastDebugUpdate = 0L
|
||||
private val debugTimeFmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
|
||||
private var isAutoReconnecting = false
|
||||
// Update banner
|
||||
private var pendingApkDownloadUrl = ""
|
||||
private var pendingInstallFile: java.io.File? = null
|
||||
private companion object {
|
||||
const val MAX_DEBUG_LINES = 150
|
||||
const val DEBUG_THROTTLE_MS = 200L
|
||||
const val GITHUB_RELEASES_API = "https://api.github.com/repos/dadaloop82/EverShelf/releases/latest"
|
||||
const val APK_DOWNLOAD_URL = "https://github.com/dadaloop82/EverShelf/releases/latest/download/evershelf-scale-gateway.apk"
|
||||
}
|
||||
|
||||
// ─── Permission launcher ───────────────────────────────────────────────────
|
||||
|
||||
private val permissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
) { granted ->
|
||||
if (granted.values.all { it }) {
|
||||
startGatewayServer()
|
||||
} else {
|
||||
showDialog("Missing permissions",
|
||||
"The app requires Bluetooth and Location permissions to function.")
|
||||
}
|
||||
}
|
||||
|
||||
private val enableBtLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (result.resultCode == RESULT_OK) checkPermissionsAndStart()
|
||||
else showDialog("Bluetooth required", "Please enable Bluetooth to use the gateway.")
|
||||
}
|
||||
|
||||
/** Returns from ACTION_MANAGE_UNKNOWN_APP_SOURCES — retry the download. */
|
||||
private val installPermLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { _ ->
|
||||
val url = pendingApkDownloadUrl
|
||||
if (url.isNotEmpty()) triggerApkDownload(url)
|
||||
}
|
||||
|
||||
/** Returns from system installer dialog — if not OK the install failed (signature conflict?). */
|
||||
private val installConfirmLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (result.resultCode != RESULT_OK) {
|
||||
val f = pendingInstallFile
|
||||
if (f != null && f.exists()) {
|
||||
runOnUiThread {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle("⚠️ Installazione non riuscita")
|
||||
.setMessage("Se hai visto un errore di conflitto firma, devi disinstallare la versione precedente.\n\nDisinstalla ora? L'installazione ripartirà automaticamente.")
|
||||
.setPositiveButton("Disinstalla") { _, _ ->
|
||||
uninstallLauncher.launch(
|
||||
Intent(Intent.ACTION_DELETE, android.net.Uri.parse("package:$packageName"))
|
||||
)
|
||||
}
|
||||
.setNegativeButton("Annulla", null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns from uninstall screen — auto-retry the install with the saved APK file. */
|
||||
private val uninstallLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { _ ->
|
||||
val f = pendingInstallFile
|
||||
if (f != null && f.exists()) installApk(f)
|
||||
}
|
||||
|
||||
// ─── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
bleManager = BleScaleManager(this, this)
|
||||
|
||||
// Initialise error reporter early so the UncaughtExceptionHandler is installed
|
||||
// and any pending crash from a previous session is sent
|
||||
ErrorReporter.init(this)
|
||||
|
||||
deviceAdapter = DeviceAdapter(devices) { info ->
|
||||
bleManager.connect(info.device)
|
||||
}
|
||||
binding.rvDevices.apply {
|
||||
layoutManager = LinearLayoutManager(this@MainActivity)
|
||||
adapter = deviceAdapter
|
||||
}
|
||||
|
||||
binding.btnScan.setOnClickListener { startScanIfPermitted() }
|
||||
binding.btnDisconnect.setOnClickListener {
|
||||
bleManager.disconnect()
|
||||
updateUiDisconnected()
|
||||
}
|
||||
binding.btnDebug.setOnClickListener {
|
||||
debugVisible = !debugVisible
|
||||
binding.svDebugLog.visibility = if (debugVisible) View.VISIBLE else View.GONE
|
||||
binding.btnCopyLog.visibility = if (debugVisible) View.VISIBLE else View.GONE
|
||||
binding.btnShareLog.visibility = if (debugVisible) View.VISIBLE else View.GONE
|
||||
binding.btnDebug.text = if (debugVisible) "\uD83D\uDC1B Hide Debug" else "\uD83D\uDC1B Debug"
|
||||
}
|
||||
binding.btnCopyLog.setOnClickListener {
|
||||
val log = debugLines.joinToString("\n")
|
||||
val cm = getSystemService(CLIPBOARD_SERVICE) as android.content.ClipboardManager
|
||||
cm.setPrimaryClip(android.content.ClipData.newPlainText("EverShelf Scale Log", log))
|
||||
Toast.makeText(this, "Log copied to clipboard", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
binding.btnShareLog.setOnClickListener {
|
||||
val log = debugLines.joinToString("\n")
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_SUBJECT, "EverShelf Scale Gateway - Debug Log")
|
||||
putExtra(Intent.EXTRA_TEXT, log)
|
||||
}
|
||||
startActivity(Intent.createChooser(intent, "Share log"))
|
||||
}
|
||||
|
||||
// Show app version
|
||||
try {
|
||||
val pInfo = packageManager.getPackageInfo(packageName, 0)
|
||||
binding.tvVersion.text = "v${pInfo.versionName} (${pInfo.longVersionCode})"
|
||||
} catch (_: Exception) { }
|
||||
|
||||
updateGatewayUrl()
|
||||
checkPermissionsAndStart()
|
||||
|
||||
// Wire update banner buttons
|
||||
binding.btnDismissUpdate.setOnClickListener { binding.updateBanner.visibility = View.GONE }
|
||||
binding.btnInstallUpdate.setOnClickListener { triggerApkDownload(pendingApkDownloadUrl) }
|
||||
|
||||
// Check for a newer release (background thread, at most once every 6 h)
|
||||
checkForUpdates()
|
||||
|
||||
// Auto-connect: if we have a saved device, start scanning with auto-connect enabled
|
||||
if (bleManager.getSavedDeviceAddress() != null) {
|
||||
binding.tvScanHint.visibility = View.VISIBLE
|
||||
binding.tvScanHint.text = "\uD83D\uDD04 Reconnecting to saved scale\u2026"
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
bleManager.disconnect()
|
||||
wsServer?.stop(1000)
|
||||
}
|
||||
|
||||
// ─── Permissions & startup ─────────────────────────────────────────────────
|
||||
|
||||
private fun checkPermissionsAndStart() {
|
||||
val required = buildList {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
add(Manifest.permission.BLUETOOTH_SCAN)
|
||||
add(Manifest.permission.BLUETOOTH_CONNECT)
|
||||
} else {
|
||||
add(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
}
|
||||
}
|
||||
val missing = required.filter {
|
||||
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
when {
|
||||
missing.isNotEmpty() -> permissionLauncher.launch(missing.toTypedArray())
|
||||
!isBluetoothEnabled() -> enableBtLauncher.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE))
|
||||
else -> startGatewayServer()
|
||||
}
|
||||
}
|
||||
|
||||
private fun isBluetoothEnabled(): Boolean {
|
||||
val adapter = android.bluetooth.BluetoothManager::class.java.let {
|
||||
getSystemService(it)
|
||||
} as? android.bluetooth.BluetoothManager
|
||||
return adapter?.adapter?.isEnabled == true
|
||||
}
|
||||
|
||||
private fun startScanIfPermitted() {
|
||||
if (!bleManager.hasRequiredPermissions()) {
|
||||
checkPermissionsAndStart()
|
||||
return
|
||||
}
|
||||
devices.clear()
|
||||
deviceAdapter.notifyDataSetChanged()
|
||||
debugLines.clear()
|
||||
binding.tvDebugLog.text = ""
|
||||
binding.tvScanHint.visibility = View.VISIBLE
|
||||
binding.tvScanHint.text = "Scanning for BLE scales\u2026"
|
||||
binding.btnScan.isEnabled = false
|
||||
bleManager.enableAutoConnect()
|
||||
isAutoReconnecting = false // manual scan — stop any pending auto-reconnect cycle
|
||||
bleManager.startScan()
|
||||
}
|
||||
|
||||
// ─── WebSocket gateway ─────────────────────────────────────────────────────
|
||||
|
||||
private fun startGatewayServer() {
|
||||
if (wsServer != null) return
|
||||
try {
|
||||
wsServer = GatewayWebSocketServer(WS_PORT, this)
|
||||
wsServer!!.start()
|
||||
updateGatewayUrl()
|
||||
binding.tvGatewayStatus.text = "\u2705 Gateway active on port $WS_PORT"
|
||||
} catch (e: Exception) {
|
||||
binding.tvGatewayStatus.text = "\u274C Failed to start gateway: ${e.message}"
|
||||
ErrorReporter.report(e, "startGatewayServer", mapOf("port" to WS_PORT))
|
||||
}
|
||||
|
||||
// Auto-scan if there's a saved device
|
||||
if (bleManager.getSavedDeviceAddress() != null && bleManager.hasRequiredPermissions()) {
|
||||
bleManager.enableAutoConnect()
|
||||
bleManager.startScan()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateGatewayUrl() {
|
||||
val ip = getLocalIpAddress() ?: "—"
|
||||
val url = "ws://$ip:$WS_PORT"
|
||||
binding.tvGatewayUrl.text = url
|
||||
binding.tvGatewayUrlHint.text = "Paste this URL in EverShelf \u2192 Settings \u2192 Smart Scale"
|
||||
binding.btnCopyUrl.setOnClickListener {
|
||||
val cm = getSystemService(CLIPBOARD_SERVICE) as android.content.ClipboardManager
|
||||
cm.setPrimaryClip(android.content.ClipData.newPlainText("EverShelf Gateway URL", url))
|
||||
binding.btnCopyUrl.text = "\u2705 Copied!"
|
||||
binding.btnCopyUrl.postDelayed({ binding.btnCopyUrl.text = "\uD83D\uDCCB Copy URL" }, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── BleScaleListener ─────────────────────────────────────────────────────
|
||||
|
||||
override fun onDeviceFound(info: BleDeviceInfo) {
|
||||
if (devices.none { it.device.address == info.device.address }) {
|
||||
// Insert keeping descending scaleScore order (scale-likely devices first)
|
||||
val insertAt = devices.indexOfFirst { it.scaleScore < info.scaleScore }
|
||||
.let { if (it < 0) devices.size else it }
|
||||
devices.add(insertAt, info)
|
||||
deviceAdapter.notifyItemInserted(insertAt)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConnecting(device: BluetoothDevice) {
|
||||
val name = try { device.name ?: device.address } catch (e: SecurityException) { device.address }
|
||||
binding.tvScaleStatus.text = "\u23f3 Connecting to $name\u2026"
|
||||
binding.tvWeight.text = "— — —"
|
||||
binding.cardConnection.setCardBackgroundColor(getColor(android.R.color.holo_orange_light))
|
||||
}
|
||||
|
||||
override fun onConnected(deviceName: String) {
|
||||
isAutoReconnecting = false
|
||||
binding.tvScaleStatus.text = "\u2705 Connected: $deviceName"
|
||||
binding.tvWeight.text = "Waiting for weight\u2026"
|
||||
binding.cardConnection.setCardBackgroundColor(getColor(android.R.color.holo_green_light))
|
||||
binding.btnDisconnect.visibility = View.VISIBLE
|
||||
binding.rvDevices.visibility = View.GONE
|
||||
binding.btnScan.visibility = View.GONE
|
||||
binding.tvScanHint.visibility = View.GONE
|
||||
wsServer?.publishStatus("connected", deviceName, batteryLevel)
|
||||
}
|
||||
|
||||
override fun onDisconnected() {
|
||||
wsServer?.publishStatus("disconnected", null, null)
|
||||
updateUiDisconnected()
|
||||
// Auto-reconnect: if a saved device exists, restart scan after a short delay.
|
||||
// This handles the scale turning off by itself (auto-off) — when it powers
|
||||
// back on it will start advertising again and we will pick it up.
|
||||
if (bleManager.getSavedDeviceAddress() != null && bleManager.hasRequiredPermissions()) {
|
||||
isAutoReconnecting = true
|
||||
binding.tvScanHint.visibility = View.VISIBLE
|
||||
binding.tvScanHint.text = "\uD83D\uDD04 Reconnecting to saved scale in 5 s\u2026"
|
||||
binding.root.postDelayed({
|
||||
if (!bleManager.isConnected && isAutoReconnecting) {
|
||||
bleManager.enableAutoConnect()
|
||||
bleManager.startScan()
|
||||
}
|
||||
}, 5_000L)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onWeightReceived(reading: WeightReading) {
|
||||
val displayValue = if (reading.value % 1f == 0f) reading.value.toInt().toString()
|
||||
else "%.1f".format(reading.value)
|
||||
binding.tvWeight.text = "$displayValue ${reading.unit}"
|
||||
|
||||
if (reading.stable) {
|
||||
binding.tvWeightHint.text = "\u2713 Stable reading"
|
||||
} else {
|
||||
binding.tvWeightHint.text = "\u23f3 Measuring\u2026"
|
||||
}
|
||||
wsServer?.publishWeight(reading.value, reading.unit, reading.stable, batteryLevel)
|
||||
}
|
||||
|
||||
override fun onBatteryReceived(level: Int) {
|
||||
batteryLevel = level
|
||||
binding.tvBattery.text = "🔋 $level%"
|
||||
binding.tvBattery.visibility = View.VISIBLE
|
||||
wsServer?.publishStatus("connected", binding.tvScaleStatus.text.toString()
|
||||
.removePrefix("\u2705 Connected: "), level)
|
||||
}
|
||||
|
||||
override fun onError(message: String) {
|
||||
binding.tvScaleStatus.text = "❌ $message"
|
||||
binding.cardConnection.setCardBackgroundColor(getColor(android.R.color.holo_red_light))
|
||||
ErrorReporter.reportMessage(
|
||||
type = "ble-error",
|
||||
message = message,
|
||||
extra = mapOf("connected_device" to (bleManager.getSavedDeviceAddress() ?: "none"))
|
||||
)
|
||||
}
|
||||
|
||||
override fun onScanStopped() {
|
||||
binding.btnScan.isEnabled = true
|
||||
if (isAutoReconnecting && !bleManager.isConnected && bleManager.getSavedDeviceAddress() != null) {
|
||||
// Scale not found yet — retry scan after 10 s indefinitely until reconnected
|
||||
binding.tvScanHint.visibility = View.VISIBLE
|
||||
binding.tvScanHint.text = "\uD83D\uDD04 Bilancia non trovata, riprovo tra 10 s\u2026"
|
||||
binding.root.postDelayed({
|
||||
if (!bleManager.isConnected && isAutoReconnecting) {
|
||||
binding.tvScanHint.text = "\uD83D\uDD04 Cerco la bilancia\u2026"
|
||||
bleManager.enableAutoConnect()
|
||||
bleManager.startScan()
|
||||
}
|
||||
}, 10_000L)
|
||||
} else if (devices.isEmpty()) {
|
||||
binding.tvScanHint.text = "No scale found. Make sure it's on, then scan again."
|
||||
} else {
|
||||
binding.tvScanHint.text = "Tap a scale to connect."
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDebugEvent(message: String) {
|
||||
runOnUiThread {
|
||||
val ts = debugTimeFmt.format(Date())
|
||||
debugLines.add("[$ts] $message")
|
||||
// Keep only last MAX_DEBUG_LINES
|
||||
while (debugLines.size > MAX_DEBUG_LINES) debugLines.removeAt(0)
|
||||
// Throttle UI updates to avoid freezing
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastDebugUpdate >= DEBUG_THROTTLE_MS) {
|
||||
lastDebugUpdate = now
|
||||
binding.tvDebugLog.text = debugLines.joinToString("\n")
|
||||
if (debugVisible) {
|
||||
binding.svDebugLog.post { binding.svDebugLog.fullScroll(View.FOCUS_DOWN) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── ServerEventListener ──────────────────────────────────────────────────
|
||||
|
||||
override fun onClientConnected(address: String) {
|
||||
runOnUiThread {
|
||||
binding.tvClientCount.text = "\uD83C\uDF10 Client connected: $address"
|
||||
binding.tvClientCount.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClientDisconnected(address: String) {
|
||||
runOnUiThread {
|
||||
binding.tvClientCount.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClientRequestedWeight() { /* Nothing extra needed */ }
|
||||
|
||||
// ─── UI helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
private fun updateUiDisconnected() {
|
||||
binding.tvScaleStatus.text = "\u26a1 Ready \u2014 scan for a scale"
|
||||
binding.tvWeight.text = "— — —"
|
||||
binding.tvWeightHint.text = ""
|
||||
binding.tvBattery.visibility = View.GONE
|
||||
binding.cardConnection.setCardBackgroundColor(getColor(android.R.color.darker_gray))
|
||||
binding.btnDisconnect.visibility = View.GONE
|
||||
binding.rvDevices.visibility = View.VISIBLE
|
||||
binding.btnScan.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
private fun getLocalIpAddress(): String? {
|
||||
return try {
|
||||
NetworkInterface.getNetworkInterfaces().toList()
|
||||
.flatMap { it.inetAddresses.toList() }
|
||||
.filterIsInstance<Inet4Address>()
|
||||
.firstOrNull { !it.isLoopbackAddress }
|
||||
?.hostAddress
|
||||
} catch (e: Exception) { null }
|
||||
}
|
||||
|
||||
private fun showDialog(title: String, message: String) {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(title)
|
||||
.setMessage(message)
|
||||
.setPositiveButton("OK", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
// ─── Update check ─────────────────────────────────────────────────────────
|
||||
|
||||
private fun checkForUpdates() {
|
||||
Thread {
|
||||
try {
|
||||
val conn = java.net.URL(GITHUB_RELEASES_API).openConnection() as java.net.HttpURLConnection
|
||||
conn.setRequestProperty("Accept", "application/vnd.github+json")
|
||||
conn.connectTimeout = 5000
|
||||
conn.readTimeout = 5000
|
||||
val body = conn.inputStream.bufferedReader().readText()
|
||||
conn.disconnect()
|
||||
val json = JSONObject(body)
|
||||
val latestTag = json.optString("tag_name", "").ifEmpty { return@Thread }
|
||||
val current = try { packageManager.getPackageInfo(packageName, 0).versionName ?: "" } catch (_: Exception) { "" }
|
||||
val norm = { v: String -> v.trimStart('v') }
|
||||
val isSemver = latestTag.trimStart('v').matches(Regex("\\d+\\.\\d+.*"))
|
||||
|
||||
// Find scale-gateway APK in release assets
|
||||
var apkUrl = ""
|
||||
val assets = json.optJSONArray("assets")
|
||||
if (assets != null) {
|
||||
for (i in 0 until assets.length()) {
|
||||
val a = assets.getJSONObject(i)
|
||||
val name = a.optString("name", "").lowercase()
|
||||
val url = a.optString("browser_download_url", "")
|
||||
if ((name.contains("gateway") || name.contains("scale")) && url.isNotEmpty()) {
|
||||
apkUrl = url; break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Only show banner if the release actually contains our APK
|
||||
if (apkUrl.isEmpty()) return@Thread
|
||||
|
||||
// Proper semver comparison: only update if remote is strictly newer
|
||||
fun semverNewer(remote: String, local: String): Boolean {
|
||||
val r = remote.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 }
|
||||
val l = local.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 }
|
||||
val len = maxOf(r.size, l.size)
|
||||
for (i in 0 until len) {
|
||||
val rv = r.getOrElse(i) { 0 }
|
||||
val lv = l.getOrElse(i) { 0 }
|
||||
if (rv != lv) return rv > lv
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if (current.isEmpty()) return@Thread
|
||||
if (isSemver && !semverNewer(norm(latestTag), norm(current))) return@Thread
|
||||
|
||||
val label = if (isSemver) "$current → $latestTag" else latestTag
|
||||
val msg = "⬆️ Scale Gateway $label"
|
||||
runOnUiThread { showNativeUpdateBanner(msg, apkUrl) }
|
||||
} catch (_: Exception) {}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun showNativeUpdateBanner(message: String, apkUrl: String) {
|
||||
pendingApkDownloadUrl = apkUrl
|
||||
binding.tvUpdateMessage.text = message
|
||||
binding.updateBanner.visibility = View.VISIBLE
|
||||
binding.updateBanner.postDelayed({ binding.updateBanner.visibility = View.GONE }, 30_000)
|
||||
}
|
||||
|
||||
private fun triggerApkDownload(apkUrl: String) {
|
||||
if (apkUrl.isEmpty()) return
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
|
||||
!packageManager.canRequestPackageInstalls()) {
|
||||
pendingApkDownloadUrl = apkUrl // remember for retry
|
||||
installPermLauncher.launch(
|
||||
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:$packageName"))
|
||||
)
|
||||
Toast.makeText(this, "Abilita 'Installa app sconosciute', poi torna qui", Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
// Download to app-private external dir — no storage permission needed
|
||||
val destDir = getExternalFilesDir(null) ?: filesDir
|
||||
val destFile = java.io.File(destDir, "evershelf-scale-update.apk")
|
||||
pendingInstallFile = destFile
|
||||
val dm = getSystemService(DOWNLOAD_SERVICE) as DownloadManager
|
||||
val req = DownloadManager.Request(Uri.parse(apkUrl)).apply {
|
||||
setTitle("EverShelf Scale Gateway — Aggiornamento")
|
||||
setDescription("Scaricamento aggiornamento…")
|
||||
setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||
setDestinationUri(Uri.fromFile(destFile))
|
||||
setMimeType("application/vnd.android.package-archive")
|
||||
}
|
||||
val downloadId = dm.enqueue(req)
|
||||
Toast.makeText(this, "Download avviato…", Toast.LENGTH_SHORT).show()
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(ctx: Context?, intent: Intent?) {
|
||||
val id = intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1)
|
||||
if (id != downloadId) return
|
||||
unregisterReceiver(this)
|
||||
val q = DownloadManager.Query().setFilterById(downloadId)
|
||||
val c = (getSystemService(DOWNLOAD_SERVICE) as DownloadManager).query(q)
|
||||
var ok = false
|
||||
if (c.moveToFirst()) {
|
||||
val status = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
|
||||
ok = (status == DownloadManager.STATUS_SUCCESSFUL)
|
||||
}
|
||||
c.close()
|
||||
if (ok) installApk(destFile)
|
||||
else runOnUiThread {
|
||||
Toast.makeText(this@MainActivity, "Download fallito, riprova", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
// RECEIVER_EXPORTED required: ACTION_DOWNLOAD_COMPLETE is sent by the system DownloadManager
|
||||
// (an external process), so NOT_EXPORTED would silently block the broadcast on API 33+.
|
||||
registerReceiver(receiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE), RECEIVER_EXPORTED)
|
||||
} else {
|
||||
@Suppress("UnspecifiedRegisterReceiverFlag")
|
||||
registerReceiver(receiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(this, "Errore download: ${e.message}", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun installApk(file: java.io.File) {
|
||||
if (!file.exists() || file.length() == 0L) {
|
||||
runOnUiThread { Toast.makeText(this, "File APK non trovato", Toast.LENGTH_LONG).show() }
|
||||
return
|
||||
}
|
||||
try {
|
||||
val pi = packageManager.packageInstaller
|
||||
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
||||
params.setAppPackageName(packageName)
|
||||
val sessionId = pi.createSession(params)
|
||||
pi.openSession(sessionId).use { session ->
|
||||
file.inputStream().use { input ->
|
||||
session.openWrite("package", 0, file.length()).use { out ->
|
||||
input.copyTo(out)
|
||||
session.fsync(out)
|
||||
}
|
||||
}
|
||||
val action = "it.dadaloop.evershelf.scalegate.INSTALL_RESULT_$sessionId"
|
||||
val resultReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(ctx: Context?, intent: Intent?) {
|
||||
unregisterReceiver(this)
|
||||
val status = intent?.getIntExtra(
|
||||
PackageInstaller.EXTRA_STATUS,
|
||||
PackageInstaller.STATUS_FAILURE
|
||||
) ?: PackageInstaller.STATUS_FAILURE
|
||||
when (status) {
|
||||
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
|
||||
// Use launcher so we get notified if system installer fails
|
||||
@Suppress("DEPRECATION")
|
||||
val confirmIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
|
||||
intent?.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
|
||||
else intent?.getParcelableExtra(Intent.EXTRA_INTENT)
|
||||
if (confirmIntent != null) installConfirmLauncher.launch(confirmIntent)
|
||||
}
|
||||
PackageInstaller.STATUS_SUCCESS ->
|
||||
runOnUiThread { Toast.makeText(this@MainActivity, "✅ Aggiornamento installato", Toast.LENGTH_SHORT).show() }
|
||||
PackageInstaller.STATUS_FAILURE_INCOMPATIBLE,
|
||||
PackageInstaller.STATUS_FAILURE_CONFLICT -> {
|
||||
runOnUiThread {
|
||||
AlertDialog.Builder(this@MainActivity)
|
||||
.setTitle("⚠️ Conflitto firma APK")
|
||||
.setMessage("L'app installata usa una firma diversa.\n\nDisinstalla la versione precedente: al termine l'installazione riparte automaticamente.")
|
||||
.setPositiveButton("Disinstalla") { _, _ ->
|
||||
uninstallLauncher.launch(
|
||||
Intent(Intent.ACTION_DELETE, android.net.Uri.parse("package:$packageName"))
|
||||
)
|
||||
}
|
||||
.setNegativeButton("Annulla", null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
val msg = intent?.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
|
||||
?: "status=$status"
|
||||
runOnUiThread { Toast.makeText(this@MainActivity, "Installazione: $msg", Toast.LENGTH_LONG).show() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
|
||||
RECEIVER_NOT_EXPORTED else 0
|
||||
registerReceiver(resultReceiver, IntentFilter(action), flags)
|
||||
val pi2 = PendingIntent.getBroadcast(
|
||||
this, sessionId,
|
||||
Intent(action).setPackage(packageName),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
session.commit(pi2.intentSender)
|
||||
}
|
||||
Toast.makeText(this, "Installazione in corso…", Toast.LENGTH_SHORT).show()
|
||||
} catch (e: Exception) {
|
||||
runOnUiThread { Toast.makeText(this, "Errore installazione: ${e.message}", Toast.LENGTH_LONG).show() }
|
||||
}
|
||||
}
|
||||
|
||||
// ─── RecyclerView adapter ──────────────────────────────────────────────────
|
||||
|
||||
inner class DeviceAdapter(
|
||||
private val items: List<BleDeviceInfo>,
|
||||
private val onClick: (BleDeviceInfo) -> Unit,
|
||||
) : RecyclerView.Adapter<DeviceAdapter.VH>() {
|
||||
|
||||
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
|
||||
val tvName: TextView = view.findViewById(R.id.tv_device_name)
|
||||
val tvAddr: TextView = view.findViewById(R.id.tv_device_addr)
|
||||
val tvRssi: TextView = view.findViewById(R.id.tv_device_rssi)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_device, parent, false)
|
||||
return VH(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: VH, position: Int) {
|
||||
val info = items[position]
|
||||
holder.tvName.text = info.name
|
||||
holder.tvAddr.text = info.device.address
|
||||
holder.tvRssi.text = info.proximity
|
||||
holder.itemView.setOnClickListener { onClick(info) }
|
||||
}
|
||||
|
||||
override fun getItemCount() = items.size
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package it.dadaloop.evershelf.scalegate
|
||||
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import java.util.UUID
|
||||
|
||||
// --- Data model ---
|
||||
|
||||
/**
|
||||
* A single weight reading from a BLE scale.
|
||||
* [value] is in the scale's current display unit (grams, oz, ml, lb).
|
||||
* [unit] is "g", "oz", "ml", or "lb".
|
||||
*/
|
||||
data class WeightReading(
|
||||
val value: Float,
|
||||
val unit: String,
|
||||
val stable: Boolean,
|
||||
)
|
||||
|
||||
// --- UUIDs ---
|
||||
|
||||
val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
||||
|
||||
object BleUuids {
|
||||
// BLE SIG Weight Scale (some kitchen scales use this)
|
||||
val WEIGHT_SCALE_SERVICE = UUID.fromString("0000181d-0000-1000-8000-00805f9b34fb")
|
||||
val WEIGHT_MEASUREMENT_CHAR = UUID.fromString("00002a9d-0000-1000-8000-00805f9b34fb")
|
||||
|
||||
// Battery
|
||||
val BATTERY_SERVICE = UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb")
|
||||
val BATTERY_LEVEL_CHAR = UUID.fromString("00002a19-0000-1000-8000-00805f9b34fb")
|
||||
|
||||
// Common vendor services used by kitchen scales
|
||||
val FFE0 = UUID.fromString("0000ffe0-0000-1000-8000-00805f9b34fb")
|
||||
val FFE1 = UUID.fromString("0000ffe1-0000-1000-8000-00805f9b34fb")
|
||||
val FFF0 = UUID.fromString("0000fff0-0000-1000-8000-00805f9b34fb")
|
||||
val FFF1 = UUID.fromString("0000fff1-0000-1000-8000-00805f9b34fb")
|
||||
val FFF4 = UUID.fromString("0000fff4-0000-1000-8000-00805f9b34fb")
|
||||
|
||||
// Acaia / Brewista coffee scales
|
||||
val ACAIA_SERVICE = UUID.fromString("49535343-fe7d-4ae5-8fa9-9fafd205e455")
|
||||
val ACAIA_CHAR = UUID.fromString("49535343-8841-43f4-a8d4-ecbe34729bb3")
|
||||
|
||||
// QN/Yolanda food scale secondary service (QN-KS, etc.)
|
||||
val QN_AE00 = UUID.fromString("0000ae00-0000-1000-8000-00805f9b34fb")
|
||||
val QN_AE02 = UUID.fromString("0000ae02-0000-1000-8000-00805f9b34fb")
|
||||
}
|
||||
|
||||
// --- Food scale protocol parser ---
|
||||
|
||||
object ScaleProtocol {
|
||||
|
||||
// Plausible kitchen scale range
|
||||
private const val MAX_GRAMS = 15000f
|
||||
private const val MIN_GRAMS = 0.5f // allow tare/small values
|
||||
|
||||
fun resetState() { /* reserved for future use */ }
|
||||
|
||||
fun parse(
|
||||
char: BluetoothGattCharacteristic,
|
||||
data: ByteArray,
|
||||
debug: ((String) -> Unit)? = null,
|
||||
): WeightReading? {
|
||||
if (data.size < 2) {
|
||||
debug?.invoke("skip: packet too short (" + data.size + "B)")
|
||||
return null
|
||||
}
|
||||
|
||||
// UUID-specific parsers
|
||||
when (char.uuid) {
|
||||
BleUuids.WEIGHT_MEASUREMENT_CHAR -> return parseSigWeight(data, debug)
|
||||
}
|
||||
|
||||
// QN/Yolanda food scale (QN-KS, BC-KS, etc.):
|
||||
// 18-byte frame starting with 0x10 0x12 on FFF1
|
||||
if (data.size == 18
|
||||
&& (data[0].toInt() and 0xFF) == 0x10
|
||||
&& (data[1].toInt() and 0xFF) == 0x12) {
|
||||
return parseQNFood(data, debug)
|
||||
}
|
||||
|
||||
return parseGeneric(data, debug)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// BLE SIG 0x2A9D Weight Measurement
|
||||
// -------------------------------------------------------------------------
|
||||
private fun parseSigWeight(data: ByteArray, debug: ((String) -> Unit)? = null): WeightReading? {
|
||||
if (data.size < 3) return null
|
||||
val flags = data[0].toInt() and 0xFF
|
||||
val isImperial = (flags and 0x01) != 0
|
||||
val raw = u16le(data, 1)
|
||||
|
||||
return if (isImperial) {
|
||||
val lb = raw * 0.01f
|
||||
debug?.invoke("SIG 2A9D: raw=$raw -> ${lb}lb")
|
||||
if (lb < 0.01f || lb > 33f) null
|
||||
else WeightReading(lb, "lb", stable = true)
|
||||
} else {
|
||||
val g = raw * 5f // 0.005 kg resolution = 5 g/unit
|
||||
debug?.invoke("SIG 2A9D: raw=$raw -> ${g}g")
|
||||
if (g < MIN_GRAMS || g > MAX_GRAMS) null
|
||||
else WeightReading(g, "g", stable = true)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// QN / Yolanda food scale (QN-KS, BC-KS, YolandaKS, ...)
|
||||
//
|
||||
// 18-byte notification on service 0xFFF0, char 0xFFF1:
|
||||
// [0x10][0x12][00][??][unit][02][05][01][flags][w_hi][w_lo][7E][1F][02][58][02][01][crc]
|
||||
// index: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
|
||||
//
|
||||
// weight = u16BE(data, 9) / 10.0 (0.1-unit resolution)
|
||||
// unit = byte[4]: 0x01=g, 0x02=oz, 0x03=ml(water), 0x04=ml(milk)
|
||||
// stable = bit3 of byte[8] != 0 (0xF8=stable, 0xF0=settling)
|
||||
// crc = sum(bytes[0..16]) mod 256
|
||||
// -------------------------------------------------------------------------
|
||||
private fun parseQNFood(data: ByteArray, debug: ((String) -> Unit)? = null): WeightReading? {
|
||||
// Verify checksum
|
||||
val calc = data.take(17).sumOf { it.toInt() and 0xFF } and 0xFF
|
||||
if (calc != (data[17].toInt() and 0xFF)) {
|
||||
debug?.invoke("QN-KS: CRC mismatch (calc=0x%02X got=0x%02X)".format(calc, data[17].toInt() and 0xFF))
|
||||
return null
|
||||
}
|
||||
|
||||
val rawValue = u16be(data, 9)
|
||||
val stable = (data[8].toInt() and 0x08) != 0
|
||||
val unit = when (data[4].toInt() and 0xFF) {
|
||||
0x01 -> "g"
|
||||
0x02 -> "oz"
|
||||
0x03 -> "ml" // water mode
|
||||
0x04 -> "ml" // milk mode
|
||||
else -> "g"
|
||||
}
|
||||
|
||||
// Resolution is 0.1 unit (e.g. 170 raw = 17.0 g, 195 raw = 19.5 g)
|
||||
val value = rawValue / 10f
|
||||
|
||||
debug?.invoke("QN-KS: ${value}${unit} stable=$stable (raw=$rawValue unit_byte=0x%02X)".format(data[4].toInt() and 0xFF))
|
||||
|
||||
if (rawValue == 0) return null
|
||||
// Convert to grams for range check
|
||||
val valueG = if (unit == "oz") value * 28.3495f else value
|
||||
if (valueG < MIN_GRAMS || valueG > MAX_GRAMS) return null
|
||||
|
||||
return WeightReading(value, unit, stable)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Generic fallback parser
|
||||
// Tries common frame layouts used by many BLE kitchen scales.
|
||||
// -------------------------------------------------------------------------
|
||||
private fun parseGeneric(data: ByteArray, debug: ((String) -> Unit)? = null): WeightReading? {
|
||||
if (data.size < 3) {
|
||||
debug?.invoke("generic: skip short packet (" + data.size + "B)")
|
||||
return null
|
||||
}
|
||||
|
||||
data class C(val pos: Int, val be: Boolean, val div: Float, val label: String)
|
||||
|
||||
val candidates = listOf(
|
||||
// Direct grams (1g resolution)
|
||||
C(1, false, 1f, "pos1 LE g"),
|
||||
C(1, true, 1f, "pos1 BE g"),
|
||||
C(2, false, 1f, "pos2 LE g"),
|
||||
C(2, true, 1f, "pos2 BE g"),
|
||||
C(3, false, 1f, "pos3 LE g"),
|
||||
C(3, true, 1f, "pos3 BE g"),
|
||||
// 0.1g resolution (high-precision scales)
|
||||
C(1, false, 10f, "pos1 LE 0.1g"),
|
||||
C(1, true, 10f, "pos1 BE 0.1g"),
|
||||
C(2, false, 10f, "pos2 LE 0.1g"),
|
||||
C(2, true, 10f, "pos2 BE 0.1g"),
|
||||
C(3, false, 10f, "pos3 LE 0.1g"),
|
||||
C(3, true, 10f, "pos3 BE 0.1g"),
|
||||
// 0.5g resolution
|
||||
C(1, false, 2f, "pos1 LE 0.5g"),
|
||||
C(1, true, 2f, "pos1 BE 0.5g"),
|
||||
// Raw = centgrams (raw*10 = g)
|
||||
C(1, false, 0.1f, "pos1 LE cg"),
|
||||
C(1, true, 0.1f, "pos1 BE cg"),
|
||||
C(3, false, 0.1f, "pos3 LE cg"),
|
||||
C(3, true, 0.1f, "pos3 BE cg"),
|
||||
)
|
||||
|
||||
for (c in candidates) {
|
||||
if (c.pos + 1 >= data.size) continue
|
||||
val raw = if (c.be) u16be(data, c.pos) else u16le(data, c.pos)
|
||||
if (raw == 0) continue
|
||||
val g = raw / c.div
|
||||
if (g in MIN_GRAMS..MAX_GRAMS) {
|
||||
debug?.invoke("generic [${c.label}]: raw=$raw -> ${g}g (unstable)")
|
||||
return WeightReading(g, "g", stable = false)
|
||||
}
|
||||
}
|
||||
debug?.invoke("generic: no valid candidate in " + data.size + " bytes")
|
||||
return null
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
private fun u16le(b: ByteArray, off: Int): Int =
|
||||
(b[off].toInt() and 0xFF) or ((b[off + 1].toInt() and 0xFF) shl 8)
|
||||
|
||||
private fun u16be(b: ByteArray, off: Int): Int =
|
||||
((b[off].toInt() and 0xFF) shl 8) or (b[off + 1].toInt() and 0xFF)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#6C63FF"
|
||||
android:pathData="M0,0h108v108H0z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M54,30L54,70 M40,38L68,38 M36,54L44,38 M64,54L72,38 M36,54C36,56 44,56 44,54 M64,54C64,56 72,56 72,54" />
|
||||
</vector>
|
||||
@@ -0,0 +1,340 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:background="#F3F4F6">
|
||||
|
||||
<!-- ── Update banner (shown at the TOP when a new version is available) ─ -->
|
||||
<LinearLayout
|
||||
android:id="@+id/updateBanner"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:background="#1e293b"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="8dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:gravity="center_vertical"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvUpdateMessage"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:textColor="#fbbf24"
|
||||
android:textSize="13sp"
|
||||
android:text="" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnInstallUpdate"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:text="⬇ Scarica"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#1e293b"
|
||||
android:backgroundTint="#fbbf24"
|
||||
style="@style/Widget.MaterialComponents.Button" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnDismissUpdate"
|
||||
android:layout_width="36dp"
|
||||
android:layout_height="36dp"
|
||||
android:layout_marginStart="4dp"
|
||||
android:text="✕"
|
||||
android:textSize="14sp"
|
||||
android:textColor="#94a3b8"
|
||||
android:backgroundTint="@android:color/transparent"
|
||||
style="@style/Widget.MaterialComponents.Button.TextButton" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<!-- ── Header ─────────────────────────────────────────────────────── -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="⚖️ EverShelf Scale Gateway"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#1E293B"
|
||||
android:paddingBottom="4dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:paddingBottom="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Connect your smart scale to EverShelf via Bluetooth"
|
||||
android:textSize="13sp"
|
||||
android:textColor="#64748B" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_version"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="v?.?.?"
|
||||
android:textSize="11sp"
|
||||
android:textColor="#94A3B8"
|
||||
android:fontFamily="monospace"
|
||||
android:gravity="end" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- ── Gateway URL card ───────────────────────────────────────────── -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="2dp"
|
||||
app:cardBackgroundColor="#EFF6FF">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="🌐 Gateway URL (paste into EverShelf)"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#64748B"
|
||||
android:paddingBottom="4dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_gateway_url"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="ws://…:8765"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#1D4ED8"
|
||||
android:fontFamily="monospace"
|
||||
android:paddingBottom="4dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_gateway_url_hint"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Settings → Smart Scale"
|
||||
android:textSize="11sp"
|
||||
android:textColor="#94A3B8"
|
||||
android:paddingBottom="8dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_copy_url"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="📋 Copy URL"
|
||||
android:backgroundTint="#1D4ED8"
|
||||
android:textColor="#FFFFFF"
|
||||
style="@style/Widget.MaterialComponents.Button" />
|
||||
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<!-- ── Gateway status ────────────────────────────────────────────── -->
|
||||
<TextView
|
||||
android:id="@+id/tv_gateway_status"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="⏳ Starting gateway…"
|
||||
android:textSize="13sp"
|
||||
android:textColor="#64748B"
|
||||
android:paddingBottom="4dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_client_count"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text=""
|
||||
android:textSize="12sp"
|
||||
android:textColor="#059669"
|
||||
android:paddingBottom="12dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<!-- ── Scale connection card ──────────────────────────────────────── -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:id="@+id/card_connection"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="2dp"
|
||||
app:cardBackgroundColor="@android:color/darker_gray">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_scale_status"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="⚡ Ready — scan for a scale"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#FFFFFF"
|
||||
android:paddingBottom="8dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_weight"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="— — —"
|
||||
android:textSize="46sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#FFFFFF"
|
||||
android:gravity="center"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="4dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_weight_hint"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text=""
|
||||
android:textSize="12sp"
|
||||
android:textColor="#E2E8F0"
|
||||
android:gravity="center"
|
||||
android:paddingBottom="8dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_battery"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text=""
|
||||
android:textSize="12sp"
|
||||
android:textColor="#E2E8F0"
|
||||
android:visibility="gone" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_disconnect"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="🔌 Disconnect scale"
|
||||
android:backgroundTint="#EF4444"
|
||||
android:textColor="#FFFFFF"
|
||||
android:visibility="gone"
|
||||
android:layout_marginTop="8dp"
|
||||
style="@style/Widget.MaterialComponents.Button" />
|
||||
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<!-- ── Scan controls ──────────────────────────────────────────────── -->
|
||||
<Button
|
||||
android:id="@+id/btn_scan"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="🔍 Scan for Bluetooth Scales"
|
||||
android:backgroundTint="#7C3AED"
|
||||
android:textColor="#FFFFFF"
|
||||
android:layout_marginBottom="8dp"
|
||||
style="@style/Widget.MaterialComponents.Button" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="8dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_debug"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="\uD83D\uDC1B Debug"
|
||||
android:backgroundTint="#374151"
|
||||
android:textColor="#FFFFFF"
|
||||
android:layout_marginEnd="4dp"
|
||||
style="@style/Widget.MaterialComponents.Button" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_copy_log"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="\uD83D\uDCCB"
|
||||
android:backgroundTint="#374151"
|
||||
android:textColor="#FFFFFF"
|
||||
android:minWidth="48dp"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:visibility="gone"
|
||||
style="@style/Widget.MaterialComponents.Button" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_share_log"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="\uD83D\uDCE4"
|
||||
android:backgroundTint="#374151"
|
||||
android:textColor="#FFFFFF"
|
||||
android:minWidth="48dp"
|
||||
android:visibility="gone"
|
||||
style="@style/Widget.MaterialComponents.Button" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/sv_debug_log"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="220dp"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:background="#111827"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_debug_log"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text=""
|
||||
android:textSize="10sp"
|
||||
android:fontFamily="monospace"
|
||||
android:textColor="#4ADE80"
|
||||
android:padding="8dp" />
|
||||
</ScrollView>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_scan_hint"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Press to scan for nearby BLE scales.\nMake sure the scale is turned on."
|
||||
android:textSize="12sp"
|
||||
android:textColor="#64748B"
|
||||
android:paddingBottom="12dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<!-- ── Device list ─────────────────────────────────────────────────── -->
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/rv_devices"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:nestedScrollingEnabled="false" />
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,58 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
app:cardCornerRadius="10dp"
|
||||
app:cardElevation="1dp"
|
||||
app:cardBackgroundColor="#FFFFFF">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:padding="14dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:text="⚖️"
|
||||
android:textSize="20sp"
|
||||
android:gravity="center"
|
||||
android:layout_marginEnd="12dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_device_name"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#1E293B" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_device_addr"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="11sp"
|
||||
android:fontFamily="monospace"
|
||||
android:textColor="#94A3B8" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_device_rssi"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="11sp"
|
||||
android:textColor="#64748B" />
|
||||
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 165 B |
|
After Width: | Height: | Size: 165 B |
|
After Width: | Height: | Size: 122 B |
|
After Width: | Height: | Size: 122 B |
|
After Width: | Height: | Size: 221 B |
|
After Width: | Height: | Size: 221 B |
|
After Width: | Height: | Size: 413 B |
|
After Width: | Height: | Size: 413 B |
|
After Width: | Height: | Size: 546 B |
|
After Width: | Height: | Size: 546 B |
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_500">#FF6200EE</color>
|
||||
<color name="purple_700">#FF3700B3</color>
|
||||
<color name="teal_200">#FF03DAC5</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="accent">#7C3AED</color>
|
||||
<color name="green">#059669</color>
|
||||
<color name="red">#EF4444</color>
|
||||
<color name="blue">#1D4ED8</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">EverShelf Scale Gateway</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<!-- App-private external dir: no storage permission needed -->
|
||||
<external-files-path name="apk_downloads" path="." />
|
||||
</paths>
|
||||
@@ -0,0 +1,5 @@
|
||||
// Top-level build file
|
||||
plugins {
|
||||
id("com.android.application") version "8.2.2" apply false
|
||||
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
@@ -0,0 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
|
||||
networkTimeout=10000
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1,17 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "EverShelf Scale Gateway"
|
||||
include(":app")
|
||||
@@ -11,7 +11,7 @@
|
||||
<title>EverShelf</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<link rel="icon" type="image/png" href="assets/img/logo/logo_icon.png">
|
||||
<link rel="stylesheet" href="assets/css/style.css?v=20260516b">
|
||||
<link rel="stylesheet" href="assets/css/style.css?v=20260513a">
|
||||
<!-- QuaggaJS for barcode scanning -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2@1.8.4/dist/quagga.min.js"></script>
|
||||
<!-- @xenova/transformers: ES-module bootstrap that exposes a lazy category-classifier as window._categoryPipelinePromise -->
|
||||
@@ -53,9 +53,8 @@
|
||||
<!-- ===== APP PRELOADER (hidden by JS once _initApp completes) ===== -->
|
||||
<div id="app-preloader" aria-hidden="true">
|
||||
<div class="app-preloader-inner">
|
||||
<img src="assets/img/logo/logo.png" alt="EverShelf" class="app-preloader-logo" />
|
||||
<div class="app-preloader-spinner"></div>
|
||||
<span class="app-preloader-version" id="preloader-version">v1.7.15</span>
|
||||
<img src="assets/img/logo/logo.png" alt="EverShelf" class="app-preloader-logo" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -68,7 +67,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.15</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.12</span>
|
||||
</h1>
|
||||
<!-- Update badge — shown alongside title, never replaces it -->
|
||||
<span class="header-update-badge" id="header-update-badge" style="display:none"></span>
|
||||
@@ -188,10 +187,10 @@
|
||||
</div>
|
||||
<div class="location-tabs" id="location-tabs">
|
||||
<button class="tab active" onclick="filterLocation('')" data-loc="" data-i18n="inventory.filter_all">Tutti</button>
|
||||
<button class="tab" onclick="filterLocation('dispensa')" data-loc="dispensa">🗄️ <span data-i18n="locations.dispensa">Dispensa</span></button>
|
||||
<button class="tab" onclick="filterLocation('frigo')" data-loc="frigo">🧊 <span data-i18n="locations.frigo">Frigo</span></button>
|
||||
<button class="tab" onclick="filterLocation('freezer')" data-loc="freezer">❄️ <span data-i18n="locations.freezer">Freezer</span></button>
|
||||
<button class="tab" onclick="filterLocation('altro')" data-loc="altro">📦 <span data-i18n="locations.altro">Altro</span></button>
|
||||
<button class="tab" onclick="filterLocation('dispensa')" data-loc="dispensa">🗄️ Dispensa</button>
|
||||
<button class="tab" onclick="filterLocation('frigo')" data-loc="frigo">🧊 Frigo</button>
|
||||
<button class="tab" onclick="filterLocation('freezer')" data-loc="freezer">❄️ Freezer</button>
|
||||
<button class="tab" onclick="filterLocation('altro')" data-loc="altro">📦 Altro</button>
|
||||
</div>
|
||||
<div class="search-bar">
|
||||
<input type="text" id="inventory-search" placeholder="🔍 Cerca prodotto..." oninput="filterInventory()" data-i18n-placeholder="inventory.search_placeholder">
|
||||
@@ -326,11 +325,11 @@
|
||||
<div class="action-buttons" id="action-buttons-container">
|
||||
<button class="btn btn-huge btn-success" onclick="showAddForm()">
|
||||
<span class="btn-icon">📥</span>
|
||||
<span class="btn-text"><span data-i18n="action.add_btn">AGGIUNGI</span><br><small data-i18n="action.add_sub">in dispensa/frigo</small></span>
|
||||
<span class="btn-text">AGGIUNGI<br><small>in dispensa/frigo</small></span>
|
||||
</button>
|
||||
<button class="btn btn-huge btn-danger" onclick="showUseForm()">
|
||||
<span class="btn-icon">📤</span>
|
||||
<span class="btn-text"><span data-i18n="action.use_btn">USA / CONSUMA</span><br><small data-i18n="action.use_sub">dalla dispensa/frigo</small></span>
|
||||
<span class="btn-text">USA / CONSUMA<br><small>dalla dispensa/frigo</small></span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -338,23 +337,23 @@
|
||||
<!-- ===== ADD TO INVENTORY FORM ===== -->
|
||||
<section class="page" id="page-add">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onclick="showPage('action')" data-i18n="btn.back">← Indietro</button>
|
||||
<h2 data-i18n="add.title">Aggiungi alla Dispensa</h2>
|
||||
<button class="back-btn" onclick="showPage('action')">← Indietro</button>
|
||||
<h2>Aggiungi alla Dispensa</h2>
|
||||
</div>
|
||||
<div class="product-preview-small" id="add-product-preview"></div>
|
||||
<form class="form" onsubmit="submitAdd(event)">
|
||||
<div class="form-group">
|
||||
<label data-i18n="add.location_label">📍 Dove lo metti?</label>
|
||||
<label>📍 Dove lo metti?</label>
|
||||
<div class="location-selector">
|
||||
<button type="button" class="loc-btn active" onclick="selectLocation(this, 'dispensa')">🗄️ <span data-i18n="locations.dispensa">Dispensa</span></button>
|
||||
<button type="button" class="loc-btn" onclick="selectLocation(this, 'frigo')">🧊 <span data-i18n="locations.frigo">Frigo</span></button>
|
||||
<button type="button" class="loc-btn" onclick="selectLocation(this, 'freezer')">❄️ <span data-i18n="locations.freezer">Freezer</span></button>
|
||||
<button type="button" class="loc-btn" onclick="selectLocation(this, 'altro')">📦 <span data-i18n="locations.altro">Altro</span></button>
|
||||
<button type="button" class="loc-btn active" onclick="selectLocation(this, 'dispensa')">🗄️ Dispensa</button>
|
||||
<button type="button" class="loc-btn" onclick="selectLocation(this, 'frigo')">🧊 Frigo</button>
|
||||
<button type="button" class="loc-btn" onclick="selectLocation(this, 'freezer')">❄️ Freezer</button>
|
||||
<button type="button" class="loc-btn" onclick="selectLocation(this, 'altro')">📦 Altro</button>
|
||||
</div>
|
||||
<input type="hidden" id="add-location" value="dispensa">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="add.quantity_label">📦 Quantità</label>
|
||||
<label>📦 Quantità</label>
|
||||
<div class="qty-unit-row">
|
||||
<div class="qty-control flex-1">
|
||||
<button type="button" class="qty-btn" onclick="adjustAddQty(-1)">−</button>
|
||||
@@ -370,7 +369,7 @@
|
||||
</div>
|
||||
<button type="button" id="btn-scale-add" class="btn btn-secondary scale-read-btn" style="display:none" onclick="readScaleWeight('add-quantity', function(){ return document.getElementById('add-unit').value; })" data-i18n="scale.read_btn">⚖️ Leggi dalla bilancia</button>
|
||||
<div id="add-conf-size-row" class="conf-size-row" style="display:none">
|
||||
<label class="conf-size-label" data-i18n="add.conf_size_label">📦 Ogni confezione contiene:</label>
|
||||
<label class="conf-size-label">📦 Ogni confezione contiene:</label>
|
||||
<div class="conf-size-inputs">
|
||||
<input type="number" id="add-conf-size" class="form-input conf-size-input" min="1" step="any" placeholder="es. 300">
|
||||
<select id="add-conf-unit" class="form-input conf-size-unit">
|
||||
@@ -383,43 +382,43 @@
|
||||
</div>
|
||||
<div class="form-group" id="add-vacuum-group">
|
||||
<label class="toggle-row" onclick="toggleVacuumSealed()">
|
||||
<span data-i18n="add.vacuum_label">🫙 Sotto vuoto</span>
|
||||
<span>🫙 Sotto vuoto</span>
|
||||
<span class="toggle-switch" id="add-vacuum-toggle">
|
||||
<input type="checkbox" id="add-vacuum-sealed" onchange="onVacuumSealedChange()">
|
||||
<span class="toggle-slider"></span>
|
||||
</span>
|
||||
</label>
|
||||
<p class="form-hint" id="add-vacuum-hint" style="display:none" data-i18n="add.vacuum_hint">La scadenza verrà estesa automaticamente</p>
|
||||
<p class="form-hint" id="add-vacuum-hint" style="display:none">La scadenza verrà estesa automaticamente</p>
|
||||
</div>
|
||||
<div class="form-group" id="add-expiry-section">
|
||||
<!-- Populated dynamically by showAddForm() -->
|
||||
</div>
|
||||
<button type="submit" class="btn btn-large btn-success full-width" data-i18n="add.submit">✅ Aggiungi</button>
|
||||
<button type="submit" class="btn btn-large btn-success full-width">✅ Aggiungi</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- ===== USE FROM INVENTORY FORM ===== -->
|
||||
<section class="page" id="page-use">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onclick="showPage('action')" data-i18n="btn.back">← Indietro</button>
|
||||
<h2 data-i18n="use.title">Usa / Consuma</h2>
|
||||
<button class="back-btn" onclick="showPage('action')">← Indietro</button>
|
||||
<h2>Usa / Consuma</h2>
|
||||
</div>
|
||||
<div class="product-preview-small" id="use-product-preview"></div>
|
||||
<div class="use-inventory-info" id="use-inventory-info"></div>
|
||||
<div id="use-expiry-hint" style="display:none"></div>
|
||||
<form class="form" onsubmit="submitUse(event)">
|
||||
<div class="form-group" id="use-location-group">
|
||||
<label data-i18n="use.location_label">📍 Da dove?</label>
|
||||
<div class="form-group">
|
||||
<label>📍 Da dove?</label>
|
||||
<div class="location-selector" id="use-location-selector">
|
||||
<button type="button" class="loc-btn active" onclick="selectUseLocation(this, 'dispensa')">🗄️ <span data-i18n="locations.dispensa">Dispensa</span></button>
|
||||
<button type="button" class="loc-btn" onclick="selectUseLocation(this, 'frigo')">🧊 <span data-i18n="locations.frigo">Frigo</span></button>
|
||||
<button type="button" class="loc-btn" onclick="selectUseLocation(this, 'freezer')">❄️ <span data-i18n="locations.freezer">Freezer</span></button>
|
||||
<button type="button" class="loc-btn" onclick="selectUseLocation(this, 'altro')">📦 <span data-i18n="locations.altro">Altro</span></button>
|
||||
<button type="button" class="loc-btn active" onclick="selectUseLocation(this, 'dispensa')">🗄️ Dispensa</button>
|
||||
<button type="button" class="loc-btn" onclick="selectUseLocation(this, 'frigo')">🧊 Frigo</button>
|
||||
<button type="button" class="loc-btn" onclick="selectUseLocation(this, 'freezer')">❄️ Freezer</button>
|
||||
<button type="button" class="loc-btn" onclick="selectUseLocation(this, 'altro')">📦 Altro</button>
|
||||
</div>
|
||||
<input type="hidden" id="use-location" value="dispensa">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="use.quantity_label">Quanto hai usato?</label>
|
||||
<label>Quanto hai usato?</label>
|
||||
<button type="button" id="btn-scale-use" class="btn btn-secondary scale-read-btn" style="display:none" onclick="readScaleWeight('use-quantity', function(){ return _useNormalUnit || 'g'; })" data-i18n="scale.read_btn">⚖️ Leggi dalla bilancia</button>
|
||||
<!-- Live scale weight box (visible when scale connected and unit is g/ml) -->
|
||||
<div id="scale-live-box" class="scale-live-box" style="display:none">
|
||||
@@ -432,21 +431,21 @@
|
||||
</div>
|
||||
<div class="use-unit-switch" id="use-unit-switch" style="display:none">
|
||||
<button type="button" class="use-unit-btn active" id="use-unit-sub" onclick="switchUseUnit('sub')"></button>
|
||||
<button type="button" class="use-unit-btn" id="use-unit-conf" onclick="switchUseUnit('conf')" data-i18n="units.boxes">Confezioni</button>
|
||||
<button type="button" class="use-unit-btn" id="use-unit-conf" onclick="switchUseUnit('conf')">Confezioni</button>
|
||||
</div>
|
||||
<div class="use-options">
|
||||
<button type="button" class="btn btn-large btn-danger full-width use-all-btn" onclick="submitUseAll()" data-i18n="use.use_all">
|
||||
<button type="button" class="btn btn-large btn-danger full-width use-all-btn" onclick="submitUseAll()">
|
||||
🗑️ Usato TUTTO / Finito
|
||||
</button>
|
||||
<div class="use-partial">
|
||||
<p id="use-partial-hint" data-i18n="use.partial_hint">Oppure specifica la quantità usata:</p>
|
||||
<p id="use-partial-hint">Oppure specifica la quantità usata:</p>
|
||||
<div class="qty-control">
|
||||
<button type="button" class="qty-btn" id="use-qty-minus" onclick="adjustUseQty(-1)">−</button>
|
||||
<input type="number" id="use-quantity" value="1" min="0.1" step="any" class="qty-input"
|
||||
oninput="_scaleUserDismissed=true; _cancelScaleTimersOnly();">
|
||||
<button type="button" class="qty-btn" id="use-qty-plus" onclick="adjustUseQty(1)">+</button>
|
||||
</div>
|
||||
<button type="submit" id="btn-use-submit" class="btn btn-large btn-warning full-width mt-2 move-countdown-btn" data-i18n="use.submit">📤 Usa questa quantità</button>
|
||||
<button type="submit" id="btn-use-submit" class="btn btn-large btn-warning full-width mt-2 move-countdown-btn">📤 Usa questa quantità</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -457,19 +456,19 @@
|
||||
<!-- ===== MANUAL / EDIT PRODUCT FORM ===== -->
|
||||
<section class="page" id="page-product-form">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onclick="showPage('scan')" data-i18n="btn.back">← Indietro</button>
|
||||
<button class="back-btn" onclick="showPage('scan')">← Indietro</button>
|
||||
<h2 id="product-form-title">Nuovo Prodotto</h2>
|
||||
</div>
|
||||
<form class="form" onsubmit="submitProduct(event)">
|
||||
<input type="hidden" id="pf-id">
|
||||
<div id="pf-ai-fill-row" class="form-group">
|
||||
<button type="button" class="btn btn-accent full-width" onclick="captureForAIFormFill()" data-i18n="product.ai_fill">
|
||||
<button type="button" class="btn btn-accent full-width" onclick="captureForAIFormFill()">
|
||||
📷 Scatta foto e identifica con AI
|
||||
</button>
|
||||
<p class="form-hint" style="text-align:center;margin-top:4px" data-i18n="product.ai_fill_hint">L'AI compilerà automaticamente i campi del prodotto</p>
|
||||
<p class="form-hint" style="text-align:center;margin-top:4px">L'AI compilerà automaticamente i campi del prodotto</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="product.name_label">🏷️ Nome Prodotto *</label>
|
||||
<label>🏷️ Nome Prodotto *</label>
|
||||
<input type="text" id="pf-name" class="form-input" required placeholder="Es: Latte intero, Pasta penne rigate..."
|
||||
list="common-products" autocomplete="off">
|
||||
<datalist id="common-products">
|
||||
@@ -536,7 +535,7 @@
|
||||
</datalist>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="product.brand_label">🏢 Marca</label>
|
||||
<label>🏢 Marca</label>
|
||||
<input type="text" id="pf-brand" class="form-input" placeholder="Es: Barilla, Granarolo, Mutti..."
|
||||
list="common-brands" autocomplete="off">
|
||||
<datalist id="common-brands">
|
||||
@@ -576,9 +575,9 @@
|
||||
</datalist>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="product.category_label">📂 Categoria</label>
|
||||
<label>📂 Categoria</label>
|
||||
<select id="pf-category" class="form-input" onchange="onCategoryChange(false)">
|
||||
<option value="" data-i18n="form.select_placeholder">-- Seleziona --</option>
|
||||
<option value="">-- Seleziona --</option>
|
||||
<option value="latticini">🥛 Latticini</option>
|
||||
<option value="carne">🥩 Carne</option>
|
||||
<option value="pesce">🐟 Pesce</option>
|
||||
@@ -599,21 +598,21 @@
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group flex-1">
|
||||
<label data-i18n="product.unit_label">📏 Unità di misura</label>
|
||||
<label>📏 Unità di misura</label>
|
||||
<select id="pf-unit" class="form-input" onchange="onPfUnitChange()">
|
||||
<option value="pz" data-i18n="units.pieces">Pezzi</option>
|
||||
<option value="g" data-i18n="units.grams">Grammi</option>
|
||||
<option value="pz">Pezzi</option>
|
||||
<option value="g">Grammi</option>
|
||||
<option value="ml">ml</option>
|
||||
<option value="conf" data-i18n="units.box">Confezione</option>
|
||||
<option value="conf">Confezione</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group flex-1">
|
||||
<label data-i18n="product.default_qty_label">🔢 Quantità default</label>
|
||||
<label>🔢 Quantità default</label>
|
||||
<input type="number" id="pf-defqty" class="form-input" value="1" min="0.1" step="any">
|
||||
</div>
|
||||
</div>
|
||||
<div id="pf-conf-size-row" class="conf-size-row" style="display:none">
|
||||
<label class="conf-size-label" data-i18n="product.conf_size_label">📦 Ogni confezione contiene:</label>
|
||||
<label class="conf-size-label">📦 Ogni confezione contiene:</label>
|
||||
<div class="conf-size-inputs">
|
||||
<input type="number" id="pf-conf-size" class="form-input conf-size-input" min="1" step="any" placeholder="es. 300">
|
||||
<select id="pf-conf-unit" class="form-input conf-size-unit">
|
||||
@@ -623,30 +622,30 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="product.notes_label">📝 Note</label>
|
||||
<label>📝 Note</label>
|
||||
<textarea id="pf-notes" class="form-input" rows="2" placeholder="Es: senza lattosio, bio, conservare in frigo dopo apertura..."></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="product.barcode_label">🔖 Barcode</label>
|
||||
<label>🔖 Barcode</label>
|
||||
<div class="expiry-input-row">
|
||||
<input type="text" id="pf-barcode" class="form-input" placeholder="Codice a barre (se disponibile)" inputmode="numeric" data-i18n-placeholder="product.barcode_placeholder">
|
||||
<input type="text" id="pf-barcode" class="form-input" placeholder="Codice a barre (se disponibile)" inputmode="numeric">
|
||||
<button type="button" class="btn btn-accent btn-scan-expiry" id="pf-barcode-scan-btn" onclick="scanBarcodeForForm()" title="Scansiona barcode">📷</button>
|
||||
</div>
|
||||
<p class="form-hint" id="pf-barcode-hint" style="display:none" data-i18n="product.barcode_hint">⚠️ Aggiungi il barcode così al prossimo acquisto basta scansionarlo!</p>
|
||||
<p class="form-hint" id="pf-barcode-hint" style="display:none">⚠️ Aggiungi il barcode così al prossimo acquisto basta scansionarlo!</p>
|
||||
</div>
|
||||
<input type="hidden" id="pf-image">
|
||||
<div class="product-image-preview" id="pf-image-preview" style="display:none">
|
||||
<img id="pf-image-img" src="" alt="Product">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-large btn-primary full-width" data-i18n="btn.save_product">💾 Salva Prodotto</button>
|
||||
<button type="submit" class="btn btn-large btn-primary full-width">💾 Salva Prodotto</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- ===== ALL PRODUCTS PAGE ===== -->
|
||||
<section class="page" id="page-products">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
|
||||
<h2 data-i18n="products.title">📦 Tutti i Prodotti</h2>
|
||||
<button class="back-btn" onclick="showPage('dashboard')">← Indietro</button>
|
||||
<h2>📦 Tutti i Prodotti</h2>
|
||||
</div>
|
||||
<div class="search-bar">
|
||||
<input type="text" id="products-search" placeholder="🔍 Cerca prodotto..." oninput="searchAllProducts()">
|
||||
@@ -779,8 +778,8 @@
|
||||
<!-- ===== AI IDENTIFICATION PAGE ===== -->
|
||||
<section class="page" id="page-ai">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onclick="stopScanner(); showPage('scan')" data-i18n="btn.back">← Indietro</button>
|
||||
<h2 data-i18n="ai.title">🤖 Identificazione AI</h2>
|
||||
<button class="back-btn" onclick="stopScanner(); showPage('scan')">← Indietro</button>
|
||||
<h2>🤖 Identificazione AI</h2>
|
||||
</div>
|
||||
<div class="ai-container">
|
||||
<div class="ai-capture" id="ai-capture">
|
||||
@@ -791,15 +790,15 @@
|
||||
<img id="ai-image" src="" alt="Captured">
|
||||
</div>
|
||||
<div class="ai-actions">
|
||||
<button class="btn btn-large btn-accent" onclick="takePhotoForAI()" id="ai-capture-btn" data-i18n="ai.capture">
|
||||
<button class="btn btn-large btn-accent" onclick="takePhotoForAI()" id="ai-capture-btn">
|
||||
📸 Scatta Foto
|
||||
</button>
|
||||
<button class="btn btn-large btn-secondary" onclick="retakePhotoAI()" id="ai-retake-btn" style="display:none" data-i18n="ai.retake">
|
||||
<button class="btn btn-large btn-secondary" onclick="retakePhotoAI()" id="ai-retake-btn" style="display:none">
|
||||
🔄 Riscatta
|
||||
</button>
|
||||
</div>
|
||||
<div class="ai-result" id="ai-result" style="display:none"></div>
|
||||
<p class="scan-hint" data-i18n="ai.hint">Scatta una foto del prodotto e l'AI cercherà di identificarlo</p>
|
||||
<p class="scan-hint">Scatta una foto del prodotto e l'AI cercherà di identificarlo</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -839,9 +838,9 @@
|
||||
<h4 data-i18n="settings.gemini.title">🤖 Google Gemini AI</h4>
|
||||
<p class="settings-hint" data-i18n="settings.gemini.hint">Chiave API per identificazione prodotti, scadenze e ricette.</p>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.gemini.key_label">API Key Gemini</label>
|
||||
<label>API Key Gemini</label>
|
||||
<input type="password" id="setting-gemini-key" class="form-input" placeholder="AIza...">
|
||||
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-gemini-key')" data-i18n="btn.toggle_password">👁️ Mostra/Nascondi</button>
|
||||
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-gemini-key')">👁️ Mostra/Nascondi</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -851,13 +850,13 @@
|
||||
<h4 data-i18n="settings.bring.title">🛒 Bring! Shopping List</h4>
|
||||
<p class="settings-hint" data-i18n="settings.bring.hint">Credenziali per l'integrazione con la lista della spesa Bring!</p>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.bring.email_label">📧 Email Bring!</label>
|
||||
<label>📧 Email Bring!</label>
|
||||
<input type="email" id="setting-bring-email" class="form-input" placeholder="email@esempio.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.bring.password_label">🔒 Password Bring!</label>
|
||||
<label>🔒 Password Bring!</label>
|
||||
<input type="password" id="setting-bring-password" class="form-input" placeholder="Password">
|
||||
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-bring-password')" data-i18n="btn.toggle_password">👁️ Mostra/Nascondi</button>
|
||||
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-bring-password')">👁️ Mostra/Nascondi</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Price Estimation Settings -->
|
||||
@@ -928,7 +927,7 @@
|
||||
<h4 data-i18n="settings.recipe.title">🍳 Preferenze Ricette</h4>
|
||||
<p class="settings-hint" data-i18n="settings.recipe.hint">Configura le opzioni predefinite per la generazione delle ricette.</p>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.recipe.persons_label">👥 Persone predefinite</label>
|
||||
<label>👥 Persone predefinite</label>
|
||||
<div class="qty-control">
|
||||
<button type="button" class="qty-btn" onclick="adjustQty('setting-default-persons', -1)">−</button>
|
||||
<input type="number" id="setting-default-persons" value="1" min="1" max="20" class="qty-input">
|
||||
@@ -936,18 +935,18 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.recipe.options_label">🎯 Opzioni ricetta predefinite</label>
|
||||
<label>🎯 Opzioni ricetta predefinite</label>
|
||||
<div class="recipe-pref-checks">
|
||||
<label class="checkbox-label"><input type="checkbox" id="setting-pref-veloce"> <span data-i18n="settings.recipe.fast">⚡ Pasto Veloce</span></label>
|
||||
<label class="checkbox-label"><input type="checkbox" id="setting-pref-pocafame"> <span data-i18n="settings.recipe.light">🥗 Poca Fame</span></label>
|
||||
<label class="checkbox-label"><input type="checkbox" id="setting-pref-scadenze"> <span data-i18n="settings.recipe.expiry">⏰ Priorità Scadenze</span></label>
|
||||
<label class="checkbox-label"><input type="checkbox" id="setting-pref-healthy"> <span data-i18n="settings.recipe.healthy">💚 Extra Salutare</span></label>
|
||||
<label class="checkbox-label"><input type="checkbox" id="setting-pref-opened"> <span data-i18n="settings.recipe.opened">📦 Priorità Cose Aperte</span></label>
|
||||
<label class="checkbox-label"><input type="checkbox" id="setting-pref-zerowaste"> <span data-i18n="settings.recipe.zerowaste">♻️ Zero Sprechi</span></label>
|
||||
<label class="checkbox-label"><input type="checkbox" id="setting-pref-veloce"> ⚡ Pasto Veloce</label>
|
||||
<label class="checkbox-label"><input type="checkbox" id="setting-pref-pocafame"> 🥗 Poca Fame</label>
|
||||
<label class="checkbox-label"><input type="checkbox" id="setting-pref-scadenze"> ⏰ Priorità Scadenze</label>
|
||||
<label class="checkbox-label"><input type="checkbox" id="setting-pref-healthy"> 💚 Extra Salutare</label>
|
||||
<label class="checkbox-label"><input type="checkbox" id="setting-pref-opened"> 📦 Priorità Cose Aperte</label>
|
||||
<label class="checkbox-label"><input type="checkbox" id="setting-pref-zerowaste"> ♻️ Zero Sprechi</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.recipe.dietary_label">🚫 Intolleranze / Restrizioni</label>
|
||||
<label>🚫 Intolleranze / Restrizioni</label>
|
||||
<textarea id="setting-dietary" class="form-input" rows="2" placeholder="Es: senza glutine, senza lattosio, vegetariano..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
@@ -959,7 +958,7 @@
|
||||
<p class="settings-hint" data-i18n="settings.mealplan.hint">Imposta la tipologia di pasto per ogni giorno. Sarà usata come guida nella generazione delle ricette.</p>
|
||||
<div class="form-group" style="margin-bottom:10px">
|
||||
<label class="toggle-row">
|
||||
<span data-i18n="settings.mealplan.enabled">✅ Attiva piano pasti settimanale</span>
|
||||
<span>✅ Attiva piano pasti settimanale</span>
|
||||
<span class="toggle-switch">
|
||||
<input type="checkbox" id="setting-meal-plan-enabled" onchange="onMealPlanEnabledChange(this)">
|
||||
<span class="toggle-slider"></span>
|
||||
@@ -970,15 +969,15 @@
|
||||
<div id="meal-plan-grid" class="mplan-grid"></div>
|
||||
<div id="meal-plan-picker" class="mplan-picker" style="display:none"></div>
|
||||
<div style="margin-top:12px; display:flex; gap:8px; flex-wrap:wrap">
|
||||
<button class="btn btn-small btn-secondary" onclick="resetMealPlan()" data-i18n="settings.mealplan.reset_btn">↺ Ripristina default</button>
|
||||
<button class="btn btn-small btn-secondary" onclick="resetMealPlan()">↺ Ripristina default</button>
|
||||
</div>
|
||||
<div class="settings-hint" style="margin-top:10px" data-i18n-html="settings.mealplan.legend">
|
||||
<div class="settings-hint" style="margin-top:10px">
|
||||
🌤️ = Pranzo · 🌙 = Cena · Tocca un badge per cambiarlo.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-card" id="meal-plan-legend-card">
|
||||
<h4 data-i18n="settings.mealplan.types_title">📋 Tipologie disponibili</h4>
|
||||
<h4>📋 Tipologie disponibili</h4>
|
||||
<div class="mplan-legend"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -995,18 +994,18 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="common-appliances mt-2">
|
||||
<p class="settings-hint" data-i18n="settings.appliances.quick_title">Aggiungi velocemente:</p>
|
||||
<p class="settings-hint">Aggiungi velocemente:</p>
|
||||
<div class="appliance-quick-tags">
|
||||
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Forno')" data-i18n="settings.appliances.oven">🔥 Forno</button>
|
||||
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Microonde')" data-i18n="settings.appliances.microwave">📡 Microonde</button>
|
||||
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Friggitrice ad aria')" data-i18n="settings.appliances.air_fryer">🍟 Friggitrice ad aria</button>
|
||||
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Macchina del pane')" data-i18n="settings.appliances.bread_maker">🍞 Macchina pane</button>
|
||||
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Bimby/Moulinex Cookeo')" data-i18n="settings.appliances.bimby">🤖 Bimby/Cookeo</button>
|
||||
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Planetaria')" data-i18n="settings.appliances.mixer">🌀 Planetaria</button>
|
||||
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Vaporiera')" data-i18n="settings.appliances.steamer">♨️ Vaporiera</button>
|
||||
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Pentola a pressione')" data-i18n="settings.appliances.pressure_cooker">🫕 Pentola pressione</button>
|
||||
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Tostapane')" data-i18n="settings.appliances.toaster">🍞 Tostapane</button>
|
||||
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Frullatore/Mixer')" data-i18n="settings.appliances.blender">🍹 Frullatore</button>
|
||||
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Forno')">🔥 Forno</button>
|
||||
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Microonde')">📡 Microonde</button>
|
||||
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Friggitrice ad aria')">🍟 Friggitrice ad aria</button>
|
||||
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Macchina del pane')">🍞 Macchina pane</button>
|
||||
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Bimby/Moulinex Cookeo')">🤖 Bimby/Cookeo</button>
|
||||
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Planetaria')">🌀 Planetaria</button>
|
||||
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Vaporiera')">♨️ Vaporiera</button>
|
||||
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Pentola a pressione')">🫕 Pentola pressione</button>
|
||||
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Tostapane')">🍞 Tostapane</button>
|
||||
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Frullatore/Mixer')">🍹 Frullatore</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1017,35 +1016,35 @@
|
||||
<h4 data-i18n="settings.camera.title">📷 Fotocamera</h4>
|
||||
<p class="settings-hint" data-i18n="settings.camera.hint">Scegli quale fotocamera utilizzare per la scansione barcode e l'identificazione AI.</p>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.camera.device_label">📸 Fotocamera predefinita</label>
|
||||
<label>📸 Fotocamera predefinita</label>
|
||||
<select id="setting-camera-facing" class="form-input">
|
||||
<option value="environment" data-i18n="settings.camera.back">📱 Posteriore (default)</option>
|
||||
<option value="user" data-i18n="settings.camera.front">🤳 Anteriore</option>
|
||||
<option value="environment">📱 Posteriore (default)</option>
|
||||
<option value="user">🤳 Anteriore</option>
|
||||
</select>
|
||||
<p class="settings-hint mt-2" data-i18n="settings.camera.devices_hint">Se hai più fotocamere, puoi selezionarne una specifica dall'elenco sopra dopo aver concesso i permessi.</p>
|
||||
<button class="btn btn-small btn-secondary mt-2" onclick="loadCameraDevices()" data-i18n="settings.camera.detect_btn">🔄 Rileva fotocamere</button>
|
||||
<p class="settings-hint mt-2">Se hai più fotocamere, puoi selezionarne una specifica dall'elenco sopra dopo aver concesso i permessi.</p>
|
||||
<button class="btn btn-small btn-secondary mt-2" onclick="loadCameraDevices()">🔄 Rileva fotocamere</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Security Tab -->
|
||||
<div class="settings-panel" id="tab-security">
|
||||
<div class="settings-card">
|
||||
<h4 data-i18n="settings.security.token_title">🔑 Token Impostazioni</h4>
|
||||
<p class="settings-hint" data-i18n="settings.security.token_hint">Se <code>SETTINGS_TOKEN</code> è configurato nel <code>.env</code> server, inserisci qui il token prima di salvare le impostazioni. Lascia vuoto se non configurato.</p>
|
||||
<h4>🔑 Token Impostazioni</h4>
|
||||
<p class="settings-hint">Se <code>SETTINGS_TOKEN</code> è configurato nel <code>.env</code> server, inserisci qui il token prima di salvare le impostazioni. Lascia vuoto se non configurato.</p>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.security.token_label">Token di accesso</label>
|
||||
<input type="password" id="setting-settings-token" class="form-input" placeholder="(vuoto = nessuna protezione)" data-i18n-placeholder="settings.security.token_placeholder">
|
||||
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-settings-token')" data-i18n="btn.toggle_password">👁️ Mostra/Nascondi</button>
|
||||
<label>Token di accesso</label>
|
||||
<input type="password" id="setting-settings-token" class="form-input" placeholder="(vuoto = nessuna protezione)">
|
||||
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-settings-token')">👁️ Mostra/Nascondi</button>
|
||||
</div>
|
||||
<p class="settings-hint" id="settings-token-status-hint" style="display:none;color:var(--accent)" data-i18n="settings.security.token_required_hint">🔒 Questo server richiede un token per salvare le impostazioni.</p>
|
||||
<p class="settings-hint" id="settings-token-status-hint" style="display:none;color:var(--accent)">🔒 Questo server richiede un token per salvare le impostazioni.</p>
|
||||
</div>
|
||||
<div class="settings-card">
|
||||
<h4 data-i18n="settings.security.title">🔒 Certificato HTTPS</h4>
|
||||
<p class="settings-hint" data-i18n="settings.security.hint">Se il browser mostra l'errore "La connessione non è privata" (ERR_CERT_AUTHORITY_INVALID), devi installare il certificato CA nel dispositivo.</p>
|
||||
<h4>🔒 Certificato HTTPS</h4>
|
||||
<p class="settings-hint">Se il browser mostra l'errore "La connessione non è privata" (ERR_CERT_AUTHORITY_INVALID), devi installare il certificato CA nel dispositivo.</p>
|
||||
<div class="form-group">
|
||||
<a href="ca.crt" download="EverShelf_CA.crt" class="btn btn-large btn-accent full-width" style="text-align:center;text-decoration:none;display:block" data-i18n="settings.security.download_btn">📥 Scarica Certificato CA</a>
|
||||
<a href="ca.crt" download="EverShelf_CA.crt" class="btn btn-large btn-accent full-width" style="text-align:center;text-decoration:none;display:block">📥 Scarica Certificato CA</a>
|
||||
</div>
|
||||
<div class="settings-hint" style="margin-top:12px;line-height:1.6" data-i18n-html="settings.security.cert_instructions">
|
||||
<div class="settings-hint" style="margin-top:12px;line-height:1.6">
|
||||
<strong>Istruzioni per Chrome (Android):</strong><br>
|
||||
1. Scarica il certificato qui sopra<br>
|
||||
2. Vai in <em>Impostazioni → Sicurezza e privacy → Altre impostazioni di sicurezza → Installa da archivio dispositivo</em><br>
|
||||
@@ -1068,7 +1067,7 @@
|
||||
<p class="settings-hint" data-i18n="settings.tts.hint">Configura la sintesi vocale. Puoi usare la voce offline del browser oppure un endpoint REST esterno (Home Assistant, ecc.).</p>
|
||||
<div class="form-group" style="margin-bottom:10px">
|
||||
<label class="toggle-row">
|
||||
<span data-i18n="settings.tts.enabled">✅ Attiva TTS</span>
|
||||
<span>✅ Attiva TTS</span>
|
||||
<span class="toggle-switch">
|
||||
<input type="checkbox" id="setting-tts-enabled">
|
||||
<span class="toggle-slider"></span>
|
||||
@@ -1076,31 +1075,31 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.tts.engine_label">⚙️ Motore TTS</label>
|
||||
<label>⚙️ Motore TTS</label>
|
||||
<select id="setting-tts-engine" class="form-input" onchange="onTtsEngineChange(this.value)">
|
||||
<option value="browser" data-i18n="settings.tts.engine_browser">🔇 Browser (offline, nessuna configurazione)</option>
|
||||
<option value="server" data-i18n="settings.tts.engine_server">🌐 Server esterno (Home Assistant, API REST...)</option>
|
||||
<option value="browser">🔇 Browser (offline, nessuna configurazione)</option>
|
||||
<option value="server">🌐 Server esterno (Home Assistant, API REST...)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Browser TTS section -->
|
||||
<div id="tts-browser-section">
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.tts.voice_label">🗣️ Voce</label>
|
||||
<label>🗣️ Voce</label>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<select id="setting-tts-voice" class="form-input" style="flex:1">
|
||||
<option value="" data-i18n="settings.tts.voices_loading">— Caricamento voci… —</option>
|
||||
<option value="">— Caricamento voci… —</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-secondary" style="padding:8px 12px;white-space:nowrap;flex-shrink:0" onclick="_initBrowserTtsVoices(document.getElementById('setting-tts-voice').value)">↺</button>
|
||||
</div>
|
||||
<p class="settings-hint" data-i18n="settings.tts.voices_hint">Le voci disponibili dipendono dal sistema operativo e dal browser. Su macOS/iOS è disponibile la voce <strong>Paola</strong> (italiano). Premi ↺ se la lista non si carica.</p>
|
||||
<p class="settings-hint">Le voci disponibili dipendono dal sistema operativo e dal browser. Su macOS/iOS è disponibile la voce <strong>Paola</strong> (italiano). Premi ↺ se la lista non si carica.</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label><span data-i18n="settings.tts.rate_label">⚡ Velocità</span>: <span id="tts-rate-label">1.0</span>×</label>
|
||||
<label>⚡ Velocità: <span id="tts-rate-label">1.0</span>×</label>
|
||||
<input type="range" id="setting-tts-rate" class="form-input" min="0.5" max="2" step="0.1" value="1" oninput="document.getElementById('tts-rate-label').textContent=parseFloat(this.value).toFixed(1)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label><span data-i18n="settings.tts.pitch_label">🎵 Tono</span>: <span id="tts-pitch-label">1.0</span></label>
|
||||
<label>🎵 Tono: <span id="tts-pitch-label">1.0</span></label>
|
||||
<input type="range" id="setting-tts-pitch" class="form-input" min="0" max="2" step="0.1" value="1" oninput="document.getElementById('tts-pitch-label').textContent=parseFloat(this.value).toFixed(1)">
|
||||
</div>
|
||||
</div>
|
||||
@@ -1108,11 +1107,11 @@
|
||||
<!-- Server TTS section -->
|
||||
<div id="tts-server-section" style="display:none">
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.tts.url_label">🌐 URL Endpoint</label>
|
||||
<label>🌐 URL Endpoint</label>
|
||||
<input type="url" id="setting-tts-url" class="form-input" placeholder="https://...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.tts.method_label">📡 Metodo HTTP</label>
|
||||
<label>📡 Metodo HTTP</label>
|
||||
<select id="setting-tts-method" class="form-input">
|
||||
<option value="POST">POST</option>
|
||||
<option value="PUT">PUT</option>
|
||||
@@ -1121,30 +1120,30 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.tts.auth_label">🔐 Autenticazione</label>
|
||||
<label>🔐 Autenticazione</label>
|
||||
<select id="setting-tts-auth-type" class="form-input" onchange="onTtsAuthTypeChange(this.value)">
|
||||
<option value="bearer" data-i18n="settings.tts.auth_bearer">Bearer Token</option>
|
||||
<option value="header" data-i18n="settings.tts.auth_custom">Header personalizzato</option>
|
||||
<option value="none" data-i18n="settings.tts.auth_none">Nessuna</option>
|
||||
<option value="bearer">Bearer Token</option>
|
||||
<option value="header">Header personalizzato</option>
|
||||
<option value="none">Nessuna</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="tts-token-group">
|
||||
<label data-i18n="settings.tts.token_label">🔑 Bearer Token</label>
|
||||
<label>🔑 Bearer Token</label>
|
||||
<input type="password" id="setting-tts-token" class="form-input" placeholder="eyJhbGci...">
|
||||
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-tts-token')" data-i18n="btn.toggle_password">👁️ Mostra/Nascondi</button>
|
||||
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-tts-token')">👁️ Mostra/Nascondi</button>
|
||||
</div>
|
||||
<div id="tts-custom-header-group" style="display:none">
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.tts.custom_header_name">📋 Nome header</label>
|
||||
<label>📋 Nome header</label>
|
||||
<input type="text" id="setting-tts-auth-header-name" class="form-input" placeholder="X-API-Key">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.tts.custom_header_value">📋 Valore header</label>
|
||||
<label>📋 Valore header</label>
|
||||
<input type="text" id="setting-tts-auth-header-value" class="form-input" placeholder="...">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.tts.content_type_label">📄 Content-Type</label>
|
||||
<label>📄 Content-Type</label>
|
||||
<select id="setting-tts-content-type" class="form-input">
|
||||
<option value="application/json">application/json</option>
|
||||
<option value="application/x-www-form-urlencoded">application/x-www-form-urlencoded</option>
|
||||
@@ -1152,18 +1151,18 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.tts.payload_key_label">🗝️ Campo testo nel payload</label>
|
||||
<label>🗝️ Campo testo nel payload</label>
|
||||
<input type="text" id="setting-tts-payload-key" class="form-input" placeholder="message">
|
||||
<p class="settings-hint">Nome del campo JSON che conterrà il testo da leggere (es: <code>message</code>, <code>text</code>).</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.tts.extra_fields_label">➕ Campi extra (JSON)</label>
|
||||
<label>➕ Campi extra (JSON)</label>
|
||||
<textarea id="setting-tts-extra-fields" class="form-input" rows="3" placeholder='{"entity_id": "media_player.living_room"}'></textarea>
|
||||
<p class="settings-hint" data-i18n="settings.tts.extra_fields_hint">Campi aggiuntivi da includere nel payload, in formato JSON. Lascia vuoto se non necessario.</p>
|
||||
<p class="settings-hint">Campi aggiuntivi da includere nel payload, in formato JSON. Lascia vuoto se non necessario.</p>
|
||||
</div>
|
||||
</div><!-- /tts-server-section -->
|
||||
|
||||
<button class="btn btn-large btn-accent full-width mt-2" onclick="testTTS()" data-i18n="settings.tts.test_btn">🔊 Invia Test Vocale</button>
|
||||
<button class="btn btn-large btn-accent full-width mt-2" onclick="testTTS()">🔊 Invia Test Vocale</button>
|
||||
<div id="tts-test-status" style="display:none;margin-top:8px"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1175,14 +1174,14 @@
|
||||
|
||||
<!-- Kiosk-mode panel: replace WebSocket config with native reconfigure button -->
|
||||
<div id="scale-kiosk-panel" style="display:none;background:rgba(16,185,129,0.07);border:1px solid rgba(16,185,129,0.25);border-radius:10px;padding:14px;margin-bottom:16px">
|
||||
<p style="margin:0 0 6px;font-weight:600" data-i18n="settings.scale.kiosk_title">📡 Bilancia BLE integrata nel Kiosk</p>
|
||||
<p class="settings-hint" style="margin-bottom:12px" data-i18n="settings.scale.kiosk_hint">La bilancia è gestita direttamente dal Gateway BLE interno al kiosk. Per abbinare un nuovo dispositivo usa il wizard di configurazione.</p>
|
||||
<button class="btn btn-secondary full-width" onclick="_kioskReconfigureScale()" data-i18n="settings.scale.kiosk_reconfigure">🔄 Riconfigura bilancia BLE</button>
|
||||
<p style="margin:0 0 6px;font-weight:600">📡 Bilancia BLE integrata nel Kiosk</p>
|
||||
<p class="settings-hint" style="margin-bottom:12px">La bilancia è gestita direttamente dal Gateway BLE interno al kiosk. Per abbinare un nuovo dispositivo usa il wizard di configurazione.</p>
|
||||
<button class="btn btn-secondary full-width" onclick="_kioskReconfigureScale()">🔄 Riconfigura bilancia BLE</button>
|
||||
<!-- shown when kiosk APK is too old to have reconfigureScale() -->
|
||||
<div id="kiosk-needs-update-notice" style="display:none;margin-top:10px;padding:8px 12px;background:rgba(245,158,11,0.1);border:1px solid rgba(245,158,11,0.35);border-radius:8px;font-size:0.83rem">
|
||||
<span data-i18n="settings.kiosk.needs_update">⚠️ Il kiosk installato non supporta questa funzione.
|
||||
Aggiorna l'app kiosk per abilitarla.</span>
|
||||
<a href="https://github.com/dadaloop82/EverShelf/releases/download/kiosk-latest/evershelf-kiosk.apk" target="_blank" rel="noopener noreferrer" style="display:block;margin-top:6px;color:#d97706;font-weight:600;text-decoration:none" data-i18n="settings.kiosk.download_btn">📥 Scarica aggiornamento kiosk</a>
|
||||
⚠️ Il kiosk installato non supporta questa funzione.
|
||||
Aggiorna l'app kiosk per abilitarla.
|
||||
<a href="https://github.com/dadaloop82/EverShelf/releases/download/kiosk-latest/evershelf-kiosk.apk" target="_blank" rel="noopener noreferrer" style="display:block;margin-top:6px;color:#d97706;font-weight:600;text-decoration:none">📥 Scarica aggiornamento kiosk</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1230,16 +1229,16 @@
|
||||
</div>
|
||||
<div style="text-align:center">
|
||||
<div id="scale-diag-weight" style="font-size:2rem;font-weight:700;line-height:1;letter-spacing:1px">— g</div>
|
||||
<div style="font-size:0.72rem;color:var(--text-secondary);margin-top:3px" data-i18n="settings.scale.live_weight">peso in tempo reale</div>
|
||||
<div style="font-size:0.72rem;color:var(--text-secondary);margin-top:3px">peso in tempo reale</div>
|
||||
</div>
|
||||
<div style="margin-top:10px;display:flex;gap:8px;font-size:0.78rem;color:var(--text-secondary)">
|
||||
<span data-i18n="settings.scale.auto_reconnect">🔁 Riconnessione: automatica</span>
|
||||
<span>🔁 Riconnessione: automatica</span>
|
||||
<span style="margin-left:auto" id="scale-diag-proto">—</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Protocol info -->
|
||||
<div class="settings-hint" style="margin-top:16px;padding:10px;background:var(--bg-secondary,#f8fafc);border-radius:8px" data-i18n-html="settings.scale.ble_protocols">
|
||||
<div class="settings-hint" style="margin-top:16px;padding:10px;background:var(--bg-secondary,#f8fafc);border-radius:8px">
|
||||
<p style="margin:0 0 6px;font-weight:600">🔌 Protocolli BLE supportati:</p>
|
||||
<ul style="margin:0 0 0 16px;padding:0;font-size:0.8rem">
|
||||
<li>Bluetooth SIG Weight Scale (0x181D)</li>
|
||||
@@ -1275,15 +1274,15 @@
|
||||
</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>
|
||||
<label for="setting-screensaver-timeout" style="font-size:0.85rem;font-weight:600;color:var(--text-secondary)">⏱️ Avvia dopo</label>
|
||||
<select id="setting-screensaver-timeout" class="form-control" style="margin-top:6px;max-width:200px">
|
||||
<option value="1" 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>
|
||||
<option value="1">1 minuto</option>
|
||||
<option value="2">2 minuti</option>
|
||||
<option value="5" selected>5 minuti</option>
|
||||
<option value="10">10 minuti</option>
|
||||
<option value="15">15 minuti</option>
|
||||
<option value="30">30 minuti</option>
|
||||
<option value="60">1 ora</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1303,29 +1302,17 @@
|
||||
<p class="settings-hint" style="margin-top:8px" data-i18n="settings.kiosk.download_sub">Modalità kiosk full-screen + gateway bilancia integrato. Sorgente: <code>evershelf-kiosk/</code></p>
|
||||
</div>
|
||||
|
||||
<!-- Kiosk native settings panel (visible only inside kiosk WebView) -->
|
||||
<div id="kiosk-native-settings-panel" style="display:none;background:rgba(99,102,241,0.06);border:1.5px solid rgba(99,102,241,0.2);border-radius:12px;padding:16px;margin-top:16px">
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px">
|
||||
<span style="font-size:1.4rem">🖥️</span>
|
||||
<div>
|
||||
<p style="margin:0;font-weight:700;font-size:0.9rem" data-i18n="settings.kiosk.native_title">Configurazione Kiosk</p>
|
||||
<p class="settings-hint" style="margin:2px 0 0" data-i18n="settings.kiosk.native_hint">URL server, bilancia BLE, salvaschermo e setup wizard.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-secondary full-width" onclick="_openKioskNativeSettings()">⚙️ <span data-i18n="settings.kiosk.native_btn">Apri configurazione kiosk</span></button>
|
||||
</div>
|
||||
|
||||
<!-- Kiosk self-update panel (visible only inside kiosk WebView) -->
|
||||
<div id="kiosk-update-panel" style="display:none;background:rgba(16,185,129,0.06);border:1.5px solid rgba(16,185,129,0.2);border-radius:12px;padding:16px;margin-top:16px">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:10px">
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<span style="font-size:1.4rem">📦</span>
|
||||
<div>
|
||||
<p style="margin:0;font-weight:700;font-size:0.9rem" data-i18n="settings.kiosk.update_title">Aggiornamento Kiosk</p>
|
||||
<p style="margin:0;font-weight:700;font-size:0.9rem">Aggiornamento Kiosk</p>
|
||||
<p class="settings-hint" style="margin:2px 0 0" id="kiosk-update-version-label">—</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-secondary" style="white-space:nowrap;min-width:120px" id="btn-kiosk-check-update" onclick="_kioskCheckForUpdates()" data-i18n="settings.kiosk.check_updates_btn">🔍 Cerca aggiornamenti</button>
|
||||
<button class="btn btn-secondary" style="white-space:nowrap;min-width:120px" id="btn-kiosk-check-update" onclick="_kioskCheckForUpdates()">🔍 Cerca aggiornamenti</button>
|
||||
</div>
|
||||
<div id="kiosk-update-status" style="display:none;padding:10px 12px;border-radius:8px;font-size:0.85rem;line-height:1.4"></div>
|
||||
<button id="btn-kiosk-install-update" style="display:none;width:100%;margin-top:10px" class="btn btn-accent btn-large" onclick="_kioskInstallUpdate()">⬇️ Installa aggiornamento</button>
|
||||
@@ -1345,7 +1332,7 @@
|
||||
<button class="btn btn-outline full-width" onclick="reportBugManual()" id="btn-report-bug">
|
||||
🐛 <span data-i18n="about.report_bug">Segnala un problema</span>
|
||||
</button>
|
||||
<p class="settings-hint" style="text-align:center;margin:0" data-i18n="about.report_bug_hint">Qualcosa non funziona? Inviaci una segnalazione direttamente dall'app.</p>
|
||||
<p class="settings-hint" style="text-align:center;margin:0" data-i18n="about.report_bug_hint">Qualcosa non funziona? Apri una segnalazione su GitHub.</p>
|
||||
<div style="display:flex;gap:8px">
|
||||
<a class="btn btn-outline full-width" style="text-decoration:none;text-align:center"
|
||||
href="https://github.com/dadaloop82/EverShelf/blob/main/CHANGELOG.md"
|
||||
@@ -1355,6 +1342,7 @@
|
||||
target="_blank" rel="noopener" data-i18n="about.github">GitHub</a>
|
||||
</div>
|
||||
</div>
|
||||
<div id="report-bug-status" style="display:none;margin-top:8px;text-align:center;font-size:0.85rem"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1416,7 +1404,7 @@
|
||||
</button>
|
||||
<button class="nav-btn" onclick="showPage('settings')" data-page="settings">
|
||||
<span class="nav-icon">⚙️</span>
|
||||
<span class="nav-label" data-i18n="nav.settings">Config</span>
|
||||
<span class="nav-label">Config</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
@@ -1485,8 +1473,8 @@
|
||||
</div>
|
||||
<div class="setup-body" id="setup-body"></div>
|
||||
<div class="setup-footer">
|
||||
<button class="btn btn-secondary" id="setup-prev" onclick="setupWizardNav(-1)" style="display:none" data-i18n="btn.back">← Indietro</button>
|
||||
<button class="btn btn-accent" id="setup-next" onclick="setupWizardNav(1)" data-i18n="btn.next">Avanti →</button>
|
||||
<button class="btn btn-secondary" id="setup-prev" onclick="setupWizardNav(-1)" style="display:none">← Indietro</button>
|
||||
<button class="btn btn-accent" id="setup-next" onclick="setupWizardNav(1)">Avanti →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1534,7 +1522,6 @@
|
||||
<button class="cooking-tts-btn" id="cooking-tts-btn" onclick="toggleCookingTTS()" title="Leggi ad alta voce">🔊</button>
|
||||
</div>
|
||||
<div id="cooking-timers-bar" class="cooking-timers-bar" style="display:none"></div>
|
||||
<div id="cooking-tools-bar" class="cooking-tools-bar" style="display:none"></div>
|
||||
<div class="cooking-body">
|
||||
<div class="cooking-step-header">
|
||||
<div class="cooking-step-num" id="cooking-step-num">1 / 1</div>
|
||||
@@ -1560,6 +1547,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="assets/js/app.js?v=20260516b"></script>
|
||||
<script src="assets/js/app.js?v=20260513a"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "EverShelf",
|
||||
"short_name": "EverShelf",
|
||||
"description": "Gestione completa della dispensa di casa con scansione barcode",
|
||||
"version": "1.7.15",
|
||||
"version": "1.7.12",
|
||||
"start_url": "/evershelf/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f0f4e8",
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
"inventory": "Vorrat",
|
||||
"recipes": "Rezepte",
|
||||
"shopping": "Einkauf",
|
||||
"log": "Verlauf",
|
||||
"settings": "Einstellungen"
|
||||
"log": "Verlauf"
|
||||
},
|
||||
"btn": {
|
||||
"back": "← Zurück",
|
||||
@@ -20,8 +19,6 @@
|
||||
"add": "✅ Hinzufügen",
|
||||
"delete": "Löschen",
|
||||
"edit": "✏️ Bearbeiten",
|
||||
"use": "Verwenden",
|
||||
"edit_item": "Bearbeiten",
|
||||
"search": "🔍 Suchen",
|
||||
"go": "✅ Los",
|
||||
"toggle_password": "👁️ Anzeigen/Ausblenden",
|
||||
@@ -31,12 +28,7 @@
|
||||
"restart": "↺ Neustart",
|
||||
"reset_default": "↺ Standard wiederherstellen",
|
||||
"save_info": "💾 Info speichern",
|
||||
"retry": "🔄 Erneut versuchen",
|
||||
"yes_short": "Ja",
|
||||
"no_short": "Nein"
|
||||
},
|
||||
"form": {
|
||||
"select_placeholder": "-- Auswählen --"
|
||||
"retry": "🔄 Erneut versuchen"
|
||||
},
|
||||
"locations": {
|
||||
"dispensa": "Vorratskammer",
|
||||
@@ -71,9 +63,7 @@
|
||||
"pieces": "Stück",
|
||||
"grams": "Gramm",
|
||||
"box": "Packung",
|
||||
"boxes": "Packungen",
|
||||
"millilitres": "Milliliter",
|
||||
"from": "von"
|
||||
"boxes": "Packungen"
|
||||
},
|
||||
"shopping_sections": {
|
||||
"frutta_verdura": "Obst & Gemüse",
|
||||
@@ -225,7 +215,7 @@
|
||||
"throw_btn": "🗑️ ENTSORGEN",
|
||||
"throw_sub": "wegwerfen",
|
||||
"edit_sub": "Ablauf, Ort…",
|
||||
"create_recipe_btn": "Rezept"
|
||||
"create_recipe_btn": "Rezept damit erstellen"
|
||||
},
|
||||
"add": {
|
||||
"title": "Zum Vorrat hinzufügen",
|
||||
@@ -250,9 +240,7 @@
|
||||
"scan_expiry_title": "📷 Ablaufdatum scannen",
|
||||
"product_added": "✅ {name} hinzugefügt!{qty}",
|
||||
"suffix_freezer_vacuum": "(Tiefkühler + vakuumversiegelt)",
|
||||
"history_badge_tip": "Durchschnitt aus {n} früheren Einträgen",
|
||||
"vacuum_question": "Vakuumiert?",
|
||||
"vacuum_saved": "🔒 Als vakuumiert gespeichert"
|
||||
"history_badge_tip": "Durchschnitt aus {n} früheren Einträgen"
|
||||
},
|
||||
"use": {
|
||||
"title": "Verwenden / Verbrauchen",
|
||||
@@ -324,13 +312,7 @@
|
||||
"edit_info": "✏️ Informationen bearbeiten",
|
||||
"modify_details": "BEARBEITEN\nAblauf, Ort…",
|
||||
"already_in_pantry": "📋 Bereits im Vorratsschrank",
|
||||
"no_barcode": "Kein Barcode",
|
||||
"unknown_product": "Unbekanntes Produkt",
|
||||
"edit_name_brand": "Name/Marke bearbeiten",
|
||||
"weight_label": "Gewicht",
|
||||
"origin_label": "Herkunft",
|
||||
"labels_label": "Etiketten",
|
||||
"select_variant": "Genaue Variante auswählen oder KI-Daten nutzen:"
|
||||
"no_barcode": "Kein Barcode"
|
||||
},
|
||||
"products": {
|
||||
"title": "📦 Alle Produkte",
|
||||
@@ -359,7 +341,6 @@
|
||||
"regenerate": "🔄 Noch eins generieren",
|
||||
"close_btn": "✅ Schließen",
|
||||
"ingredients_title": "🧾 Zutaten",
|
||||
"tools_title": "Benötigte Geräte",
|
||||
"steps_title": "👨🍳 Zubereitung",
|
||||
"no_steps": "Keine Zubereitungsschritte verfügbar",
|
||||
"generate_error": "Fehler bei der Generierung",
|
||||
@@ -520,8 +501,7 @@
|
||||
"transfer_to_recipes": "Zu Rezepten hinzufügen",
|
||||
"transferring": "Übertrage...",
|
||||
"transferred": "Zu Rezepten hinzugefügt!",
|
||||
"open_recipe": "Rezept öffnen",
|
||||
"quick_recipe_prompt": "Schlage mir ein schnelles Rezept FÜR EINE PERSON vor, das die Produkte mit dem nächsten Ablaufdatum verwendet! Ignoriere Tiefkühlprodukte, konzentriere dich auf Kühlschrank und Vorratsschrank."
|
||||
"open_recipe": "Rezept öffnen"
|
||||
},
|
||||
"cooking": {
|
||||
"close": "Schließen",
|
||||
@@ -538,8 +518,7 @@
|
||||
"timer_warning_tts": "Achtung! {label}: noch 10 Sekunden!",
|
||||
"recipe_done_tts": "Rezept abgeschlossen! Guten Appetit!",
|
||||
"expires_chip": "läuft ab {date}",
|
||||
"finish": "✅ Fertig",
|
||||
"step_fallback": "Schritt {n}"
|
||||
"finish": "✅ Fertig"
|
||||
},
|
||||
"settings": {
|
||||
"title": "⚙️ Einstellungen",
|
||||
@@ -592,9 +571,8 @@
|
||||
"title": "📅 Wöchentlicher Essensplan",
|
||||
"hint": "Lege die Mahlzeitenart für jeden Tag fest. Wird als Leitfaden bei der Rezeptgenerierung verwendet.",
|
||||
"enabled": "✅ Wöchentlichen Essensplan aktivieren",
|
||||
"legend": "🌤️ = Mittagessen · 🌙 = Abendessen · Tippe auf ein Badge, um es zu ändern.",
|
||||
"types_title": "📋 Verfügbare Typen",
|
||||
"reset_btn": "↺ Standard wiederherstellen"
|
||||
"legend": "🌤️ = Mittagessen · 🌙 = Abendessen · Tippe auf ein Badge, um es zu ändern.",
|
||||
"types_title": "📋 Verfügbare Typen"
|
||||
},
|
||||
"appliances": {
|
||||
"title": "🔌 Verfügbare Geräte",
|
||||
@@ -648,24 +626,12 @@
|
||||
"security": {
|
||||
"title": "🔒 HTTPS-Zertifikat",
|
||||
"hint": "Wenn der Browser den Fehler \"Verbindung nicht sicher\" (ERR_CERT_AUTHORITY_INVALID) zeigt, installiere das CA-Zertifikat auf dem Gerät.",
|
||||
"download_btn": "📥 CA-Zertifikat herunterladen",
|
||||
"token_title": "🔑 Einstellungs-Token",
|
||||
"token_label": "Zugriffstoken",
|
||||
"token_hint": "Falls `SETTINGS_TOKEN` in der Server-`.env` konfiguriert ist, gib hier den Token ein, bevor du die Einstellungen speicherst. Leer lassen, wenn nicht konfiguriert.",
|
||||
"token_placeholder": "(leer = kein Schutz)",
|
||||
"token_required_hint": "🔒 Dieser Server benötigt einen Token zum Speichern der Einstellungen.",
|
||||
"cert_instructions": "<strong>Anleitung für Chrome (Android):</strong><br>1. Zertifikat oben herunterladen<br>2. Gehe zu <em>Einstellungen → Sicherheit & Datenschutz → Weitere Sicherheitseinstellungen → Vom Gerätespeicher installieren</em><br>3. Wähle die heruntergeladene <em>EverShelf_CA.crt</em> Datei<br>4. Wähle \"CA\" und bestätige<br>5. Chrome neu starten<br><br><strong>Anleitung für Chrome (PC):</strong><br>1. Zertifikat oben herunterladen<br>2. Gehe zu <em>chrome://settings/certificates</em> (oder Einstellungen → Datenschutz und Sicherheit → Sicherheit → Zertifikate verwalten)<br>3. Tab \"Zertifizierungsstellen\" → Importieren → Datei auswählen<br>4. Häkchen bei \"Dieser Zertifizierungsstelle für die Identifikation von Webseiten vertrauen\"<br>5. Chrome neu starten"
|
||||
"download_btn": "📥 CA-Zertifikat herunterladen"
|
||||
},
|
||||
"tts": {
|
||||
"title": "🔊 Sprache & TTS",
|
||||
"hint": "Sprachsynthese über externe REST-API konfigurieren. Rezeptschritte und abgelaufene Timer werden an den Endpunkt gesendet.",
|
||||
"enabled": "✅ TTS aktivieren",
|
||||
"engine_label": "⚙️ TTS-Engine",
|
||||
"engine_browser": "🔇 Browser (offline, keine Konfiguration erforderlich)",
|
||||
"engine_server": "🌐 Externer Server (Home Assistant, REST API...)",
|
||||
"voice_label": "🗣️ Stimme",
|
||||
"rate_label": "⚡ Geschwindigkeit",
|
||||
"pitch_label": "🎵 Tonhöhe",
|
||||
"url_label": "🌐 Endpunkt-URL",
|
||||
"method_label": "📡 HTTP-Methode",
|
||||
"auth_label": "🔐 Authentifizierung",
|
||||
@@ -681,14 +647,7 @@
|
||||
"extra_fields_label": "➕ Zusätzliche Felder (JSON)",
|
||||
"extra_fields_placeholder": "{\"entity_id\": \"media_player.living_room\"}",
|
||||
"extra_fields_hint": "Zusätzliche Felder im Payload, im JSON-Format. Leer lassen wenn nicht benötigt.",
|
||||
"test_btn": "🔊 Testansage senden",
|
||||
"voices_loading": "Stimmen werden geladen…",
|
||||
"voice_not_supported": "Stimme vom Browser nicht unterstützt",
|
||||
"voices_none": "Keine Stimmen auf diesem Gerät verfügbar",
|
||||
"voices_hint": "Verfügbare Stimmen hängen vom Betriebssystem und Browser ab. Auf macOS/iOS ist die Stimme Paola (Italienisch) verfügbar. Drücken Sie ↺ wenn die Liste nicht lädt.",
|
||||
"url_missing": "⚠️ Endpunkt-URL fehlt.",
|
||||
"test_sending": "⏳ Wird gesendet…",
|
||||
"test_ok": "✅ Antwort {code} — prüfe ob der Lautsprecher gesprochen hat."
|
||||
"test_btn": "🔊 Testansage senden"
|
||||
},
|
||||
"language": {
|
||||
"title": "🌐 Sprache",
|
||||
@@ -699,15 +658,7 @@
|
||||
"screensaver": {
|
||||
"label": "Bildschirmschoner aktivieren",
|
||||
"card_title": "🌙 Bildschirmschoner",
|
||||
"card_hint": "Zeigt nach 5 Minuten Inaktivität eine Uhr mit nützlichen Fakten. Standardmäßig deaktiviert.",
|
||||
"timeout_1": "1 Minute",
|
||||
"timeout_2": "2 Minuten",
|
||||
"timeout_5": "5 Minuten",
|
||||
"timeout_10": "10 Minuten",
|
||||
"timeout_15": "15 Minuten",
|
||||
"timeout_30": "30 Minuten",
|
||||
"timeout_60": "1 Stunde",
|
||||
"start_after": "⏱️ Starten nach"
|
||||
"card_hint": "Zeigt nach 5 Minuten Inaktivität eine Uhr mit nützlichen Fakten. Standardmäßig deaktiviert."
|
||||
},
|
||||
"scale": {
|
||||
"title": "⚖️ Smart-Waage",
|
||||
@@ -720,26 +671,12 @@
|
||||
"test_btn": "🔗 Verbindung testen",
|
||||
"download_btn": "📥 Android-Gateway herunterladen (APK)",
|
||||
"download_hint": "Android-App als Brücke zwischen BLE-Waage und EverShelf.",
|
||||
"download_sub": "Quellcode: evershelf-scale-gateway/ im Projektstamm",
|
||||
"live_weight": "Echtzeit-Gewicht",
|
||||
"auto_reconnect": "🔁 Verbindung: automatisch",
|
||||
"kiosk_title": "📡 BLE-Waage im Kiosk integriert",
|
||||
"kiosk_hint": "Die Waage wird direkt vom internen BLE-Gateway des Kiosks verwaltet. Um ein neues Gerät zu koppeln, verwende den Konfigurationsassistenten.",
|
||||
"kiosk_reconfigure": "🔄 BLE-Waage neu konfigurieren",
|
||||
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Unterstützte BLE-Protokolle:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) — Gewicht, Fett, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generisch — automatische Heuristik für 100+ Modelle</li></ul>"
|
||||
"download_sub": "Quellcode: evershelf-scale-gateway/ im Projektstamm"
|
||||
},
|
||||
"kiosk": {
|
||||
"hint": "Verwandeln Sie ein Android-Tablet in ein EverShelf-Panel mit integriertem BLE-Waagen-Gateway.",
|
||||
"download_btn": "📥 EverShelf Kiosk herunterladen (APK)",
|
||||
"download_sub": "Vollbild-Kioskmodus + integriertes Waagen-Gateway. Quellcode: evershelf-kiosk/",
|
||||
"native_title": "Kiosk-Konfiguration",
|
||||
"native_hint": "Server-URL, BLE-Waage, Bildschirmschoner und Einrichtungsassistent.",
|
||||
"native_btn": "Kiosk-Konfiguration öffnen",
|
||||
"native_tap_hint": "Zahnrad oben rechts antippen",
|
||||
"native_update_hint": "Kiosk-App aktualisieren, um diese Funktion zu nutzen",
|
||||
"update_title": "Kiosk-Aktualisierung",
|
||||
"check_updates_btn": "🔍 Nach Updates suchen",
|
||||
"needs_update": "⚠️ Das installierte Kiosk unterstützt diese Funktion nicht. Aktualisiere die Kiosk-App, um sie zu aktivieren."
|
||||
"download_sub": "Vollbild-Kioskmodus + integriertes Waagen-Gateway. Quellcode: evershelf-kiosk/"
|
||||
},
|
||||
"saved": "✅ Konfiguration gespeichert!",
|
||||
"saved_local": "✅ Konfiguration lokal gespeichert",
|
||||
@@ -780,8 +717,7 @@
|
||||
"opened_suffix": "— Zu lange geöffnet!",
|
||||
"opened_suffix_ok": "— Geöffnet (noch ok)",
|
||||
"opened_suffix_warning": "— Geöffnet (erst prüfen)",
|
||||
"days_compact": "{n}T",
|
||||
"badge_check_soon": "Bald prüfen"
|
||||
"days_compact": "{n}T"
|
||||
},
|
||||
"status": {
|
||||
"ok": "OK",
|
||||
@@ -873,10 +809,7 @@
|
||||
"select_items": "Wähle mindestens ein Produkt aus",
|
||||
"server_offline": "Serververbindung unterbrochen",
|
||||
"server_restored": "Serververbindung wiederhergestellt",
|
||||
"server_retry": "Erneut versuchen",
|
||||
"unknown": "Unbekannter Fehler",
|
||||
"prefix": "Fehler",
|
||||
"no_inventory_entry": "Kein Inventareintrag gefunden"
|
||||
"server_retry": "Erneut versuchen"
|
||||
},
|
||||
"confirm": {
|
||||
"remove_item": "Möchtest du dieses Produkt wirklich aus dem Bestand entfernen?",
|
||||
@@ -892,9 +825,7 @@
|
||||
"edit": {
|
||||
"title": "{name} bearbeiten",
|
||||
"unknown_hint": "Produktname und Informationen eingeben",
|
||||
"label_name": "🏷️ Produktname",
|
||||
"choose_location_title": "Welchen Ort?",
|
||||
"choose_location_hint": "Wähle den zu bearbeitenden Ort:"
|
||||
"label_name": "🏷️ Produktname"
|
||||
},
|
||||
"screensaver": {
|
||||
"recipe_btn": "Rezepte",
|
||||
@@ -991,8 +922,7 @@
|
||||
"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"
|
||||
},
|
||||
"nova": {
|
||||
"1": "Unverarbeitet",
|
||||
@@ -1160,19 +1090,7 @@
|
||||
"title": "Über",
|
||||
"version": "Version",
|
||||
"report_bug": "Fehler melden",
|
||||
"report_bug_hint": "Etwas funktioniert nicht? Sende uns direkt aus der App eine Meldung.",
|
||||
"report_bug_modal_title": "Fehler melden",
|
||||
"report_type_bug": "Fehler",
|
||||
"report_type_feature": "Funktion",
|
||||
"report_type_question": "Frage",
|
||||
"report_field_title": "Titel",
|
||||
"report_field_title_ph": "Kurze Beschreibung des Problems",
|
||||
"report_field_desc": "Beschreibung",
|
||||
"report_field_desc_ph": "Problem detailliert beschreiben…",
|
||||
"report_field_steps": "Schritte zum Reproduzieren (optional)",
|
||||
"report_field_steps_ph": "1. Gehe zu…\n2. Tippe auf…\n3. Fehler erscheint…",
|
||||
"report_auto_info": "Automatisch beigefügt: Version {version}, Sprache {lang}.",
|
||||
"report_send_btn": "Bericht senden",
|
||||
"report_bug_hint": "Etwas funktioniert nicht? Öffne ein Issue auf GitHub.",
|
||||
"report_bug_sending": "Wird gesendet…",
|
||||
"report_bug_sent": "Bericht gesendet — danke!",
|
||||
"report_bug_error": "Bericht konnte nicht gesendet werden. Verbindung prüfen.",
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
"inventory": "Pantry",
|
||||
"recipes": "Recipes",
|
||||
"shopping": "Shopping",
|
||||
"log": "Log",
|
||||
"settings": "Settings"
|
||||
"log": "Log"
|
||||
},
|
||||
"btn": {
|
||||
"back": "← Back",
|
||||
@@ -20,8 +19,6 @@
|
||||
"add": "✅ Add",
|
||||
"delete": "Delete",
|
||||
"edit": "✏️ Edit",
|
||||
"use": "Use",
|
||||
"edit_item": "Edit",
|
||||
"search": "🔍 Search",
|
||||
"go": "✅ Go",
|
||||
"toggle_password": "👁️ Show/Hide",
|
||||
@@ -31,12 +28,7 @@
|
||||
"restart": "↺ Restart",
|
||||
"reset_default": "↺ Reset to default",
|
||||
"save_info": "💾 Save information",
|
||||
"retry": "🔄 Retry",
|
||||
"yes_short": "Yes",
|
||||
"no_short": "No"
|
||||
},
|
||||
"form": {
|
||||
"select_placeholder": "-- Select --"
|
||||
"retry": "🔄 Retry"
|
||||
},
|
||||
"locations": {
|
||||
"dispensa": "Pantry",
|
||||
@@ -71,9 +63,7 @@
|
||||
"pieces": "Pieces",
|
||||
"grams": "Grams",
|
||||
"box": "Package",
|
||||
"boxes": "Packages",
|
||||
"millilitres": "Millilitres",
|
||||
"from": "of"
|
||||
"boxes": "Packages"
|
||||
},
|
||||
"shopping_sections": {
|
||||
"frutta_verdura": "Fruits & Vegetables",
|
||||
@@ -225,7 +215,7 @@
|
||||
"throw_btn": "🗑️ DISCARD",
|
||||
"throw_sub": "throw away",
|
||||
"edit_sub": "expiry, location…",
|
||||
"create_recipe_btn": "Recipe"
|
||||
"create_recipe_btn": "Create a recipe with this"
|
||||
},
|
||||
"add": {
|
||||
"title": "Add to Pantry",
|
||||
@@ -250,9 +240,7 @@
|
||||
"scan_expiry_title": "📷 Scan Expiry Date",
|
||||
"product_added": "✅ {name} added!{qty}",
|
||||
"suffix_freezer_vacuum": "(freezer + vacuum sealed)",
|
||||
"history_badge_tip": "Average from {n} previous entries",
|
||||
"vacuum_question": "Vacuum sealed?",
|
||||
"vacuum_saved": "🔒 Vacuum sealed!"
|
||||
"history_badge_tip": "Average from {n} previous entries"
|
||||
},
|
||||
"use": {
|
||||
"title": "Use / Consume",
|
||||
@@ -324,13 +312,7 @@
|
||||
"edit_info": "✏️ Edit information",
|
||||
"modify_details": "EDIT\nexpiry, location…",
|
||||
"already_in_pantry": "📋 Already in pantry",
|
||||
"no_barcode": "No barcode",
|
||||
"unknown_product": "Unrecognized product",
|
||||
"edit_name_brand": "Edit name/brand",
|
||||
"weight_label": "Weight",
|
||||
"origin_label": "Origin",
|
||||
"labels_label": "Labels",
|
||||
"select_variant": "Select the exact variant or use AI data:"
|
||||
"no_barcode": "No barcode"
|
||||
},
|
||||
"products": {
|
||||
"title": "📦 All Products",
|
||||
@@ -359,7 +341,6 @@
|
||||
"regenerate": "🔄 Generate another one",
|
||||
"close_btn": "✅ Close",
|
||||
"ingredients_title": "🧾 Ingredients",
|
||||
"tools_title": "Equipment needed",
|
||||
"steps_title": "👨🍳 Steps",
|
||||
"no_steps": "No steps available",
|
||||
"generate_error": "Generation error",
|
||||
@@ -500,8 +481,7 @@
|
||||
"undo_success": "↩ Operation undone for {name}",
|
||||
"already_undone": "Operation already undone",
|
||||
"too_old": "Cannot undo operations older than 24 hours",
|
||||
"undo_error": "Error during undo",
|
||||
"recipe_prefix": "Recipe"
|
||||
"undo_error": "Error during undo"
|
||||
},
|
||||
"chat": {
|
||||
"title": "Gemini Chef",
|
||||
@@ -521,8 +501,7 @@
|
||||
"transfer_to_recipes": "Transfer to Recipes",
|
||||
"transferring": "Transferring...",
|
||||
"transferred": "Added to Recipes!",
|
||||
"open_recipe": "Open recipe",
|
||||
"quick_recipe_prompt": "Suggest a quick recipe FOR ONE PERSON using the products that expire first! Ignore freezer items, focus on fridge and pantry."
|
||||
"open_recipe": "Open recipe"
|
||||
},
|
||||
"cooking": {
|
||||
"close": "Close",
|
||||
@@ -539,8 +518,7 @@
|
||||
"timer_warning_tts": "Heads up! {label}: 10 seconds left!",
|
||||
"recipe_done_tts": "Recipe complete! Enjoy your meal!",
|
||||
"expires_chip": "exp. {date}",
|
||||
"finish": "✅ Finish",
|
||||
"step_fallback": "Step {n}"
|
||||
"finish": "✅ Finish"
|
||||
},
|
||||
"settings": {
|
||||
"title": "⚙️ Settings",
|
||||
@@ -593,9 +571,8 @@
|
||||
"title": "📅 Weekly Meal Plan",
|
||||
"hint": "Set the meal type for each day. It will be used as a guide in recipe generation.",
|
||||
"enabled": "✅ Enable weekly meal plan",
|
||||
"legend": "🌤️ = Lunch · 🌙 = Dinner · Tap a badge to change it.",
|
||||
"types_title": "📋 Available types",
|
||||
"reset_btn": "↺ Restore defaults"
|
||||
"legend": "🌤️ = Lunch · 🌙 = Dinner · Tap a badge to change it.",
|
||||
"types_title": "📋 Available types"
|
||||
},
|
||||
"appliances": {
|
||||
"title": "🔌 Available Appliances",
|
||||
@@ -649,24 +626,12 @@
|
||||
"security": {
|
||||
"title": "🔒 HTTPS Certificate",
|
||||
"hint": "If the browser shows the error \"Your connection is not private\" (ERR_CERT_AUTHORITY_INVALID), you need to install the CA certificate on the device.",
|
||||
"download_btn": "📥 Download CA Certificate",
|
||||
"token_title": "🔑 Settings Token",
|
||||
"token_label": "Access token",
|
||||
"token_hint": "If `SETTINGS_TOKEN` is configured in the server's `.env`, enter the token here before saving settings. Leave empty if not configured.",
|
||||
"token_placeholder": "(empty = no protection)",
|
||||
"token_required_hint": "🔒 This server requires a token to save settings.",
|
||||
"cert_instructions": "<strong>Instructions for Chrome (Android):</strong><br>1. Download the certificate above<br>2. Go to <em>Settings → Security & Privacy → More security settings → Install from device storage</em><br>3. Select the downloaded <em>EverShelf_CA.crt</em> file<br>4. Choose \"CA\" and confirm<br>5. Restart Chrome<br><br><strong>Instructions for Chrome (PC):</strong><br>1. Download the certificate above<br>2. Go to <em>chrome://settings/certificates</em> (or Settings → Privacy and security → Security → Manage certificates)<br>3. Tab \"Authorities\" → Import → select the file<br>4. Check \"Trust this certificate for identifying websites\"<br>5. Restart Chrome"
|
||||
"download_btn": "📥 Download CA Certificate"
|
||||
},
|
||||
"tts": {
|
||||
"title": "🔊 Voice & TTS",
|
||||
"hint": "Configure text-to-speech via any external REST API. Recipe steps and expired timers will be sent to the configured endpoint.",
|
||||
"enabled": "✅ Enable TTS",
|
||||
"engine_label": "⚙️ TTS Engine",
|
||||
"engine_browser": "🔇 Browser (offline, no configuration required)",
|
||||
"engine_server": "🌐 External server (Home Assistant, REST API...)",
|
||||
"voice_label": "🗣️ Voice",
|
||||
"rate_label": "⚡ Speed",
|
||||
"pitch_label": "🎵 Pitch",
|
||||
"url_label": "🌐 Endpoint URL",
|
||||
"method_label": "📡 HTTP Method",
|
||||
"auth_label": "🔐 Authentication",
|
||||
@@ -682,14 +647,7 @@
|
||||
"extra_fields_label": "➕ Extra fields (JSON)",
|
||||
"extra_fields_placeholder": "{\"entity_id\": \"media_player.living_room\"}",
|
||||
"extra_fields_hint": "Additional fields to include in the payload, in JSON format. Leave empty if not needed.",
|
||||
"test_btn": "🔊 Send Test Voice",
|
||||
"voices_loading": "Loading voices…",
|
||||
"voice_not_supported": "Voice not supported by this browser",
|
||||
"voices_none": "No voices available on this device",
|
||||
"voices_hint": "Available voices depend on the OS and browser. On macOS/iOS the Paola (Italian) voice is available. Press ↺ if the list does not load.",
|
||||
"url_missing": "⚠️ Endpoint URL missing.",
|
||||
"test_sending": "⏳ Sending…",
|
||||
"test_ok": "✅ Response {code} — check that the speaker has spoken."
|
||||
"test_btn": "🔊 Send Test Voice"
|
||||
},
|
||||
"language": {
|
||||
"title": "🌐 Language",
|
||||
@@ -700,15 +658,7 @@
|
||||
"screensaver": {
|
||||
"label": "Enable screensaver",
|
||||
"card_title": "🌙 Screensaver",
|
||||
"card_hint": "Shows a clock with useful facts after 5 minutes of inactivity. Disabled by default.",
|
||||
"timeout_1": "1 minute",
|
||||
"timeout_2": "2 minutes",
|
||||
"timeout_5": "5 minutes",
|
||||
"timeout_10": "10 minutes",
|
||||
"timeout_15": "15 minutes",
|
||||
"timeout_30": "30 minutes",
|
||||
"timeout_60": "1 hour",
|
||||
"start_after": "⏱️ Start after"
|
||||
"card_hint": "Shows a clock with useful facts after 5 minutes of inactivity. Disabled by default."
|
||||
},
|
||||
"scale": {
|
||||
"title": "⚖️ Smart Scale",
|
||||
@@ -721,26 +671,12 @@
|
||||
"test_btn": "🔗 Test connection",
|
||||
"download_btn": "📥 Download Android Gateway (APK)",
|
||||
"download_hint": "Android app that bridges your BLE scale and EverShelf.",
|
||||
"download_sub": "Source: evershelf-scale-gateway/ in the project root",
|
||||
"live_weight": "real-time weight",
|
||||
"auto_reconnect": "🔁 Reconnect: automatic",
|
||||
"kiosk_title": "📡 BLE Scale integrated in Kiosk",
|
||||
"kiosk_hint": "The scale is directly managed by the internal BLE Gateway of the kiosk. To pair a new device, use the configuration wizard.",
|
||||
"kiosk_reconfigure": "🔄 Reconfigure BLE Scale",
|
||||
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Supported BLE protocols:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) — weight, fat, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generic — automatic heuristic for 100+ models</li></ul>"
|
||||
"download_sub": "Source: evershelf-scale-gateway/ in the project root"
|
||||
},
|
||||
"kiosk": {
|
||||
"hint": "Turn an Android tablet into an always-on EverShelf panel with built-in BLE scale gateway.",
|
||||
"download_btn": "📥 Download EverShelf Kiosk (APK)",
|
||||
"download_sub": "Full-screen kiosk mode + integrated scale gateway. Source: evershelf-kiosk/",
|
||||
"native_title": "Kiosk Configuration",
|
||||
"native_hint": "Server URL, BLE scale, screensaver and setup wizard.",
|
||||
"native_btn": "Open kiosk configuration",
|
||||
"native_tap_hint": "Tap the gear button at the top right",
|
||||
"native_update_hint": "Update the kiosk app to use this feature",
|
||||
"update_title": "Kiosk Update",
|
||||
"check_updates_btn": "🔍 Check for updates",
|
||||
"needs_update": "⚠️ The installed kiosk does not support this feature. Update the kiosk app to enable it."
|
||||
"download_sub": "Full-screen kiosk mode + integrated scale gateway. Source: evershelf-kiosk/"
|
||||
},
|
||||
"saved": "✅ Configuration saved!",
|
||||
"saved_local": "✅ Configuration saved locally",
|
||||
@@ -781,8 +717,7 @@
|
||||
"opened_suffix": "— Opened too long!",
|
||||
"opened_suffix_ok": "— Opened (still ok)",
|
||||
"opened_suffix_warning": "— Opened (check first)",
|
||||
"days_compact": "{n}d",
|
||||
"badge_check_soon": "Check soon"
|
||||
"days_compact": "{n}d"
|
||||
},
|
||||
"status": {
|
||||
"ok": "OK",
|
||||
@@ -874,10 +809,7 @@
|
||||
"select_items": "Select at least one product",
|
||||
"server_offline": "Server connection lost",
|
||||
"server_restored": "Server connection restored",
|
||||
"server_retry": "Retry",
|
||||
"unknown": "Unknown error",
|
||||
"prefix": "Error",
|
||||
"no_inventory_entry": "No inventory entry found"
|
||||
"server_retry": "Retry"
|
||||
},
|
||||
"confirm": {
|
||||
"remove_item": "Do you really want to remove this product from inventory?",
|
||||
@@ -893,9 +825,7 @@
|
||||
"edit": {
|
||||
"title": "Edit {name}",
|
||||
"unknown_hint": "Enter the product name and information",
|
||||
"label_name": "🏷️ Product name",
|
||||
"choose_location_title": "Which location?",
|
||||
"choose_location_hint": "Choose the location to edit:"
|
||||
"label_name": "🏷️ Product name"
|
||||
},
|
||||
"screensaver": {
|
||||
"recipe_btn": "Recipes",
|
||||
@@ -992,8 +922,7 @@
|
||||
"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"
|
||||
},
|
||||
"nova": {
|
||||
"1": "Unprocessed",
|
||||
@@ -1161,19 +1090,7 @@
|
||||
"title": "About",
|
||||
"version": "Version",
|
||||
"report_bug": "Report a Bug",
|
||||
"report_bug_hint": "Something not working? Send us a report directly from the app.",
|
||||
"report_bug_modal_title": "Report a Bug",
|
||||
"report_type_bug": "Bug",
|
||||
"report_type_feature": "Feature",
|
||||
"report_type_question": "Question",
|
||||
"report_field_title": "Title",
|
||||
"report_field_title_ph": "Brief description of the issue",
|
||||
"report_field_desc": "Description",
|
||||
"report_field_desc_ph": "Describe the issue in detail…",
|
||||
"report_field_steps": "Steps to reproduce (optional)",
|
||||
"report_field_steps_ph": "1. Go to…\n2. Tap…\n3. See the error…",
|
||||
"report_auto_info": "Automatically attached: version {version}, language {lang}.",
|
||||
"report_send_btn": "Send report",
|
||||
"report_bug_hint": "Something not working? Open an issue on GitHub.",
|
||||
"report_bug_sending": "Sending…",
|
||||
"report_bug_sent": "Report sent — thank you!",
|
||||
"report_bug_error": "Could not send the report. Check your connection.",
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
"inventory": "Dispensa",
|
||||
"recipes": "Ricette",
|
||||
"shopping": "Spesa",
|
||||
"log": "Storico",
|
||||
"settings": "Config"
|
||||
"log": "Storico"
|
||||
},
|
||||
"btn": {
|
||||
"back": "← Indietro",
|
||||
@@ -20,8 +19,6 @@
|
||||
"add": "✅ Aggiungi",
|
||||
"delete": "Elimina",
|
||||
"edit": "✏️ Modifica",
|
||||
"use": "Usa",
|
||||
"edit_item": "Modifica",
|
||||
"search": "🔍 Cerca",
|
||||
"go": "✅ Vai",
|
||||
"toggle_password": "👁️ Mostra/Nascondi",
|
||||
@@ -31,12 +28,7 @@
|
||||
"restart": "↺ Ricomincia",
|
||||
"reset_default": "↺ Ripristina default",
|
||||
"save_info": "💾 Salva informazioni",
|
||||
"retry": "🔄 Riprova",
|
||||
"yes_short": "Sì",
|
||||
"no_short": "No"
|
||||
},
|
||||
"form": {
|
||||
"select_placeholder": "-- Seleziona --"
|
||||
"retry": "🔄 Riprova"
|
||||
},
|
||||
"locations": {
|
||||
"dispensa": "Dispensa",
|
||||
@@ -71,9 +63,7 @@
|
||||
"pieces": "Pezzi",
|
||||
"grams": "Grammi",
|
||||
"box": "Confezione",
|
||||
"boxes": "Confezioni",
|
||||
"millilitres": "Millilitri",
|
||||
"from": "da"
|
||||
"boxes": "Confezioni"
|
||||
},
|
||||
"shopping_sections": {
|
||||
"frutta_verdura": "Frutta & Verdura",
|
||||
@@ -225,7 +215,7 @@
|
||||
"throw_btn": "🗑️ BUTTA",
|
||||
"throw_sub": "butta il prodotto",
|
||||
"edit_sub": "scadenza, luogo…",
|
||||
"create_recipe_btn": "Ricetta"
|
||||
"create_recipe_btn": "Crea una ricetta con questo"
|
||||
},
|
||||
"add": {
|
||||
"title": "Aggiungi alla Dispensa",
|
||||
@@ -250,9 +240,7 @@
|
||||
"scan_expiry_title": "📷 Scansiona Data Scadenza",
|
||||
"product_added": "✅ {name} aggiunto!{qty}",
|
||||
"suffix_freezer_vacuum": "(freezer + sotto vuoto)",
|
||||
"history_badge_tip": "Media da {n} inserimenti precedenti",
|
||||
"vacuum_question": "Messo sotto vuoto?",
|
||||
"vacuum_saved": "🔒 Sotto vuoto registrato"
|
||||
"history_badge_tip": "Media da {n} inserimenti precedenti"
|
||||
},
|
||||
"use": {
|
||||
"title": "Usa / Consuma",
|
||||
@@ -324,13 +312,7 @@
|
||||
"edit_info": "✏️ Modifica informazioni",
|
||||
"modify_details": "MODIFICA\nscadenza, luogo…",
|
||||
"already_in_pantry": "📋 Già in dispensa",
|
||||
"no_barcode": "Senza barcode",
|
||||
"unknown_product": "Prodotto non riconosciuto",
|
||||
"edit_name_brand": "Modifica nome/marca",
|
||||
"weight_label": "Peso",
|
||||
"origin_label": "Origine",
|
||||
"labels_label": "Etichette",
|
||||
"select_variant": "Seleziona la variante esatta o usa i dati AI:"
|
||||
"no_barcode": "Senza barcode"
|
||||
},
|
||||
"products": {
|
||||
"title": "📦 Tutti i Prodotti",
|
||||
@@ -359,7 +341,6 @@
|
||||
"regenerate": "🔄 Generane un'altra",
|
||||
"close_btn": "✅ Chiudi",
|
||||
"ingredients_title": "🧾 Ingredienti",
|
||||
"tools_title": "Strumenti necessari",
|
||||
"steps_title": "👨🍳 Procedimento",
|
||||
"no_steps": "Nessun procedimento disponibile",
|
||||
"generate_error": "Errore nella generazione",
|
||||
@@ -500,8 +481,7 @@
|
||||
"undo_success": "↩ Operazione annullata per {name}",
|
||||
"already_undone": "Operazione già annullata",
|
||||
"too_old": "Non è possibile annullare operazioni più vecchie di 24 ore",
|
||||
"undo_error": "Errore durante l'annullamento",
|
||||
"recipe_prefix": "Ricetta"
|
||||
"undo_error": "Errore durante l'annullamento"
|
||||
},
|
||||
"chat": {
|
||||
"title": "Gemini Chef",
|
||||
@@ -521,8 +501,7 @@
|
||||
"transfer_to_recipes": "Trasferisci a Ricette",
|
||||
"transferring": "Trasferimento in corso...",
|
||||
"transferred": "Aggiunta alle Ricette!",
|
||||
"open_recipe": "Apri la ricetta",
|
||||
"quick_recipe_prompt": "Suggeriscimi una ricetta veloce PER UNA PERSONA usando i prodotti che scadono prima! Ignora i prodotti in freezer, concentrati su frigo e dispensa."
|
||||
"open_recipe": "Apri la ricetta"
|
||||
},
|
||||
"cooking": {
|
||||
"close": "Chiudi",
|
||||
@@ -539,8 +518,7 @@
|
||||
"timer_warning_tts": "Attenzione! {label}: mancano 10 secondi!",
|
||||
"recipe_done_tts": "Ricetta completata! Buon appetito!",
|
||||
"expires_chip": "scade {date}",
|
||||
"finish": "✅ Fine",
|
||||
"step_fallback": "Passo {n}"
|
||||
"finish": "✅ Fine"
|
||||
},
|
||||
"settings": {
|
||||
"title": "⚙️ Configurazione",
|
||||
@@ -593,9 +571,8 @@
|
||||
"title": "📅 Piano Pasti Settimanale",
|
||||
"hint": "Imposta la tipologia di pasto per ogni giorno. Sarà usata come guida nella generazione delle ricette.",
|
||||
"enabled": "✅ Attiva piano pasti settimanale",
|
||||
"legend": "🌤️ = Pranzo · 🌙 = Cena · Tocca un badge per cambiarlo.",
|
||||
"types_title": "📋 Tipologie disponibili",
|
||||
"reset_btn": "↺ Ripristina default"
|
||||
"legend": "🌤️ = Pranzo · 🌙 = Cena · Tocca un badge per cambiarlo.",
|
||||
"types_title": "📋 Tipologie disponibili"
|
||||
},
|
||||
"appliances": {
|
||||
"title": "🔌 Elettrodomestici Disponibili",
|
||||
@@ -649,24 +626,12 @@
|
||||
"security": {
|
||||
"title": "🔒 Certificato HTTPS",
|
||||
"hint": "Se il browser mostra l'errore \"La connessione non è privata\" (ERR_CERT_AUTHORITY_INVALID), devi installare il certificato CA nel dispositivo.",
|
||||
"download_btn": "📥 Scarica Certificato CA",
|
||||
"token_title": "🔑 Token Impostazioni",
|
||||
"token_label": "Token di accesso",
|
||||
"token_hint": "Se `SETTINGS_TOKEN` è configurato nel `.env` server, inserisci qui il token prima di salvare le impostazioni. Lascia vuoto se non configurato.",
|
||||
"token_placeholder": "(vuoto = nessuna protezione)",
|
||||
"token_required_hint": "🔒 Questo server richiede un token per salvare le impostazioni.",
|
||||
"cert_instructions": "<strong>Istruzioni per Chrome (Android):</strong><br>1. Scarica il certificato qui sopra<br>2. Vai in <em>Impostazioni → Sicurezza e privacy → Altre impostazioni di sicurezza → Installa da archivio dispositivo</em><br>3. Seleziona il file <em>EverShelf_CA.crt</em> scaricato<br>4. Scegli \"CA\" e conferma<br>5. Riavvia Chrome<br><br><strong>Istruzioni per Chrome (PC):</strong><br>1. Scarica il certificato qui sopra<br>2. Vai in <em>chrome://settings/certificates</em> (o Impostazioni → Privacy e sicurezza → Sicurezza → Gestisci certificati)<br>3. Tab \"Autorità\" → Importa → seleziona il file<br>4. Spunta \"Considera attendibile per identificare siti web\"<br>5. Riavvia Chrome"
|
||||
"download_btn": "📥 Scarica Certificato CA"
|
||||
},
|
||||
"tts": {
|
||||
"title": "🔊 Voce & TTS",
|
||||
"hint": "Configura la sintesi vocale tramite qualsiasi API REST esterna. I passi della ricetta e i timer scaduti verranno inviati all'endpoint configurato.",
|
||||
"enabled": "✅ Attiva TTS",
|
||||
"engine_label": "⚙️ Motore TTS",
|
||||
"engine_browser": "🔇 Browser (offline, nessuna configurazione)",
|
||||
"engine_server": "🌐 Server esterno (Home Assistant, API REST...)",
|
||||
"voice_label": "🗣️ Voce",
|
||||
"rate_label": "⚡ Velocità",
|
||||
"pitch_label": "🎵 Tono",
|
||||
"url_label": "🌐 URL Endpoint",
|
||||
"method_label": "📡 Metodo HTTP",
|
||||
"auth_label": "🔐 Autenticazione",
|
||||
@@ -682,14 +647,7 @@
|
||||
"extra_fields_label": "➕ Campi extra (JSON)",
|
||||
"extra_fields_placeholder": "{\"entity_id\": \"media_player.living_room\"}",
|
||||
"extra_fields_hint": "Campi aggiuntivi da includere nel payload, in formato JSON. Lascia vuoto se non necessario.",
|
||||
"test_btn": "🔊 Invia Test Vocale",
|
||||
"voices_loading": "Caricamento voci…",
|
||||
"voice_not_supported": "Voce non supportata dal browser",
|
||||
"voices_none": "Nessuna voce disponibile su questo dispositivo",
|
||||
"voices_hint": "Le voci disponibili dipendono dal sistema operativo e dal browser. Su macOS/iOS è disponibile la voce Paola (italiano). Premi ↺ se la lista non si carica.",
|
||||
"url_missing": "⚠️ URL endpoint mancante.",
|
||||
"test_sending": "⏳ Invio in corso…",
|
||||
"test_ok": "✅ Risposta {code} — controlla che l'altoparlante abbia parlato."
|
||||
"test_btn": "🔊 Invia Test Vocale"
|
||||
},
|
||||
"language": {
|
||||
"title": "🌐 Lingua / Language",
|
||||
@@ -700,15 +658,7 @@
|
||||
"screensaver": {
|
||||
"label": "Attiva salvaschermo",
|
||||
"card_title": "🌙 Salvaschermo",
|
||||
"card_hint": "Mostra un orologio con fatti utili dopo 5 minuti di inattività. Di default è disattivato.",
|
||||
"timeout_1": "1 minuto",
|
||||
"timeout_2": "2 minuti",
|
||||
"timeout_5": "5 minuti",
|
||||
"timeout_10": "10 minuti",
|
||||
"timeout_15": "15 minuti",
|
||||
"timeout_30": "30 minuti",
|
||||
"timeout_60": "1 ora",
|
||||
"start_after": "⏱️ Avvia dopo"
|
||||
"card_hint": "Mostra un orologio con fatti utili dopo 5 minuti di inattività. Di default è disattivato."
|
||||
},
|
||||
"scale": {
|
||||
"title": "⚖️ Bilancia Smart",
|
||||
@@ -721,26 +671,12 @@
|
||||
"test_btn": "🔗 Testa connessione",
|
||||
"download_btn": "📥 Scarica Gateway Android (APK)",
|
||||
"download_hint": "App Android che fa da ponte tra la bilancia BLE e questo sito.",
|
||||
"download_sub": "Sorgente: evershelf-scale-gateway/ nella root del progetto",
|
||||
"live_weight": "peso in tempo reale",
|
||||
"auto_reconnect": "🔁 Riconnessione: automatica",
|
||||
"kiosk_title": "📡 Bilancia BLE integrata nel Kiosk",
|
||||
"kiosk_hint": "La bilancia è gestita direttamente dal Gateway BLE interno al kiosk. Per abbinare un nuovo dispositivo usa il wizard di configurazione.",
|
||||
"kiosk_reconfigure": "🔄 Riconfigura bilancia BLE",
|
||||
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Protocolli BLE supportati:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) — peso, grasso, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generico — heuristica automatica su 100+ modelli</li></ul>"
|
||||
"download_sub": "Sorgente: evershelf-scale-gateway/ nella root del progetto"
|
||||
},
|
||||
"kiosk": {
|
||||
"hint": "Trasforma un tablet Android in un pannello EverShelf sempre acceso, con bilancia BLE integrata.",
|
||||
"download_btn": "📥 Scarica EverShelf Kiosk (APK)",
|
||||
"download_sub": "Modalità kiosk full-screen + gateway bilancia integrato. Sorgente: evershelf-kiosk/",
|
||||
"native_title": "Configurazione Kiosk",
|
||||
"native_hint": "URL server, bilancia BLE, salvaschermo e setup wizard.",
|
||||
"native_btn": "Apri configurazione kiosk",
|
||||
"native_tap_hint": "Tocca la rotella in alto a destra",
|
||||
"native_update_hint": "Aggiorna l'app kiosk per usare questa funzione",
|
||||
"update_title": "Aggiornamento Kiosk",
|
||||
"check_updates_btn": "🔍 Cerca aggiornamenti",
|
||||
"needs_update": "⚠️ Il kiosk installato non supporta questa funzione. Aggiorna l'app kiosk per abilitarla."
|
||||
"download_sub": "Modalità kiosk full-screen + gateway bilancia integrato. Sorgente: evershelf-kiosk/"
|
||||
},
|
||||
"saved": "✅ Configurazione salvata!",
|
||||
"saved_local": "✅ Configurazione salvata localmente",
|
||||
@@ -781,8 +717,7 @@
|
||||
"opened_suffix": "— Aperto da troppo tempo!",
|
||||
"opened_suffix_ok": "— Aperto (ancora ok)",
|
||||
"opened_suffix_warning": "— Aperto (controlla)",
|
||||
"days_compact": "{n}gg",
|
||||
"badge_check_soon": "Controlla presto"
|
||||
"days_compact": "{n}gg"
|
||||
},
|
||||
"status": {
|
||||
"ok": "OK",
|
||||
@@ -874,10 +809,7 @@
|
||||
"select_items": "Seleziona almeno un prodotto",
|
||||
"server_offline": "Connessione al server persa",
|
||||
"server_restored": "Connessione al server ripristinata",
|
||||
"server_retry": "Riprova",
|
||||
"unknown": "Errore sconosciuto",
|
||||
"prefix": "Errore",
|
||||
"no_inventory_entry": "Nessuna voce di inventario trovata"
|
||||
"server_retry": "Riprova"
|
||||
},
|
||||
"confirm": {
|
||||
"remove_item": "Vuoi davvero rimuovere questo prodotto dall'inventario?",
|
||||
@@ -893,9 +825,7 @@
|
||||
"edit": {
|
||||
"title": "Modifica {name}",
|
||||
"unknown_hint": "Inserisci il nome e le informazioni del prodotto",
|
||||
"label_name": "🏷️ Nome prodotto",
|
||||
"choose_location_title": "Quale modifica?",
|
||||
"choose_location_hint": "Scegli la posizione da modificare:"
|
||||
"label_name": "🏷️ Nome prodotto"
|
||||
},
|
||||
"screensaver": {
|
||||
"recipe_btn": "Ricette",
|
||||
@@ -992,8 +922,7 @@
|
||||
"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"
|
||||
},
|
||||
"nova": {
|
||||
"1": "Non trasformato",
|
||||
@@ -1161,19 +1090,7 @@
|
||||
"title": "Informazioni",
|
||||
"version": "Versione",
|
||||
"report_bug": "Segnala un problema",
|
||||
"report_bug_hint": "Qualcosa non funziona? Inviaci una segnalazione direttamente dall'app.",
|
||||
"report_bug_modal_title": "Segnala un problema",
|
||||
"report_type_bug": "Bug",
|
||||
"report_type_feature": "Funzionalità",
|
||||
"report_type_question": "Domanda",
|
||||
"report_field_title": "Titolo",
|
||||
"report_field_title_ph": "Breve descrizione del problema",
|
||||
"report_field_desc": "Descrizione",
|
||||
"report_field_desc_ph": "Descrivi il problema in dettaglio…",
|
||||
"report_field_steps": "Passi per riprodurlo (opzionale)",
|
||||
"report_field_steps_ph": "1. Vai su…\n2. Tocca…\n3. Vedi l'errore…",
|
||||
"report_auto_info": "Saranno allegati automaticamente: versione {version}, lingua {lang}.",
|
||||
"report_send_btn": "Invia segnalazione",
|
||||
"report_bug_hint": "Qualcosa non funziona? Apri una segnalazione su GitHub.",
|
||||
"report_bug_sending": "Invio in corso…",
|
||||
"report_bug_sent": "Segnalazione inviata — grazie!",
|
||||
"report_bug_error": "Impossibile inviare la segnalazione. Controlla la connessione.",
|
||||
|
||||