Compare commits

..

15 Commits

Author SHA1 Message Date
dependabot[bot] 1cb4ae13f1 ci: bump gradle/actions from 3 to 6
Bumps [gradle/actions](https://github.com/gradle/actions) from 3 to 6.
- [Release notes](https://github.com/gradle/actions/releases)
- [Commits](https://github.com/gradle/actions/compare/v3...v6)

---
updated-dependencies:
- dependency-name: gradle/actions
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-16 06:04:55 +00:00
github-actions[bot] 189b640309 chore: auto-merge develop → main
Triggered by: da4bd63 feat: professional repo cleanup + community infrastructure
2026-05-16 06:04:18 +00:00
dadaloop82 da4bd635db feat: professional repo cleanup + community infrastructure
- README: remove Recent Updates section, clean roadmap (pending only),
  replace Screenshots with demo link, add 6 new badges (stars, last
  commit, contributors, discussions, CI), invite GIF contributions
- CHANGELOG: translate all Italian entries to English, add v1.7.13
  (DB fresh-install crash fix)
- database.php: add missing 'undone' column to transactions schema;
  wrap ALTER TABLE calls in try/catch to prevent race-condition errors
  on concurrent first requests
- Wiki: Android-Kiosk v1.5.0 → v1.6.0, Step 5 rewritten (BLE scan,
  no external APK), removed gateway troubleshooting section
- Wiki: Scale-Gateway promoted to deprecated with redirect banner
- Wiki: Home What's New updated to v1.7.12 / v1.7.13
- Wiki: Features.md, kiosk README Italian UI strings translated
- .github: add bug_report.yml, feature_request.yml issue templates,
  config.yml (blank issues off, links to FAQ/Discussions/Security),
  PULL_REQUEST_TEMPLATE.md with checklist
- .github: FUNDING.yml (Ko-fi), dependabot.yml (monthly action updates)
- .github/workflows/security.yml: Trivy docker + fs scan, SARIF upload
- .github/workflows/build-scale-gateway.yml: disabled (deprecated)
- SECURITY.md: responsible disclosure policy, supported versions, scope
- CODE_OF_CONDUCT.md: Contributor Covenant 2.1
- Settings UI: About section with version display, Report Bug button,
  Changelog and GitHub links; reportBugManual() + _loadAboutSection()
- Translations: added 'about' key group (9 keys × 3 languages)
2026-05-16 06:02:18 +00:00
dadaloop82 ab6aca2f01 Merge branch 'develop' into main 2026-05-15 11:41:59 +00:00
dadaloop82 850c5047b8 Fix noisy consumption alerts and make predictions adaptive 2026-05-15 11:41:29 +00:00
dadaloop82 3e44f5bb24 merge: scale thresholds correction from develop 2026-05-14 14:53:45 +00:00
dadaloop82 02964ecf23 fix(scale): revert autofill min to 10g; keep 2g only for stability filter and live box 2026-05-14 14:53:42 +00:00
dadaloop82 49e5319f4c merge: scale 2g noise filter from develop 2026-05-14 14:49:58 +00:00
dadaloop82 3ebe551b9e fix(scale): ignore weight variations < 2g everywhere (stability, live box, autofill) 2026-05-14 14:49:56 +00:00
dadaloop82 0e1eccfe33 merge: kiosk update check fixes from develop 2026-05-14 14:47:50 +00:00
dadaloop82 4624811707 fix(kiosk): periodic update check every 30min + persist pending update across restarts 2026-05-14 14:47:47 +00:00
dadaloop82 3607ebf1d7 merge: move modal fixes from develop 2026-05-14 12:22:47 +00:00
dadaloop82 8bb6c01b7d fix: move modal countdown stops on touch; vacuum btn no longer triggers native settings 2026-05-14 12:22:45 +00:00
dadaloop82 b1a882f92d merge: kiosk crash detection from develop 2026-05-14 11:47:12 +00:00
dadaloop82 1b7b271b43 fix(kiosk): detect ANR/OOM/native crashes on restart via ApplicationExitInfo + dirty sentinel 2026-05-14 11:47:05 +00:00
28 changed files with 1273 additions and 257 deletions
+1
View File
@@ -0,0 +1 @@
ko_fi: dadaloop82
+114
View File
@@ -0,0 +1,114 @@
name: Bug Report
description: Report a bug or unexpected behavior in EverShelf
title: "[BUG] "
labels: ["bug"]
assignees: ["dadaloop82"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report a bug! Please fill in the details below.
Before submitting, check the [FAQ](https://github.com/dadaloop82/EverShelf/wiki/FAQ) and [existing issues](https://github.com/dadaloop82/EverShelf/issues?q=is%3Aissue+label%3Abug).
- type: input
id: version
attributes:
label: EverShelf Version
description: Found in Settings → About, or in the footer of the web app.
placeholder: "e.g. 1.7.13"
validations:
required: true
- type: dropdown
id: component
attributes:
label: Component
description: Which part of EverShelf is affected?
options:
- Web app (browser / PWA)
- Android Kiosk app
- API / PHP backend
- Docker setup
- Bring! integration
- AI features (Gemini)
- Smart Scale
- Other
validations:
required: true
- type: textarea
id: description
attributes:
label: Bug Description
description: A clear and concise description of the bug.
placeholder: "What went wrong?"
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to Reproduce
description: How can we reproduce this?
placeholder: |
1. Go to '...'
2. Tap '...'
3. See error
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior
description: What should have happened?
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual Behavior
description: What actually happened? Include error messages, screenshots, or console output.
validations:
required: true
- type: input
id: browser
attributes:
label: Browser / OS
placeholder: "e.g. Chrome 124 on Android 13, Safari on iOS 17, Firefox on Ubuntu 22.04"
- type: input
id: php
attributes:
label: PHP Version (if relevant)
placeholder: "e.g. 8.2.12 — run: php -v"
- type: dropdown
id: install
attributes:
label: Installation Method
options:
- Docker (docker compose)
- Manual (Apache/Nginx)
- Other
- type: textarea
id: logs
attributes:
label: Relevant Logs
description: PHP error log, browser console output, or `data/error_reports.log` snippet.
render: text
- type: checkboxes
id: checklist
attributes:
label: Checklist
options:
- label: I searched existing issues and this is not a duplicate
required: true
- label: I checked the FAQ
required: true
- label: I am on the latest version (or this bug exists on the latest version)
required: false
+11
View File
@@ -0,0 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: 📖 Wiki & FAQ
url: https://github.com/dadaloop82/EverShelf/wiki/FAQ
about: Check the FAQ — your question may already be answered there.
- name: 💬 Discussions — Q&A
url: https://github.com/dadaloop82/EverShelf/discussions
about: General questions, show-and-tell, ideas — use Discussions, not Issues.
- name: 🔒 Security Vulnerability
url: mailto:evershelfproject@gmail.com
about: Please report security vulnerabilities privately via email, not as a public issue.
@@ -0,0 +1,68 @@
name: Feature Request
description: Suggest a new feature or improvement
title: "[FEATURE] "
labels: ["enhancement"]
assignees: ["dadaloop82"]
body:
- type: markdown
attributes:
value: |
Thanks for the idea! Check the [Roadmap](https://github.com/dadaloop82/EverShelf/blob/main/README.md#-roadmap) and [Discussions](https://github.com/dadaloop82/EverShelf/discussions) first — it may already be planned or discussed.
- type: dropdown
id: category
attributes:
label: Category
options:
- Inventory management
- Shopping list
- AI / Gemini features
- Cooking mode
- Dashboard / stats
- Kiosk app
- Smart Scale
- Integrations (Bring!, HA, etc.)
- Performance / developer experience
- Translations / i18n
- Other
validations:
required: true
- type: textarea
id: problem
attributes:
label: Problem / Motivation
description: What pain point does this address? Why do you need this?
placeholder: "I'm always frustrated when..."
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed Solution
description: Describe what you'd like to see added or changed.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered
description: Any workarounds you've tried, or other solutions you considered?
- type: textarea
id: context
attributes:
label: Additional Context
description: Screenshots, mockups, links to similar features in other apps, etc.
- type: checkboxes
id: checklist
attributes:
label: Checklist
options:
- label: I checked the Roadmap and this is not already planned
required: true
- label: I searched existing issues and discussions — this is not a duplicate
required: true
+47
View File
@@ -0,0 +1,47 @@
## Description
<!-- What does this PR do? Link the related issue: "Closes #123" or "Relates to #123" -->
Closes #
---
## Type of Change
- [ ] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
- [ ] Refactor / cleanup (no functional change)
- [ ] Documentation update
- [ ] Translation update
---
## Testing
<!-- How was this tested? -->
- [ ] Tested locally (PHP built-in server or Docker)
- [ ] Tested on mobile browser
- [ ] Tested with Docker Compose: `docker compose up --build`
- [ ] PHP syntax: `php -l api/index.php && php -l api/database.php`
- [ ] JS syntax: `node --check assets/js/app.js`
---
## Translation
- [ ] New user-visible strings added → translation keys added to **all three** files: `translations/it.json`, `en.json`, `de.json`
- [ ] No user-visible strings changed
---
## CHANGELOG
- [ ] Entry added to `CHANGELOG.md` under `## [Unreleased]` or the correct version
---
## Screenshots / Video
<!-- If this is a UI change, add before/after screenshots. Delete this section if not applicable. -->
+10
View File
@@ -0,0 +1,10 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"
commit-message:
prefix: "ci"
labels:
- "dependencies"
+1 -1
View File
@@ -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'
+9 -8
View File
@@ -1,13 +1,14 @@
name: Build & Release Scale Gateway APK
name: Build & Release Scale Gateway APK (DEPRECATED)
# ⚠️ This workflow is disabled. The Scale Gateway is deprecated since Kiosk v1.6.0.
# BLE scale support is now built into the EverShelf Kiosk app.
# Kept for reference — re-enable manually via workflow_dispatch if needed for legacy setups.
on:
push:
branches:
- main
- develop
paths:
- 'evershelf-scale-gateway/**'
workflow_dispatch:
inputs:
confirm:
description: "Type 'yes' to confirm you want to build the deprecated gateway APK"
required: true
permissions:
contents: write
@@ -28,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'
+68
View File
@@ -0,0 +1,68 @@
name: Security Scan (Trivy)
on:
push:
branches: [main, develop]
paths:
- 'Dockerfile'
- 'docker-compose.yml'
- 'api/**'
schedule:
# Run weekly on Monday at 07:00 UTC
- cron: '0 7 * * 1'
workflow_dispatch:
jobs:
trivy-docker:
name: Trivy — Docker image scan
runs-on: ubuntu-latest
permissions:
security-events: write
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Build Docker image
run: docker build -t evershelf:scan .
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: 'evershelf:scan'
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
exit-code: '0' # don't fail the build, just report
- name: Upload Trivy SARIF to GitHub Security tab
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: 'trivy-results.sarif'
category: 'trivy-docker'
trivy-fs:
name: Trivy — Filesystem scan
runs-on: ubuntu-latest
permissions:
security-events: write
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Run Trivy filesystem scanner
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
scan-ref: '.'
format: 'sarif'
output: 'trivy-fs-results.sarif'
severity: 'CRITICAL,HIGH'
exit-code: '0'
- name: Upload Trivy FS SARIF
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: 'trivy-fs-results.sarif'
category: 'trivy-fs'
+52 -67
View File
@@ -5,105 +5,90 @@ 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).
## [1.7.13] - 2026-05-16
### Fixed
- **Fresh-install crash: `no such column: undone`** — The `transactions` table was created in `initializeDB()` without the `undone` column, but the composite index `idx_transactions_pid_type_undone` immediately referenced it, crashing every new installation at first DB access. Added `undone INTEGER DEFAULT 0` to the transactions schema in `initializeDB()`.
- **Race condition: `duplicate column name: package_unit`** — Concurrent API requests on a new installation could all pass the `PRAGMA table_info` guard simultaneously and each try to `ALTER TABLE products ADD COLUMN package_unit`, with all but the first failing with a PDOException. Wrapped all `ALTER TABLE … ADD COLUMN` calls in try/catch to silently ignore duplicate-column errors.
## [1.7.12] - 2026-05-13
### Fixed
- **Banner "Usa prima" con data calcolata confusa** — `_renderUseExpiryHint` mostrava una data di scadenza *calcolata* (shelf life dopo apertura) anziché la data reale. Ora, se il prodotto ha `opened_at`, il banner mostra "Quella [nel frigo], aperta da X giorni — usala prima!" usando la nuova chiave `use.expiry_warning_opened`.
- **"Usa TUTTO / Finito" nelle ricette cancellava la riga** — `submitRecipeUse(true)` inviava `use_all: true` all'API che eseguiva un `DELETE` diretto sulla riga di inventario senza conferma. La funzione ora calcola la quantità esatta dagli item disponibili (`_recipeUseContext.items`) e invia un normale `inventory_use` con quantità esplicita.
- **Ricette: `qty_number` in grammi per prodotti `pz`** — Il prompt AI e la post-elaborazione PHP ora istruiscono Gemini a esprimere `qty_number` come pezzi interi per ingredienti con unità `pz` (Pan bauletto, fette biscottate, ecc.). La lista ingredienti nel prompt include `[usa PEZZI interi]` per ogni prodotto `pz`. Il fallback PHP per `pz` senza `default_quantity` non divide più per 100 (trattando grammi come pezzi), ma usa il `qty_number` restituito dall'AI se sembra un conteggio plausibile, altrimenti 1.
- **"Use first" banner showed a calculated expiry date** — `_renderUseExpiryHint` was displaying a *calculated* shelf-life date (from opening date) instead of the actual one. When `opened_at` is set, the banner now shows "That one [in the fridge], opened X days ago — use it first!" using the new `use.expiry_warning_opened` translation key.
- **"Use All / Done" in recipes deleted the inventory row** — `submitRecipeUse(true)` was sending `use_all: true` to the API, which executed a direct `DELETE` on the inventory row without any confirmation. The function now calculates the exact quantity from the available items (`_recipeUseContext.items`) and sends a regular `inventory_use` with an explicit quantity.
- **Recipes: `qty_number` returned in grams for piece-counted (`pz`) items** — The AI prompt and PHP post-processing now instruct Gemini to express `qty_number` as whole pieces for ingredients with unit `pz` (sliced bread, crackers, etc.). The ingredient list in the prompt includes `[use whole PIECES]` for each `pz` product. The PHP fallback for `pz` items without `default_quantity` no longer divides by 100, but uses the AI-returned `qty_number` if it is a plausible count, otherwise defaults to 1.
### Added
- **Traduzione `use.expiry_warning_opened`** — Nuova chiave in `it.json`, `en.json`, `de.json` con placeholder `{loc}` (posizione) e `{when}` (giorni dall'apertura).
- **Translation key `use.expiry_warning_opened`** — New key in `it.json`, `en.json`, `de.json` with `{loc}` (location) and `{when}` (days since opening) placeholders.
## [1.7.11] - 2026-05-12
### Added
- **Scan page redesign** — La pagina di scansione è stata completamente ridisegnata per tablet e mobile:
- **2× zoom fisso** — zoom hardware se disponibile, altrimenti CSS `scale(2)` automatico.
- **Torcia** — bottone nel viewport con feedback toast e stato visivo.
- **Flip fotocamera** — switch front/back con persistenza in settings.
- **3 tab input** — Barcode / Nome / AI per un accesso rapido a ciascuna modalità.
- **Prodotti recenti** — chip degli ultimi 6 prodotti scansionati (localStorage), con icona categoria.
- **Live code overlay** — codice barcode rilevato parzialmente mostrato in sovrimpressione nel viewport.
- **Confirm overlay** — checkmark + nome prodotto per 900ms al riconoscimento avvenuto.
- **Angoli guida** — frame visivo per inquadrare il barcode.
- **AI Number OCR** — dopo 4s senza scansione, compare il bottone "Leggi numeri con AI": Gemini analizza l'immagine e legge le cifre del barcode anche se non viene letto otticamente.
- **PHP `gemini_number_ocr`** — Nuovo endpoint POST; accetta un'immagine JPEG base64, chiede a Gemini di individuare il codice EAN-13 / EAN-8 stampato sul prodotto, e restituisce le cifre o `not_found`.
- **Scan page redesign** — The scanner page has been completely redesigned for tablet and mobile:
- **2× fixed zoom** — hardware zoom if available, otherwise automatic CSS `scale(2)`.
- **Torch** — in-viewport button with toast feedback and visual state indicator.
- **Camera flip** — front/back switch with persistence in settings.
- **3 input tabs** — Barcode / Name / AI for quick access to each scanning mode.
- **Recent products** — chips for the last 6 scanned products (localStorage), with category icon.
- **Live code overlay** — partially detected barcode shown as overlay in the viewport during partial scan.
- **Confirm overlay** — checkmark + product name displayed for 900 ms on successful recognition.
- **Guide corners** — visual alignment frame for barcode centering.
- **AI Number OCR** — after 4 s without a scan, a "Read numbers with AI" button appears; Gemini analyses the video frame and returns barcode digits even when the optical scanner fails.
- **PHP `gemini_number_ocr` endpoint** — New POST endpoint; accepts a base64 JPEG image, asks Gemini to locate the EAN-13 / EAN-8 code printed on the product, and returns the digits or `not_found`.
### Fixed
- **Falsi positivi anomalia consumo "Mozzarella 3 pezzi"** — Rimossa la direzione `untracked` (consumo maggiore degli acquisti registrati) che generava banner su ogni prodotto con acquisti non tracciati. Ora vengono segnalate solo le anomalie `phantom` e `missing`.
- **Predizione "~0g/settimana"** — Il modello richiedeva ora min 5 transazioni (era 3) e un arco temporale di almeno 7 giorni; se il consumo predetto è < 15% della baseline viene saltato, eliminando i falsi positivi su prodotti con poche transazioni ravvicinate.
- **Menu a tendina suggerimenti sul campo Nome (scan)** — Rimosso `list="common-products"` dal campo di input, il datalist non viene più aperto su tablet.
- **False consumption anomaly positives (e.g. "Mozzarella 3 pcs")** — Removed the `untracked` direction (consumption higher than recorded purchases), which was generating banners for every product with untracked purchase history. Only `phantom` and `missing` anomalies are now reported.
- **"~0 g/week" consumption prediction** — The model now requires a minimum of 5 transactions (was 3) and a time span of at least 7 days; predictions where consumption is < 15% of the baseline are skipped, eliminating false positives for products with few closely-spaced transactions.
- **Suggestion dropdown on the Name field (scan page)** — Removed `list="common-products"` from the input field; the datalist is no longer triggered on tablets.
## [1.7.10] - 2026-05-11
### Fixed
- **Banner "Imposta scadenza" non faceva nulla** — `editBannerNoExpiry()` chiamava `openEditInventoryModal()` che non esiste. Corretto in `editInventoryItem()` (la funzione corretta usata da tutti gli altri handler banner). Aggiunto anche il fetch preventivo di `inventory_list` perché `currentInventory` è vuoto sulla dashboard.
- **"Prodotto non trovato" aprendo modal da banner** — `currentInventory` è sempre vuoto sulla dashboard; il fetch dell'inventario ora avviene prima di aprire la modal (stesso pattern di `editReviewItem` e `weighBannerItem`).
- **Banner scaduto su latte UHT aperto** — Il testo mostrava "Scaduto!" invece di "Aperto da troppo tempo". Ora i prodotti con `opened_at` mostrano "Aperto da N giorni in [posizione]" sia nel titolo che nel dettaglio del banner.
- **Shelf life latte generico 4 → 7 giorni** — Il latte senza qualificatori (es. "Latte") veniva trattato come fresco (4 giorni). Il latte fresco è già gestito esplicitamente (`latte fresco/intero/parzial/scremato` → 3gg); il generico ora vale 7 giorni (default UHT). Fix applicato sia in PHP (`database.php`) che in JS (`app.js`).
- **`opened_at` stale sulle confezioni intere dopo split** — Quando un uso splitta la riga in "confezioni intere + frazione aperta", la riga delle intere non azzerava `opened_at`. Ora tutti e 3 i percorsi di split eseguono `opened_at = NULL` sulla riga sigillata.
- **`inventory_update` non registrava transazioni** — La modal di modifica quantità aggiornava l'inventario senza creare transazioni. La differenza viene ora registrata automaticamente come `'in'` o `'out'` con nota `[Correzione manuale]`, evitando falsi positivi nel rilevatore di anomalie.
- **False anomalie di consumo dopo la spesa** — La baseline della prediction usava solo la quantità del rifornimento (`restockQty`), ignorando le scorte preesistenti → `actual > expected` sistematicamente. Nuova baseline: `qty_attuale + consumato_da_ultimo_rifornimento`, che riflette correttamente la realtà indipendentemente dalle scorte pregresse.
- **Banner "consumo anomalo" su quasi tutti i prodotti** — Due fix:
1. `expected = 0` non genera più anomalia "more" (il modello pensa che dovresti aver finito, ma hai ricomprato).
2. Soglia "more than expected" alzata al 400% (era 30%); "less than expected" rimane al 30%.
- **Sezione scaduti mostra prodotti già buttati** — La query `expired` mancava di `AND i.quantity > 0`; i prodotti buttati (qty=0) con scadenza passata continuavano ad apparire. Corretta la query + pulizia righe orfane nel DB.
- **Hardcoded `scade il` in banner** — Stringa italiana hardcodata nel dettaglio del banner scaduti rimossa.
- **Docker: `SQLSTATE[HY000][14] unable to open database file`** — Aggiunta `_ensureDataDir()` in `database.php` che crea la directory se mancante e tenta `chmod(0775)` se non scrivibile.
- **"Set expiry" banner did nothing** — `editBannerNoExpiry()` was calling `openEditInventoryModal()` which does not exist. Fixed to call `editInventoryItem()` (the correct function used by all other banner handlers). Added a prefetch of `inventory_list` because `currentInventory` is empty on the dashboard.
- **"Product not found" when opening modal from a banner** — `currentInventory` is always empty on the dashboard; the inventory fetch now happens before opening the modal (same pattern as `editReviewItem` and `weighBannerItem`).
- **Expired banner on opened UHT milk** — The banner was showing "Expired!" instead of "Opened too long". Items with `opened_at` now display "Opened X days ago in [location]" in both the title and the banner detail.
- **Generic milk shelf life 4 → 7 days** — Milk without qualifiers (e.g. "Milk") was treated as fresh (4 days). Fresh milk is still handled explicitly (`latte fresco/intero/parzial/scremato` → 3 days); the generic case now defaults to 7 days (UHT default). Fix applied in both PHP (`database.php`) and JS (`app.js`).
- **Stale `opened_at` on sealed packages after split** — When a use operation splits a row into "whole sealed packages + opened fraction", the sealed-packages row was not clearing `opened_at`. All 3 split code paths now execute `opened_at = NULL` on the sealed row.
- **`inventory_update` was not recording transactions** — The quantity-edit modal updated inventory without creating transaction records. The quantity difference is now automatically recorded as `in` or `out` with a `[Manual correction]` note, preventing false positives in the anomaly detector.
- **False consumption anomalies after restocking** — The prediction baseline was using only the restock quantity (`restockQty`), ignoring pre-existing stock, causing `actual > expected` systematically. New baseline: `current_qty + consumed_since_last_restock`, which correctly reflects the real situation regardless of prior stock levels.
- **Anomaly banner firing on almost all products** — Two fixes:
1. `expected = 0` no longer generates a "more" anomaly (the model assumed you should have run out, but you restocked).
2. "More than expected" threshold raised to 400% (was 30%); "less than expected" threshold remains at 30%.
- **Expired section showing already-discarded products** — The `expired` query was missing `AND i.quantity > 0`; discarded products (qty=0) with a past expiry kept appearing. Query fixed and orphan rows cleaned from the DB.
- **Hardcoded Italian string `scade il` in banner** — Replaced with the correct i18n key.
- **Docker: `SQLSTATE[HY000][14] unable to open database file`** — `_ensureDataDir()` in `database.php` now creates the `data/` directory if missing and attempts `chmod(0775)` if not writable, resolving the error on freshly mounted Docker volumes.
### Added
- **i18n completa** — Aggiunti ~25 chiavi di traduzione mancanti per UI kiosk, gemini, banner, scanner, shopping, appliances in tutti e 3 i file (`it.json`, `en.json`, `de.json`). Totale: 934 chiavi per lingua.
### Added
- **Category badge on inventory items** — Every product in the inventory now displays a macro-category badge (icon + label) next to the location badge. Badges showing `altro` are asynchronously refined via the new `guess_category` AI endpoint (Gemini + `data/category_ai_cache.json` cache) so the correct category appears automatically after the page loads.
- **Category search** — The inventory search bar now matches items by category. Typing "biscotti" returns every cookie/biscuit regardless of brand or exact name; the match uses both the direct category key and the translated label.
- **Brand map in `guessCategoryFromName`** — A fast-path brand table (Oreo, Ringo, Uno, Barilla, De Cecco, Galbani, Mutti, Lavazza, etc.) provides instant category resolution before any regex evaluation.
- **PHP `guess_category` endpoint** — New server-side action that calls Gemini to classify a product name into a local category key, with file-based caching (`data/category_ai_cache.json`). Returns `altro` immediately when no Gemini API key is configured.
### Fixed
- **Duplicate banner alerts** — `loadBannerAlerts()` was occasionally enqueuing the same item multiple times when called concurrently. Fixed with a `_bannerLoading` re-entrancy guard and a `_queuedItemIds` Set that prevents any item from being pushed more than once per refresh cycle.
- **`mapToLocalCategory` with `en:dairies` / `en:dairies-and-eggs`** — The dairy regex was not matching OpenFoodFacts tags that use the `dairi` stem; extended to cover the full range of dairy tags.
- **`mapToLocalCategory` always returning `altro`** — When the input category was already `altro`, the function exited the direct-match loop before attempting any fallback, losing all name-based guesses. The loop now skips the `altro` key for the early-return and falls back to `guessCategoryFromName(productName)` at the end.
- **"Tonno all'olio" → condimenti** — `tonno\b` was matched after `olio\b` (condimenti) due to regex ordering. Moved the conserve block before the condimenti block so tuna products resolve correctly.
### Security
- **AI function guards** — All Gemini-powered functions now check `_geminiAvailable` (JS) or the presence of `GEMINI_API_KEY` (PHP) before executing. Affected functions: `_refineCategoryBadgesAsync`, `fetchAllPrices`, `getShoppingPrice`. The PHP endpoint returns `{"success":false,"error":"no_api_key"}` instead of silently returning empty results, making the missing-key state explicit and diagnosable.
- **Complete i18n** — Added ~25 missing translation keys for kiosk UI, Gemini responses, banners, scanner, shopping, and appliances across all 3 language files (`it.json`, `en.json`, `de.json`). Total: 934 keys per language.
## [1.7.8] - 2026-05-10
### Added
- **Trasferisci a Ricette dalla chat** — Quando la chat con Gemini Chef genera una ricetta, compare il bottone "📥 Trasferisci a Ricette". Premendolo, Gemini converte il testo in JSON strutturato completo (titolo, pasti, ingredienti, passi), il backend arricchisce ogni ingrediente con product_id e location via fuzzy-match (identico a generateRecipe), la ricetta viene salvata in archivio e si apre direttamente nella sezione Ricette con tutti i pulsanti "Usa" e la modalità cottura completa.
- **Bottone "Apri la ricetta"** — Dopo un trasferimento riuscito, il bottone "📥 Trasferisci a Ricette" si trasforma direttamente in "📖 Apri la ricetta" (stesso elemento DOM), evitando problemi di sovrapposizione.
- **Crea una ricetta per ingrediente** — Nel pannello azione di ogni alimento in inventario compare il bottone "👨‍🍳 Crea una ricetta con questo" (teal, larghezza piena). Premendolo, Gemini genera una ricetta italiana usando quell'alimento come protagonista (stesso pipeline di chatToRecipe: arricchimento fuzzy-match inventario, meal=null, 8192 token max).
- **meal non auto-categorizzato** — Le ricette generate da chat o da ingrediente non vengono più auto-categorizzate (meal rimane null); il tag pasto nell'UI viene mostrato solo se valorizzato.
- **Transfer to Recipes from chat** — When the Gemini Chef chat generates a recipe, a "📥 Transfer to Recipes" button appears. Pressing it triggers Gemini to convert the chat text into a complete structured JSON (title, meal, ingredients, steps); the backend enriches each ingredient with `product_id` and `location` via fuzzy-match (identical to `generateRecipe`); the recipe is saved and opens directly in the Recipes section with all "Use" buttons and full cooking mode.
- **"Open recipe" button** — After a successful transfer, the "📥 Transfer to Recipes" button transforms into "📖 Open recipe" (same DOM element), preventing overlap.
- **Create a recipe from an ingredient** — In the action panel of every inventory item, a "👨‍🍳 Create a recipe with this" button appears (teal, full width). Pressing it, Gemini generates a recipe using that ingredient as the star (same pipeline as `chatToRecipe`: inventory fuzzy-match enrichment, `meal=null`, 8192 token max).
- **Meal not auto-categorized** — Recipes generated from chat or from an ingredient are no longer auto-categorized (`meal` remains null); the meal tag in the UI is only shown when explicitly set.
### Fixed
- **Smart shopping: falso positivo "quasi finito"** — Se un prodotto in grammi/ml era quasi esaurito (es. Burro 30g = 12%) ma lo stesso prodotto era disponibile anche come confezione (Burro 1 conf = 99%), il sistema segnalava ugualmente "sta finendo". Ora verifica se la famiglia `shopping_name` ha scorte da altri prodotti: se sì, l'alert viene soppresso. (Esempio: 30g di Burro + 1 conf di Burro → nessun alert.)
- **Traduzioni JSON corrotte** — La sezione `action` era duplicata nei file `de.json`, `en.json` e `it.json`, causando errori di parsing che bloccavano la CI/CD. Rimossa la sezione spuria.
- **Smart shopping: false "running low" alert** — If a product in grams/ml was nearly exhausted (e.g. Butter 30 g = 12%) but the same product was also available as a sealed package (Butter 1 pack = 99%), the system still flagged "running low". Now checks whether the `shopping_name` family has stock from other products; if so, the alert is suppressed.
- **Corrupted translation JSON** — The `action` section was duplicated in `de.json`, `en.json`, and `it.json`, causing JSON parse errors that blocked CI/CD. The spurious duplicate section has been removed.
## [1.7.7] - 2026-05-10
### Fixed
- **Smart shopping family suppression** — La logica `recentlyExhausted` (prodotti terminati < 14gg) bypassava erroneamente anche la suppression per `shopping_name` family, causando falsi positivi: prodotti come Yaourt Vanille apparivano come urgenti anche con 2kg di Yogurt in stock, Salame Paesano con 1kg di Affettato in stock, Gran bauletto rustico con più pani in stock. Ora `recentlyExhausted` bypassa solo il check token-based (match lasco), mentre la family suppression per `shopping_name` si applica sempre.
- **Shelf life pre-warming nel cron** — Il cron ora chiama `prewarmShelfLifeCache()` ogni 5 minuti, precaricando via Gemini AI la shelf life degli item aperti in inventario (max 5 item per ciclo) prima che l'utente li visualizzi. Questo elimina il delay percepibile al primo click su "Aperto il...".
- **Smart shopping family suppression** — The `recentlyExhausted` logic (products finished < 14 days ago) was incorrectly bypassing the `shopping_name` family suppression, causing false positives: products like Vanilla Yogurt appeared urgent even with 2 kg of Yogurt in stock. `recentlyExhausted` now only bypasses the token-based loose match; family suppression by `shopping_name` always applies.
- **Shelf-life pre-warming in cron** — The cron now calls `prewarmShelfLifeCache()` every 5 minutes, pre-loading via Gemini AI the shelf life of opened inventory items (max 5 items per cycle) before the user views them. This eliminates the noticeable delay on first click of "Opened on…".
## [1.7.6] - 2026-05-10
### Fixed
- **`shopping_name` troncato (Piadina)** — Il prodotto "Piadine medie" aveva `shopping_name='Pi'` (troncato), non veniva aggruppato correttamente nella famiglia. Corretto in `Piadina`.
- **Family merges DB** — Grana Padano ora sotto `Formaggio` (era `Grana` singleton), Prosciutto cotto ora sotto `Affettato`, Panna acida ora sotto `Panna`.
- **`daily_rate` su periodo effettivo** — Il tasso di consumo giornaliero usava `first_in → now` come finestra, diluendo il rate con periodi in cui il prodotto era già esaurito (es. aglio esaurito a 34gg veniva calcolato su 60+). Ora usa `first_in → last_activity` (ultimo acquisto o ultimo uso), più preciso per le previsioni di riordino.
- **Anomaly dismiss key stabile** — La chiave di dismiss usava `product_id + round(expected)` che cambiava ad ogni nuova transazione, causando la ricomparsa delle anomalie già chiuse. Ora usa `product_id + direction` (phantom/missing/untracked) — stabile finché la direzione non cambia.
- **Smart shopping: prodotti esauriti < 14 giorni** — Prodotti terminati negli ultimi 14 giorni non vengono più soppressi dal check token-coverage o shopping_name-family: se li hai appena finiti, è probabile tu voglia ricomprarli indipendentemente dalla presenza di equivalenti in stock.
- **Chat pruning** — `chatSave()` ora esegue `DELETE` dei messaggi oltre i 200 più recenti dopo ogni salvataggio, evitando crescita illimitata della tabella `chat_messages`.
- **`getStats()` query consolidate** — Le 5 query separate (COUNT products, SUM inventory, COUNT locations, COUNT recent_in, COUNT recent_out) sono ora una sola query con subselect, riducendo i round-trip SQLite da 5 a 1.
- **Bring! cleanup rate-limiting** — Aggiunto `usleep(300ms)` tra le rimozioni multiple per evitare di sovraccaricare l'API Bring! in burst.
- **Indici compositi su `transactions`** — Aggiunti `idx_transactions_type_date(type, created_at)` (per `getStats`) e `idx_transactions_pid_type_undone(product_id, type, undone)` (per `smartShopping`), con migration automatica per DB esistenti.
- **`shopping_name` truncated (Piadina)** — The product "Piadine medie" had `shopping_name='Pi'` (truncated), preventing it from grouping correctly in its family. Fixed to `Piadina`.
- **Family merges in DB** — Grana Padano now under `Formaggio` (was a `Grana` singleton), Prosciutto cotto now under `Affettato`, Panna acida now under `Panna`.
- **`daily_rate` over the actual active period** — The daily consumption rate was using `first_in → now` as the window, diluting the rate with periods when the product was already exhausted (e.g. garlic exhausted at day 34 was calculated over 60+ days). Now uses `first_in → last_activity` (last purchase or last use), giving more accurate reorder predictions.
- **Stable anomaly dismiss key** — The dismiss key was using `product_id + round(expected)`, which changed with every new transaction, causing already-dismissed anomalies to reappear. Now uses `product_id + direction` (phantom/missing/untracked) — stable as long as the direction does not change.
- **Smart shopping: products exhausted < 14 days ago** — Products finished within the last 14 days are no longer suppressed by the token-coverage check or the shopping_name family check: if you just ran out, you probably want to restock regardless of equivalent stock on hand.
- **Chat pruning** — `chatSave()` now deletes messages beyond the 200 most recent after each save, preventing unbounded growth of the `chat_messages` table.
### Security
- **CSRF protection** — Le action di scrittura (inventory_add, bring_add, product_save, ecc.) richiedono ora `X-EverShelf-Request: 1` oppure `Content-Type: application/json`. Il frontend `api()` invia sempre il header su POST. Questo previene attacchi CSRF cross-site tramite form HTML.
## [1.7.5] - 2026-05-10
+41
View File
@@ -0,0 +1,41 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes
* Focusing on what is best not just for us as individuals, but for the overall community
Examples of unacceptable behavior:
* The use of sexualized language or imagery, and sexual attention or advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting
## Enforcement Responsibilities
Project maintainers are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior they deem inappropriate, threatening, offensive, or harmful.
## Scope
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the project maintainer at **evershelfproject@gmail.com**. All complaints will be reviewed and investigated promptly and fairly.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.1.
+9 -78
View File
@@ -25,54 +25,15 @@
[![SQLite](https://img.shields.io/badge/SQLite-3-blue.svg)](https://www.sqlite.org/)
[![Docker](https://img.shields.io/badge/Docker-Ready-2496ED.svg)](Dockerfile)
[![i18n](https://img.shields.io/badge/i18n-IT%20%7C%20EN%20%7C%20DE-orange.svg)](translations/)
[![Version](https://img.shields.io/badge/version-1.7.12-brightgreen.svg)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.7.13-brightgreen.svg)](CHANGELOG.md)
[![GitHub stars](https://img.shields.io/github/stars/dadaloop82/EverShelf?style=social)](https://github.com/dadaloop82/EverShelf/stargazers)
[![Last commit](https://img.shields.io/github/last-commit/dadaloop82/EverShelf/main)](https://github.com/dadaloop82/EverShelf/commits/main)
[![Contributors](https://img.shields.io/github/contributors/dadaloop82/EverShelf)](https://github.com/dadaloop82/EverShelf/graphs/contributors)
[![GitHub Discussions](https://img.shields.io/github/discussions/dadaloop82/EverShelf)](https://github.com/dadaloop82/EverShelf/discussions)
[![CI](https://github.com/dadaloop82/EverShelf/actions/workflows/ci.yml/badge.svg)](https://github.com/dadaloop82/EverShelf/actions/workflows/ci.yml)
---
## 🌍 Recent Updates (v1.7.12)
- **Banner aperto con indicazione posizione** — Nella sezione "Usa prima" il testo ora mostra "Quella nel frigo, aperta da X giorni" invece di una data di scadenza calcolata che poteva risultare confusa.
- **Ricette: quantità in pezzi per prodotti pz** — Il prompt AI e la post-elaborazione PHP ora istruiscono Gemini a esprimere `qty_number` come pezzi interi (non grammi) per i prodotti con unità `pz` (es. Pan bauletto, fette biscottate). Il fallback PHP non divide più per 100 quando `default_quantity = 0`.
- **Fix: "Usa TUTTO" nelle ricette non elimina più la riga** — Il pulsante "Usa TUTTO / Finito" nella modal di utilizzo ricette inviava `use_all: true` che causava un `DELETE` immediato senza conferma. Ora calcola la quantità esatta dagli item disponibili e fa un normale `inventory_use`.
- **Scan page redesign** — La pagina di scansione è stata completamente ridisegnata: **2× zoom fisso** (hardware o CSS), **torcia** con feedback visivo, **flip fotocamera** (front/back), **3 tab input** (Barcode / Nome / AI), **prodotti recenti** (ultimi 6 in localStorage), **live code overlay** durante la scansione parziale, **confirm overlay** al successo, **angoli guida** nel viewport.
- **AI Number OCR** — Dopo 4 secondi senza scansione compare il bottone "Leggi numeri con AI": Gemini analizza il frame video e restituisce le cifre del barcode anche quando lo scanner ottico non riesce a leggerlo.
- **Fix falsi positivi anomalie** — Rimossa la direzione `untracked` dal rilevatore di anomalie; le predizioni di consumo richiedono ora min 5 transazioni e 7 giorni di storico.
- **Fix menu suggerimenti scan** — Rimosso il datalist dal campo Nome nella pagina scansione (non più aperto su tablet).
- **Fix falsi positivi anomalie consumo** — `getConsumptionPredictions` richiedeva solo 3 transazioni, potendo generare rate esplose su dati ravvicinati. Ora: min 5 txn, min 7gg span, skip se consumo predetto < 15% baseline.
- **Banner "Imposta scadenza" ora funziona** — Il pulsante sul banner "nessuna scadenza" apriva una funzione inesistente. Corretto, ora apre correttamente la modal di modifica.
- **Banner aperto vs scaduto** — I prodotti con `opened_at` mostrano "Aperto da N giorni in [posizione]" invece di "Scaduto!", con la posizione (frigo/dispensa/freezer) esplicitamente indicata.
- **Shelf life latte UHT** — Il latte generico è ora trattato come UHT (7 giorni dopo apertura) invece che fresco (4 giorni).
- **Niente più false anomalie di consumo** — Il rilevatore ora ignora i casi in cui `expected = 0` (prodotto probabilmente ricomprato) e alza la soglia "more than expected" al 400%. Le notifiche rimangono solo per consumi significativamente inferiori al previsto.
- **Scaduti nascondono prodotti già buttati** — La sezione scaduti ora filtra correttamente i prodotti con `quantity = 0`.
- **Docker: fix permessi DB al primo avvio** — `_ensureDataDir()` crea la directory `data/` se mancante e tenta `chmod(0775)` se non scrivibile, risolvendo `SQLSTATE[HY000][14]` su volumi Docker freschi.
- **AI price estimation for shopping list** — Each Bring! shopping item now shows an estimated retail price badge (unit price + total). Prices are fetched via Gemini AI, cached server-side for 3 months, and stored client-side in `sessionStorage` to survive navigation. The dashboard shopping stat card shows a live green `ca. €X.XX` badge that updates in real-time as prices are calculated — even in background when you're on another tab.
- **Kiosk v1.7.0: OTA update system** — "Cerca aggiornamenti" button in Settings triggers a forced GitHub release check; new `installUpdate()` JS bridge calls Android `DownloadManager` directly (lockTask mode blocks external browser links); graceful degradation for older APKs with manual instructions. Automatic OTA check every 6 hours with native update banner.
- **Kiosk: consistent APK signing** — Project keystore (`evershelf.jks`) committed to the repo; every build — local or CI — now produces an APK with the same signature, eliminating "APK incompatible / signature conflict" errors on OTA update.
- **GitHub Actions: auto-publish kiosk APK** — On every push to `main` that touches `evershelf-kiosk/`, Actions builds the APK and publishes a versioned semver release (`kiosk-X.Y.Z`) plus updates the `kiosk-latest` alias. No more manual release uploads.
- **Fix: false "update available" on launch** — `checkForUpdates` now requires a strictly-greater semver tag to flag an update. Non-semver tags (e.g. `kiosk-latest`) no longer trigger a false positive immediately after a fresh install.
- **Kiosk: live scale diagnostic panel** — When connected, Settings shows device name, battery %, real-time weight, protocol and reconnection status without leaving the settings page.
- **Kiosk: scale dot visible on header** — Connected-state dot changed from green-on-green to white fill + green glow, clearly visible on any background.
- **Kiosk: reconfigure BLE scale** — New "Riconfigura bilancia BLE" button in Settings; shows amber notice with download link if the installed APK predates the `reconfigureScale()` bridge method.
- **Nutrition analysis dashboard** — Category distribution pie chart (3D conic-gradient), health/variety/freshness score bars, alternates with the anti-waste section hourly.
- **Screensaver nutrition panel** — Animated 3D pie + donut ring scores rotate with fact cards every 5 minutes in the screensaver overlay.
- **Automatic error reporting** — Unhandled JS errors, Android crashes and PHP exceptions are silently posted to `api/?action=report_error`; the server deduplicates by fingerprint and creates or comments on a GitHub Issue automatically. Crash details are persisted to `SharedPreferences` so even errors that prevent network I/O are sent on the next launch.
- **Demo mode (JS)** — Full frontend demo with mock pantry data, Gemini enabled, Bring! writes silently no-op'd; accessible via `?demo=1` or `.env` `DEMO_MODE=true`.
- **Graceful Bring! no-key state** — When Bring! credentials are not configured, the shopping tab shows a friendly message with a direct link to Settings instead of a raw error.
- **Use-quantity guard** — Consuming more than the stocked quantity at a given location is now blocked client-side with a shake animation on the quantity field.
- **Kiosk v1.6.0: BLE scale gateway integrated** — The standalone Scale Gateway app is no longer needed. BLE scanning, GATT connection and the WebSocket server (`:8765`) now run as a built-in `GatewayService` foreground service inside the kiosk app. Setup step 4 shows a live BLE device scan — users select their scale directly, no external APK install required. The external gateway app is deprecated.
- **Kiosk setup wizard overhaul** — Auto-discovery rewritten with `ExecutorCompletionService` + `NetworkInterface` (no deprecated `WifiManager`), 60 parallel TCP pre-checks, real-time UI feedback, ports 80/443/8080/8443, correct LAN subnet detection (VPN/cellular interfaces filtered, `wlan`/`eth` prioritised).
- **Kiosk permissions flow** — Grant button transforms into a green "✅ Permessi concessi — Continua →" button after permissions are granted instead of just showing a card.
- **3 new AI features (Gemini)** — Storage/shelf-life hint shown inline in the add form; AI-enriched shopping suggestions with a short practical tip per item; plain-language anomaly explanation via a "🤖 Spiega" button on anomaly banners.
- **Security hardening** — `get_settings` no longer exposes API keys in plain text (boolean flags only); `save_settings` protected by optional `SETTINGS_TOKEN` (validated with `hash_equals`); native `DEMO_MODE` in `.env` blocks all write operations at the PHP router level before any other guard.
- **Real-time webapp update detection** — An inline header pill appears when a newer release is on GitHub (checked on load + every 30 min); no intrusive full-page banners.
- **Gemini availability flag** — All AI entry points check `_geminiAvailable` before firing; the header button shows a visual no-AI state (greyed + amber dot) when no key is configured.
- **Dashboard skeleton loading** — Stat cards show an animated shimmer while data loads instead of a jarring `0` flash for 35 seconds.
- **APK self-update with conflict recovery** — Both Kiosk and Scale Gateway use the `PackageInstaller` session API for OTA installs; a signature conflict now shows a dialog to uninstall the old version instead of a cryptic failure.
- **Smarter low-quantity alerts** — The "suspiciously low quantity" banner is no longer raised for a partially-used entry when the same product has stock in another location.
- **Non-alarmist expired banner** — Adapts icon, colour, and title to the actual safety level: green ✅ for long-life products still safe, amber 👀 for items to check, red 🚫 only for genuinely dangerous items.
## ✨ Features
### 📦 Inventory Management
@@ -389,32 +350,7 @@ The application uses no build tools — edit files directly and refresh.
## 📋 Roadmap
- [x] Multi-language support (i18n) — 3 languages (it/en/de), 347 keys
- [ ] User authentication / multi-user support
- [x] Docker container for easy deployment — see [Dockerfile](Dockerfile) + [docker-compose.yml](docker-compose.yml)
- [x] REST API documentation (OpenAPI/Swagger) — see [docs/openapi.yaml](docs/openapi.yaml)
- [x] First-run setup wizard — 4-step guided configuration
- [x] API rate limiting — file-based, 3 tiers (120/15/5 req/min)
- [x] CI/CD pipeline — GitHub Actions (lint, Docker build, translation validation)
- [x] Android kiosk mode — dedicated tablet app with screen pinning
- [x] Anomaly detection banner — suspicious quantities + consumption predictions
- [x] AI scan local matching — suggest existing pantry products before OFF lookup
- [x] Scale auto-fill improvements — 10g threshold, ml conversion hints
- [x] Update notification system — inline header pill (webapp) + kiosk checks GitHub releases
- [x] Kiosk OTA update — forced check button, `installUpdate()` bridge, graceful old-APK fallback
- [x] Kiosk consistent APK signing — project keystore eliminates signature conflicts on OTA
- [x] GitHub Actions kiosk CI — auto-builds and publishes versioned semver APK on every push to main
- [x] Kiosk live scale diagnostics — device, battery, real-time weight in Settings when connected
- [x] Nutrition analysis dashboard — category pie + health/variety/freshness scores, alternates with waste section
- [x] Screensaver nutrition panel — animated pie + donut ring scores rotate with facts
- [x] Automatic error reporting — JS/Android/PHP errors → GitHub Issues with deduplication
- [x] Generic shopping name grouping — compound-phrase + keyword map (100+ entries) + Gemini AI fallback
- [x] Auto-add to Bring! on product depletion — no confirmation step when stock reaches zero
- [x] Native Android TTS in kiosk — bypasses Web Speech API voice detection issues
- [x] AI product storage hint — background Gemini call suggests location + shelf-life in the add form
- [x] AI shopping tips enrichment — each suggestion enriched with a short practical tip
- [x] AI anomaly explanation — "🤖 Spiega" button explains discrepancies in plain language
- [x] Security hardening — no raw key exposure, SETTINGS_TOKEN auth, DEMO_MODE native blocking
- [ ] Offline mode with service worker
- [ ] Export/import inventory data
- [ ] Notification system (Telegram, email) for expiring products
@@ -464,11 +400,6 @@ This project is licensed under the **MIT License** — see the [LICENSE](LICENSE
## 📸 Screenshots
| | | |
|:---:|:---:|:---:|
| ![Dashboard](assets/img/screenshots/01_dashboard.jpg) | ![Inventory](assets/img/screenshots/02_inventory.jpg) | ![Barcode Scanner](assets/img/screenshots/03_barcode_scanner.jpg) |
| **Dashboard** — Inventory overview with counters by location (pantry, fridge, freezer), upcoming expiry alerts, and consumed vs. wasted tracking over the last 30 days. | **Inventory** — Full product list filterable by location (All / Pantry / Fridge / Freezer) and searchable by name, with category, quantity, and expiry date. | **Barcode Scanner** — Scan barcodes with the camera (QuaggaJS) or enter manually. Shopping mode lets you register purchased products in quick sequence. |
| ![AI Recipe Detail](assets/img/screenshots/04_recipe_detail.jpg) | ![Recipes](assets/img/screenshots/05_recipes.jpg) | ![Cooking Mode](assets/img/screenshots/06_cooking_mode.jpg) |
| **AI Recipe Detail** — Recipe generated by Gemini AI using expiring ingredients: each ingredient is matched to the real inventory with quantity and location, ready to scale. | **Recipes** — History of AI-generated recipes, organized by day and meal (lunch / dinner / other), with preparation and cooking time. | **Cooking Mode** — Fullscreen step-by-step guide with Text-to-Speech. Each step shows the ingredient to use from your pantry with an integrated "Use" button. |
| ![AI Chat](assets/img/screenshots/07_ai_chat.jpg) | ![Shopping List](assets/img/screenshots/08_shopping_list.jpg) | ![Smart Predictions](assets/img/screenshots/09_smart_predictions.jpg) |
| **Gemini Chat** — AI assistant that knows your pantry, your appliances, and your preferences. Suggests snacks, smoothies, or quick meals with a single tap. | **Shopping List** — List synced with Bring!, organized by product category, with urgency indicators and links to search for prices online. | **Smart Predictions** — AI analysis of historical consumption: shows what is running low, how much time is left, and why restocking is recommended (regular use, nearly empty, opened). |
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 a GIF or screenshots? See [CONTRIBUTING.md](CONTRIBUTING.md) — PRs welcome!
+48
View File
@@ -0,0 +1,48 @@
# Security Policy
## Supported Versions
Only the latest released version of EverShelf receives security fixes.
| Version | Supported |
|---------|-----------|
| Latest (1.7.x) | ✅ |
| Older releases | ❌ |
## Reporting a Vulnerability
**Please do NOT open a public GitHub issue for security vulnerabilities.**
Report security issues privately via email:
**📧 evershelfproject@gmail.com**
Include:
- A description of the vulnerability
- Steps to reproduce
- Potential impact
- Your GitHub username (optional — for credit)
I aim to acknowledge reports within **48 hours** and release a fix within **7 days** for critical issues.
## Scope
EverShelf is a **self-hosted** application. The security model assumes:
- It runs on a trusted private network (home LAN)
- Access from the internet requires the user to set up their own authentication layer (e.g. reverse proxy with Authelia, Nginx `auth_basic`)
Out-of-scope issues:
- Vulnerabilities that require physical access to the server
- Issues only affecting users who have not followed the security recommendations in the README
- Denial-of-service attacks on the demo server
## Security Features
- API keys stored server-side in `.env`, never sent to the browser
- `get_settings` returns only boolean flags (`gemini_key_set`), never raw key values
- Optional `SETTINGS_TOKEN` protects write operations (`hash_equals` to prevent timing attacks)
- `DEMO_MODE=true` blocks all write operations at the router level
- Parameterized SQL queries (PDO prepared statements) throughout
- Input validation and length limits on all user-supplied fields
- `.env` and `data/` directories denied via web server config (see README)
+5 -2
View File
@@ -95,6 +95,7 @@ function initializeDB(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
);
@@ -117,10 +118,12 @@ function migrateDB(PDO $db): void {
$cols = $db->query("PRAGMA table_info(products)")->fetchAll();
$colNames = array_column($cols, 'name');
if (!in_array('package_unit', $colNames)) {
$db->exec("ALTER TABLE products ADD COLUMN package_unit TEXT DEFAULT ''");
try { $db->exec("ALTER TABLE products ADD COLUMN package_unit TEXT DEFAULT ''"); }
catch (PDOException $e) { if (strpos($e->getMessage(), 'duplicate column') === false) throw $e; }
}
if (!in_array('shopping_name', $colNames)) {
$db->exec("ALTER TABLE products ADD COLUMN shopping_name TEXT DEFAULT ''");
try { $db->exec("ALTER TABLE products ADD COLUMN shopping_name TEXT DEFAULT ''"); }
catch (PDOException $e) { if (strpos($e->getMessage(), 'duplicate column') === false) throw $e; }
}
// Migrate transactions CHECK constraint to allow 'waste' type
+29 -8
View File
@@ -2132,9 +2132,9 @@ function getConsumptionPredictions(PDO $db): void {
$daySpan = ($lastDate - $firstDate) / 86400;
// If all transactions are clustered within a week, the rate is unreliable
if ($daySpan < 7) continue;
$dailyRate = $totalUsed / $daySpan;
$historicalRate = $totalUsed / $daySpan;
if ($dailyRate < 0.01) continue; // negligible consumption
if ($historicalRate < 0.01) continue; // negligible consumption
// Get the most recent restock (last 'in' transaction)
$lastIn = $db->prepare("
@@ -2166,16 +2166,35 @@ function getConsumptionPredictions(PDO $db): void {
$baselineQty = floatval($item['quantity']) + $usedSinceRestock;
$daysSinceRestock = max(1, (time() - $restockDate) / 86400);
// Recalculate the expected consumption with an adaptive rate:
// blend long-term history with post-restock behavior when available.
$txSinceRestock = 0;
foreach ($rows as $r) {
if (strtotime($r['created_at']) >= $restockDate) $txSinceRestock++;
}
$observedRate = $daysSinceRestock > 0 ? ($usedSinceRestock / $daysSinceRestock) : 0;
$dailyRate = $historicalRate;
if ($observedRate > 0) {
if ($txSinceRestock >= 3) {
$dailyRate = ($historicalRate * 0.45) + ($observedRate * 0.55);
} elseif ($txSinceRestock >= 1) {
$dailyRate = ($historicalRate * 0.70) + ($observedRate * 0.30);
}
}
// If the model predicts you should have consumed less than 15% of baseline
// in this period, the daily rate is too low to make reliable predictions:
// any single normal use will look like an anomaly. Skip it.
$predictedConsumption = $dailyRate * $daysSinceRestock;
if ($baselineQty > 0 && $predictedConsumption < $baselineQty * 0.15) continue;
// Predicted remaining qty = baseline - (daily rate * days since restock)
// Predicted remaining qty = baseline - (adaptive daily rate * days since restock)
$expectedQty = max(0, $baselineQty - ($dailyRate * $daysSinceRestock));
$actualQty = floatval($item['quantity']);
// Need at least some post-restock usage observations before warning.
if ($txSinceRestock < 2) continue;
// Flag if deviation > 30% and absolute diff > meaningful threshold
$deviation = abs($actualQty - $expectedQty);
$threshold = max($dailyRate * 3, 0.5); // at least 3 days worth or 0.5 units
@@ -2188,10 +2207,12 @@ function getConsumptionPredictions(PDO $db): void {
$pctDev = $expectedQty > 0 ? ($deviation / $expectedQty) : ($actualQty > 0 ? 1 : 0);
// "more than expected" is almost always a restock the model doesn't know about yet.
// Only flag it at very high deviation (>400%) to catch truly impossible values.
// "less than expected" is more actionable: user may have consumed without registering.
$flagThreshold = ($actualQty > $expectedQty) ? 4.0 : 0.30;
// "More than expected" usually means slower real consumption, not bad data.
// Suppress this direction to avoid noisy/accusatory banners.
if ($actualQty > $expectedQty) continue;
// Only keep meaningful "less than expected" deviations.
$flagThreshold = 0.45;
if ($pctDev > $flagThreshold && $deviation > $threshold) {
$unit = $item['unit'];
@@ -2220,7 +2241,7 @@ function getConsumptionPredictions(PDO $db): void {
'daily_rate' => round($dailyRate, 3),
'deviation_pct' => round($pctDev * 100),
'days_since_restock' => (int)round($daysSinceRestock),
'direction' => $actualQty > $expectedQty ? 'more' : 'less',
'direction' => 'less',
'tx_count' => count($rows),
];
}
+465 -41
View File
@@ -303,14 +303,15 @@ function _scaleOnMessage(msg) {
const rawValue = parseFloat(msg.value);
if (rawValue < 0) return;
// Ignore sub-gram jitter for stability decisions: only integer-gram changes matter.
// Ignore sub-2g jitter for stability decisions: changes below 2g are considered noise.
const SCALE_NOISE_G = 2;
let effectiveStable = !!msg.stable;
const grams = _scaleToGrams(rawValue, msg.unit);
if (grams !== null) {
if (effectiveStable) {
_scaleLastStableGrams = grams;
} else if (_scaleLastStableGrams !== null) {
if (Math.round(grams) === Math.round(_scaleLastStableGrams)) {
if (Math.abs(grams - _scaleLastStableGrams) < SCALE_NOISE_G) {
effectiveStable = true;
}
}
@@ -402,7 +403,7 @@ function _scaleUpdateLiveBox(msg) {
const raw = parseFloat(msg.value);
const rawUnit = (msg.unit || 'kg').toLowerCase();
// Convert to grams for the < 10 g threshold check
// Convert to grams for the < 2 g threshold check
let gForCheck = isFinite(raw) ? raw : 0;
if (rawUnit === 'kg') gForCheck = raw * 1000;
if (rawUnit === 'lbs' || rawUnit === 'lb') gForCheck = raw * 453.592;
@@ -410,7 +411,7 @@ function _scaleUpdateLiveBox(msg) {
const valEl = document.getElementById('scale-live-val');
const lblEl = document.getElementById('scale-live-label');
if (isFinite(raw) && gForCheck < 10 && gForCheck > 0) {
if (isFinite(raw) && gForCheck < 2 && gForCheck > 0) {
// Weight too low — show red flashing warning
box.classList.add('scale-low-weight');
if (valEl) valEl.textContent = `${raw} ${msg.unit || 'kg'}`;
@@ -509,13 +510,6 @@ function _scaleAutoFillUse(msg) {
// Reject if converted value < 10 (density edge case)
if (val < 10) {
_cancelScaleStabilityWait();
return;
}
if (val !== _scaleStabilityVal) {
// New (different) weight → clear dismissal, restart stability wait
_scaleStabilityVal = val;
_scaleUserDismissed = false;
_cancelScaleTimersOnly();
_startScaleStabilityWait(() => {
@@ -2085,6 +2079,75 @@ function _applySyncedSettings(serverSettings) {
}
}
/**
* Populate the About section with the current app version from the server.
*/
async function _loadAboutSection() {
const el = document.getElementById('about-version-label');
if (!el) return;
try {
const res = await api('check_update');
const manifest = await fetch('manifest.json?_=' + Date.now()).then(r => r.json()).catch(() => ({}));
const local = manifest.version || '—';
const latest = res.latest_tag ? res.latest_tag.replace(/^v/, '') : null;
el.textContent = 'v' + local + (latest && latest !== local ? ' → v' + latest + ' available' : '');
} catch(e) {
el.textContent = '—';
}
}
/**
* Manually triggered bug report from the About section in Settings.
* Collects basic info and submits via the existing report_error endpoint.
*/
async function reportBugManual() {
const btn = document.getElementById('btn-report-bug');
const statusEl = document.getElementById('report-bug-status');
if (!btn || !statusEl) return;
btn.disabled = true;
statusEl.style.display = '';
statusEl.style.color = '#64748b';
statusEl.textContent = t('about.report_bug_sending');
const manifest = await fetch('manifest.json?_=' + Date.now()).then(r => r.json()).catch(() => ({}));
try {
const res = await fetch(API_BASE + '?action=report_error', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source: 'pwa',
type: 'manual_report',
message: 'Manual bug report submitted from Settings → About',
stack: '',
url: location.href,
user_agent: navigator.userAgent,
version: manifest.version || '',
context: {
lang: _currentLang,
online: navigator.onLine,
version_guard_bypass: true,
}
})
});
const json = await res.json();
if (json.ok) {
statusEl.style.color = '#15803d';
statusEl.textContent = t('about.report_bug_sent');
// Open GitHub issues so user can add details
setTimeout(() => window.open('https://github.com/dadaloop82/EverShelf/issues', '_blank', 'noopener'), 800);
} else {
throw new Error(json.error || 'error');
}
} catch(e) {
statusEl.style.color = '#dc2626';
statusEl.textContent = t('about.report_bug_error');
} finally {
btn.disabled = false;
}
}
async function loadSettingsUI() {
const s = getSettings();
document.getElementById('setting-gemini-key').value = s.gemini_key || '';
@@ -2275,6 +2338,9 @@ async function loadSettingsUI() {
const updatePanel = document.getElementById('kiosk-update-panel');
if (updatePanel) updatePanel.style.display = '';
}
// Populate About section version
_loadAboutSection();
}
// ── Kiosk: trigger native BLE scale reconfiguration wizard ────────────
@@ -4820,6 +4886,9 @@ function showItemDetail(inventoryId, productId) {
function closeModal() {
document.getElementById('modal-overlay').style.display = 'none';
clearMoveModalTimer();
// Restore native kiosk settings button visibility
try { if (typeof _kioskBridge !== 'undefined') _kioskBridge.setNativeSettingsVisible(true); } catch (_) {}
_cancelScaleAutoConfirm(false);
_scaleRecipeAutoFillPaused = false;
_scaleUserDismissed = false;
@@ -8048,14 +8117,22 @@ function closeLowStockPrompt() {
let _moveModalTimer = null;
let _moveModalRAF = null;
let _moveModalTouchHandler = null;
function clearMoveModalTimer() {
if (_moveModalTimer) { clearTimeout(_moveModalTimer); _moveModalTimer = null; }
if (_moveModalRAF) { cancelAnimationFrame(_moveModalRAF); _moveModalRAF = null; }
if (_moveModalTouchHandler) {
document.getElementById('modal-content')?.removeEventListener('pointerdown', _moveModalTouchHandler, true);
_moveModalTouchHandler = null;
}
}
function startMoveModalCountdown(btnId, onExpire) {
clearMoveModalTimer();
// Any touch inside the modal cancels the auto-close countdown
_moveModalTouchHandler = () => clearMoveModalTimer();
document.getElementById('modal-content')?.addEventListener('pointerdown', _moveModalTouchHandler, { capture: true, once: true });
const duration = 15000;
const start = performance.now();
const btn = document.getElementById(btnId);
@@ -8102,6 +8179,8 @@ function showMoveAfterUseModal(product, fromLoc, remaining, openedId, openedVacu
</div>
`;
document.getElementById('modal-overlay').style.display = 'flex';
// Hide the native kiosk settings button while the modal is open (prevents touch bleed-through)
try { if (typeof _kioskBridge !== 'undefined') _kioskBridge.setNativeSettingsVisible(false); } catch (_) {}
startMoveModalCountdown('btn-move-stay', () => { _saveVacuumAndStay(openedId || 0); });
}
@@ -11729,6 +11808,8 @@ function showRecipeMoveModal(productId, fromLoc, remaining, openedId, wasVacuum)
</div>
`;
document.getElementById('modal-overlay').style.display = 'flex';
// Hide the native kiosk settings button while the modal is open (prevents touch bleed-through)
try { if (typeof _kioskBridge !== 'undefined') _kioskBridge.setNativeSettingsVisible(false); } catch (_) {}
startMoveModalCountdown('btn-move-stay', () => { closeModal(); });
}
@@ -11822,6 +11903,9 @@ function renderRecipe(r) {
});
html += '</ul>';
// Cooking mode action between ingredients and steps
html += `<button class="btn btn-large btn-cooking full-width mt-2" onclick="startCookingMode()">${t('recipes.start_cooking')}</button>`;
// Steps
html += `<h3>${t('recipes.steps_title')}</h3><ol>`;
(r.steps || []).forEach(step => {
@@ -11843,6 +11927,62 @@ let _cookingRecipe = null;
let _cookingStep = 0;
let _cookingTTS = true;
let _cookingVisited = new Set(); // indices of steps already seen
let _cookingWheelBound = false;
let _cookingWheelTouchStartY = null;
let _cookingWheelLastNavTs = 0;
let _cookingWheelLastDelta = 0;
let _cookingWheelTiltResetTimer = null;
function _layoutCookingWheelCards() {
const wheelEl = document.getElementById('cooking-wheel');
const centerEl = document.getElementById('cooking-step-text');
const prevEl = document.getElementById('cooking-step-prev');
const nextEl = document.getElementById('cooking-step-next');
if (!wheelEl || !centerEl || !prevEl || !nextEl) return;
const wheelH = wheelEl.clientHeight;
if (!wheelH) return;
const centerH = centerEl.offsetHeight;
const centerTop = Math.max(0, (wheelH - centerH) / 2);
const centerBottom = centerTop + centerH;
const pad = 8;
const gap = Math.max(10, Math.round(wheelH * 0.045));
const placeGhost = (el, isPrev) => {
el.style.bottom = 'auto';
if (el.classList.contains('is-empty')) {
el.style.maxHeight = '0px';
return;
}
// Measure natural height before clamping to available slot.
el.style.maxHeight = 'none';
const naturalH = Math.min(el.scrollHeight + 10, Math.round(wheelH * 0.42));
const available = isPrev
? (centerTop - gap - pad)
: (wheelH - centerBottom - gap - pad);
if (available <= 20) {
el.style.maxHeight = '0px';
el.style.opacity = '0';
return;
}
const ghostH = Math.max(28, Math.min(naturalH, available));
el.style.maxHeight = `${Math.round(ghostH)}px`;
el.style.opacity = '';
const top = isPrev
? Math.max(pad, centerTop - gap - ghostH)
: Math.min(wheelH - pad - ghostH, centerBottom + gap);
el.style.top = `${Math.round(top)}px`;
};
placeGhost(prevEl, true);
placeGhost(nextEl, false);
}
function startCookingMode() {
const recipe = _cachedRecipe && _cachedRecipe.recipe ? _cachedRecipe.recipe : null;
@@ -11863,6 +12003,9 @@ function startCookingMode() {
document.getElementById('cooking-tts-btn').textContent = '🔊';
document.getElementById('cooking-overlay').style.display = 'flex';
document.body.classList.add('cooking-mode-active');
_bindCookingWheelControls();
const wheelEl = document.getElementById('cooking-wheel');
if (wheelEl) setTimeout(() => wheelEl.focus(), 20);
try { screen.orientation?.lock('portrait').catch(() => {}); } catch (_) { /* ignore */ }
renderCookingStep();
if (_cookingTTS) {
@@ -11880,11 +12023,135 @@ function closeCookingMode() {
function restartCookingMode() {
_cookingStep = 0;
_cookingWheelLastDelta = 0;
_cookingVisited = new Set();
clearAllCookingTimers();
renderCookingStep();
}
function _setCookingWheelTilt(clientX, clientY) {
const wheelEl = document.getElementById('cooking-wheel');
if (!wheelEl) return;
const rect = wheelEl.getBoundingClientRect();
if (!rect.width || !rect.height) return;
const nx = ((clientX - rect.left) / rect.width) - 0.5;
const ny = ((clientY - rect.top) / rect.height) - 0.5;
const tiltY = Math.max(-1, Math.min(1, nx)) * 7;
const tiltX = Math.max(-1, Math.min(1, -ny)) * 4;
const glow = 0.32 + (Math.min(1, Math.abs(nx) + Math.abs(ny)) * 0.45);
wheelEl.style.setProperty('--wheel-tilt-x', `${tiltX.toFixed(2)}deg`);
wheelEl.style.setProperty('--wheel-tilt-y', `${tiltY.toFixed(2)}deg`);
wheelEl.style.setProperty('--wheel-glow', glow.toFixed(2));
}
function _resetCookingWheelTilt() {
const wheelEl = document.getElementById('cooking-wheel');
if (!wheelEl) return;
wheelEl.style.setProperty('--wheel-tilt-x', '0deg');
wheelEl.style.setProperty('--wheel-tilt-y', '0deg');
wheelEl.style.setProperty('--wheel-glow', '0.45');
}
function _pulseCookingWheel() {
const wheelEl = document.getElementById('cooking-wheel');
if (!wheelEl) return;
wheelEl.classList.remove('snap');
void wheelEl.offsetWidth;
wheelEl.classList.add('snap');
setTimeout(() => wheelEl.classList.remove('snap'), 320);
}
function _cookingStepFeedback() {
_pulseCookingWheel();
if (navigator.vibrate) {
try { navigator.vibrate([10, 16, 10]); } catch (_) { /* ignore */ }
}
}
function _bindCookingWheelControls() {
const wheelEl = document.getElementById('cooking-wheel');
if (!wheelEl || _cookingWheelBound) return;
wheelEl.addEventListener('wheel', (e) => {
if (!document.body.classList.contains('cooking-mode-active')) return;
if (Math.abs(e.deltaY) < 8) return;
e.preventDefault();
const now = Date.now();
if (now - _cookingWheelLastNavTs < 240) return;
_cookingWheelLastNavTs = now;
navigateCookingStep(e.deltaY > 0 ? 1 : -1);
}, { passive: false });
wheelEl.addEventListener('touchstart', (e) => {
const t = e.touches && e.touches[0] ? e.touches[0] : null;
_cookingWheelTouchStartY = t ? t.clientY : null;
if (t) _setCookingWheelTilt(t.clientX, t.clientY);
}, { passive: true });
wheelEl.addEventListener('touchmove', (e) => {
const t = e.touches && e.touches[0] ? e.touches[0] : null;
if (t) _setCookingWheelTilt(t.clientX, t.clientY);
}, { passive: true });
wheelEl.addEventListener('touchend', (e) => {
if (_cookingWheelTouchStartY === null) return;
const endY = e.changedTouches && e.changedTouches[0] ? e.changedTouches[0].clientY : _cookingWheelTouchStartY;
const delta = _cookingWheelTouchStartY - endY;
_cookingWheelTouchStartY = null;
if (Math.abs(delta) < 42) return;
const now = Date.now();
if (now - _cookingWheelLastNavTs < 240) return;
_cookingWheelLastNavTs = now;
navigateCookingStep(delta > 0 ? 1 : -1);
if (_cookingWheelTiltResetTimer) clearTimeout(_cookingWheelTiltResetTimer);
_cookingWheelTiltResetTimer = setTimeout(_resetCookingWheelTilt, 80);
}, { passive: true });
wheelEl.addEventListener('mousemove', (e) => {
if (!document.body.classList.contains('cooking-mode-active')) return;
_setCookingWheelTilt(e.clientX, e.clientY);
});
wheelEl.addEventListener('mouseleave', () => {
_resetCookingWheelTilt();
});
window.addEventListener('resize', () => {
if (!document.body.classList.contains('cooking-mode-active')) return;
_layoutCookingWheelCards();
});
wheelEl.addEventListener('keydown', (e) => {
if (!document.body.classList.contains('cooking-mode-active')) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
navigateCookingStep(1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
navigateCookingStep(-1);
}
});
_cookingWheelBound = true;
}
function _animateCookingWheelTransition() {
const wheelEl = document.getElementById('cooking-wheel');
if (!wheelEl) return;
wheelEl.classList.remove('turn-next', 'turn-prev');
if (_cookingWheelLastDelta === 0) return;
// Force style recalculation so repeated class toggles retrigger CSS animation.
void wheelEl.offsetWidth;
wheelEl.classList.add(_cookingWheelLastDelta > 0 ? 'turn-next' : 'turn-prev');
setTimeout(() => {
wheelEl.classList.remove('turn-next', 'turn-prev');
}, 380);
}
function renderCookingStep() {
if (!_cookingRecipe) return;
const steps = _cookingRecipe.steps || [];
@@ -11898,6 +12165,30 @@ function renderCookingStep() {
document.getElementById('cooking-step-num').textContent = `${_cookingStep + 1} / ${total}`;
document.getElementById('cooking-step-text').textContent = cleanStep;
const prevEl = document.getElementById('cooking-step-prev');
const nextEl = document.getElementById('cooking-step-next');
if (prevEl) {
if (_cookingStep > 0) {
prevEl.textContent = (steps[_cookingStep - 1] || '').replace(/^Passo\s*\d+\s*[:.]\s*/i, '');
prevEl.classList.remove('is-empty');
} else {
prevEl.textContent = '';
prevEl.classList.add('is-empty');
}
}
if (nextEl) {
if (_cookingStep < total - 1) {
nextEl.textContent = (steps[_cookingStep + 1] || '').replace(/^Passo\s*\d+\s*[:.]\s*/i, '');
nextEl.classList.remove('is-empty');
} else {
nextEl.textContent = '';
nextEl.classList.add('is-empty');
}
}
requestAnimationFrame(_layoutCookingWheelCards);
_animateCookingWheelTransition();
_cookingWheelLastDelta = 0;
// Progress dots
const dotsEl = document.getElementById('cooking-progress-dots');
if (dotsEl) {
@@ -12203,6 +12494,53 @@ let _cookingTimerIdCounter = 0;
let _cookingSuggestedSeconds = 0;
let _cookingSuggestedLabel = '';
function _playCookingTimerSound(type = 'done') {
try {
const Ctx = window.AudioContext || window.webkitAudioContext;
if (!Ctx) return;
const ctx = new Ctx();
const now = ctx.currentTime;
const pattern = type === 'warning'
? [{ f: 880, d: 0.08, o: 0.00 }, { f: 1046, d: 0.10, o: 0.14 }]
: [
{ f: 740, d: 0.10, o: 0.00 },
{ f: 988, d: 0.12, o: 0.18 },
{ f: 1318, d: 0.14, o: 0.38 }
];
for (const p of pattern) {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = p.f;
gain.gain.setValueAtTime(0.0001, now + p.o);
gain.gain.exponentialRampToValueAtTime(0.12, now + p.o + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, now + p.o + p.d);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(now + p.o);
osc.stop(now + p.o + p.d + 0.02);
}
const endAt = now + Math.max(...pattern.map(p => p.o + p.d)) + 0.08;
setTimeout(() => { try { ctx.close(); } catch (_) { /* ignore */ } }, Math.max(120, Math.round((endAt - now) * 1000)));
} catch (_) { /* ignore */ }
}
function _notifyCookingTimer(type, label) {
const key = type === 'warning' ? 'cooking.timer_warning_tts' : 'cooking.timer_expired_tts';
const msg = t(key).replace('{label}', label || t('cooking.timer'));
const s = getSettings();
const hasBrowserTts = typeof window !== 'undefined' && 'speechSynthesis' in window;
const hasCustomTts = (s.tts_engine === 'custom' && !!s.tts_url);
if (_cookingTTS && (hasBrowserTts || hasCustomTts)) {
speakCookingStep(msg);
} else {
_playCookingTimerSound(type === 'warning' ? 'warning' : 'done');
}
}
/**
* Parse time durations from step text.
* Returns total seconds or 0 if no time found.
@@ -12236,18 +12574,88 @@ function _formatTimerDisplay(sec) {
/** Extract a short 2-3 word label from the step text for the timer. */
function _extractTimerLabel(text, stepNum) {
const raw = String(text || '');
const fillers = new Set(['il','la','lo','le','gli','i','dell','della','dello','delle','degli','dei',
'un','una','uno','del','al','alla','allo','alle','agli','ai','nel','nella','nello','nelle',
'negli','nei','per','con','che','poi','e','o','non','se','in','di','a','da','fino','mentre',
'quando','dopo','prima','circa','bene','ancora','subito','su','ad','ed','più','meno','tutto','tutta']);
'quando','dopo','prima','circa','bene','ancora','subito','su','ad','ed','piu','meno','tutto','tutta',
'the','and','for','mit','und','zum','zur']);
const applianceWords = new Set(['moulinex','cookeo','bimby','forno','airfryer','friggitrice','microonde','tm5','tm6']);
const timePatterns = [/mezz['']?\s*ora/i, /\bor[ae]\b/i, /\bmin(?:ut[oi])?\b/i, /\bsecond[oi]\b/i, /\bquarto\s+d['']?\s*ora/i];
let timeIdx = text.length;
for (const p of timePatterns) { const r = p.exec(text); if (r && r.index < timeIdx) timeIdx = r.index; }
const beforeTime = (text.slice(0, timeIdx).trim() || text);
const words = beforeTime.replace(/[.,!?;:'"()\[\]]/g, '').split(/\s+/).filter(w => w.length > 2 && !/^\d+$/.test(w));
const meaningful = words.filter(w => !fillers.has(w.toLowerCase()));
if (meaningful.length >= 1) return meaningful.slice(0, 3).join(' ');
return `Passo ${stepNum + 1}`;
let timeIdx = raw.length;
for (const p of timePatterns) {
const r = p.exec(raw);
if (r && r.index < timeIdx) timeIdx = r.index;
}
let beforeTime = (raw.slice(0, timeIdx).trim() || raw)
.replace(/\([^)]*\)/g, ' ')
.replace(/[.,!?;:'"\[\]]/g, ' ')
.replace(/^\s*(poi|quindi|allora|infine|then|dann)\s+/i, '')
.replace(/\s+/g, ' ')
.trim();
if (!beforeTime) return `Passo ${stepNum + 1}`;
const actionRules = [
{ re: /\b(rosolatur\w*|rosola\w*|soffrigg\w*)\b/i, label: 'Rosolatura' },
{ re: /\b(stuf\w*)\b/i, label: 'Stufare' },
{ re: /\b(boll\w*|sobboll\w*)\b/i, label: 'Bollitura' },
{ re: /\b(cuoc\w*|cottur\w*)\b/i, label: 'Cottura' },
{ re: /\b(tost\w*)\b/i, label: 'Tostatura' },
{ re: /\b(mescol\w*|mischi\w*)\b/i, label: 'Mescola' },
{ re: /\b(ripos\w*)\b/i, label: 'Riposo' },
{ re: /\b(marin\w*)\b/i, label: 'Marinatura' },
{ re: /\b(preriscald\w*|accend\w*|scald\w*)\b/i, label: 'Preriscalda' }
];
const hasAppliance = /\b(moulinex|cookeo|bimby|forno|airfryer|friggitrice|microonde|tm5|tm6)\b/i.test(beforeTime);
let actionLabel = '';
for (const rule of actionRules) {
if (rule.re.test(beforeTime)) {
actionLabel = rule.label;
break;
}
}
// Remove the leading verb chunk and appliance references, then keep only compact object words.
let objectPart = beforeTime
.replace(/^(?:fai|lascia|metti|porta|tieni|poi|quindi)\s+/i, '')
.replace(/^(?:rosola\w*|soffrigg\w*|stuf\w*|boll\w*|sobboll\w*|cuoc\w*|tost\w*|mescol\w*|mischi\w*|ripos\w*|marin\w*|preriscald\w*|accend\w*|scald\w*)\s+/i, '')
.replace(/\b(?:nel|nella|nello|nei|in|su|sul|sulla|dentro|con)\b\s+(?:il|lo|la|i|gli|le)?\s*(?:moulinex|cookeo|bimby|forno|airfryer|friggitrice|microonde|tm5|tm6)\b/gi, ' ')
.replace(/\b(moulinex|cookeo|bimby|forno|airfryer|friggitrice|microonde|tm5|tm6)\b/gi, ' ')
.replace(/\s+/g, ' ')
.trim();
const objectWords = objectPart
.split(/\s+/)
.map(w => w.toLowerCase())
.filter(w => w.length > 2 && !/^\d+$/.test(w) && !fillers.has(w) && !applianceWords.has(w));
const shortObject = objectWords.slice(0, 2).join(' ');
let label = '';
if (actionLabel) {
label = shortObject ? `${actionLabel} ${shortObject}` : actionLabel;
if (actionLabel === 'Preriscalda' && hasAppliance) label = 'Preriscalda';
} else {
const fallback = beforeTime
.split(/\s+/)
.map(w => w.toLowerCase())
.filter(w => w.length > 2 && !/^\d+$/.test(w) && !fillers.has(w) && !applianceWords.has(w))
.slice(0, 3)
.join(' ');
label = fallback || `Passo ${stepNum + 1}`;
}
label = label.replace(/\s+/g, ' ').trim();
if (!label) return `Passo ${stepNum + 1}`;
// Keep timer chips compact and readable.
const maxLen = 30;
if (label.length > maxLen) label = label.slice(0, maxLen).trim() + '…';
return label.charAt(0).toUpperCase() + label.slice(1);
}
function setupCookingTimerSuggestion(stepText) {
@@ -12281,28 +12689,34 @@ function addCookingTimer(seconds, label) {
}
function removeCookingTimer(id) {
const t = _cookingTimers.find(t => t.id === id);
if (t && t.interval) clearInterval(t.interval);
_cookingTimers = _cookingTimers.filter(t => t.id !== id);
const timer = _cookingTimers.find(ti => ti.id === id);
if (timer && timer.interval) clearInterval(timer.interval);
_cookingTimers = _cookingTimers.filter(ti => ti.id !== id);
renderTimersBar();
_updateScreenFlash();
}
function toggleCookingTimerById(id) {
const t = _cookingTimers.find(t => t.id === id);
if (!t) return;
if (t.running) {
clearInterval(t.interval);
t.interval = null;
t.running = false;
const timer = _cookingTimers.find(ti => ti.id === id);
if (!timer) return;
if (timer.running) {
clearInterval(timer.interval);
timer.interval = null;
timer.running = false;
} else {
t.running = true;
t.interval = setInterval(() => {
t.seconds--;
if (t.seconds === 10 && _cookingTTS) {
speakCookingStep(t('cooking.timer_warning_tts').replace('{label}', t.label));
timer.running = true;
timer.interval = setInterval(() => {
timer.seconds = Math.max(0, timer.seconds - 1);
if (timer.seconds === 10) {
_notifyCookingTimer('warning', timer.label);
}
if (t.seconds === 0) _cookingTimerDoneById(id);
if (timer.seconds === 0) {
_cookingTimerDoneById(id);
return;
}
_updateTimerCard(id);
}, 1000);
}
@@ -12310,19 +12724,27 @@ function toggleCookingTimerById(id) {
}
function resetCookingTimerById(id) {
const t = _cookingTimers.find(t => t.id === id);
if (!t) return;
clearInterval(t.interval);
t.interval = null;
t.running = false;
t.seconds = t.total;
const timer = _cookingTimers.find(ti => ti.id === id);
if (!timer) return;
clearInterval(timer.interval);
timer.interval = null;
timer.running = false;
timer.seconds = timer.total;
_updateTimerCard(id);
}
function _cookingTimerDoneById(id) {
if (navigator.vibrate) navigator.vibrate([300, 100, 300, 100, 300]);
const timer = _cookingTimers.find(ti => ti.id === id);
if (_cookingTTS && timer) speakCookingStep(t('cooking.timer_expired_tts').replace('{label}', timer.label));
if (!timer) return;
clearInterval(timer.interval);
timer.interval = null;
timer.running = false;
timer.seconds = 0;
_notifyCookingTimer('done', timer.label);
removeCookingTimer(id); // auto-cancel finished timer (do not continue past 00:00)
}
function _updateTimerCard(id) {
@@ -12427,8 +12849,10 @@ function navigateCookingStep(delta) {
closeCookingMode();
return;
}
_cookingWheelLastDelta = delta;
_cookingStep = next;
renderCookingStep();
_cookingStepFeedback();
if (_cookingTTS) {
const text = ((_cookingRecipe.steps || [])[_cookingStep] || '').replace(/^Passo\s*\d+\s*[:.]\s*/i, '');
speakCookingStep(text);
+9 -12
View File
@@ -8,7 +8,7 @@ The EverShelf Kiosk app turns any Android tablet into a dedicated, locked-down k
**[⬇ Download latest APK](https://github.com/dadaloop82/EverShelf/releases/latest/download/evershelf-kiosk.apk)**
> Current version: **v1.5.0** — requires Android 7.0+
> Current version: **v1.6.0** — requires Android 7.0+
---
@@ -16,7 +16,7 @@ The EverShelf Kiosk app turns any Android tablet into a dedicated, locked-down k
- Displays the EverShelf web app in a **full-screen WebView** (no browser chrome)
- **Locks the screen** with Android's `startLockTask` — home, recents, and back buttons are blocked
- Runs the **Scale Gateway** app in the background automatically on startup
- Runs the **built-in BLE scale gateway** as an integrated foreground service — no external app required
- Provides a **native TTS bridge** so Cooking Mode reads steps aloud via Android TextToSpeech
- Auto-detects your EverShelf server on the LAN with a **smart discovery scanner**
- Reports errors and install failures back to the developer automatically
@@ -46,11 +46,13 @@ Enter your EverShelf server URL (e.g. `https://192.168.1.100/dispensa`).
- Only scans your actual Wi-Fi/Ethernet subnet (VPN and cellular interfaces ignored)
- Real-time feedback as hosts are tested
### Step 5 — Scale Gateway
If you have a BLE smart scale, install and configure the Scale Gateway:
1. Tap **"Installa Gateway"** — the APK is downloaded from GitHub and installed via `PackageInstaller`
2. If installation fails, a diagnostic dialog shows: status code, error message, APK size, Android version, and device model — plus a "Riprova" button
3. On success, the wizard automatically writes `scale_enabled=true` and `scale_gateway_url=ws://127.0.0.1:8765` to your EverShelf server
### Step 5 — Smart Scale
If you have a Bluetooth LE smart scale, configure it here:
1. Tap **"Yes, I have a scale"** — the app scans for nearby BLE devices
2. Tap your scale in the list (devices most likely to be scales are marked with ⭐)
3. On selection, the app automatically writes `scale_enabled=true` and `scale_gateway_url=ws://127.0.0.1:8765` to your EverShelf server
The BLE gateway runs as a built-in foreground service — **no external APK needed**.
### Step 6 — Screensaver
Choose whether the screen should go dark after inactivity.
@@ -97,11 +99,6 @@ The WebView accepts self-signed certificates automatically. No configuration nee
## Troubleshooting
### "Impossibile installare il gateway"
- Make sure "Install from unknown sources" is enabled for the kiosk app in Android Settings → Apps → Special app access
- Check that there is enough free storage (the APK is ~15 MB)
- The diagnostic dialog shows the exact failure code — include it when opening an issue
### "Server non trovato" during auto-discovery
- Make sure your tablet and server are on the same Wi-Fi network
- Ensure the server is not on a VPN-only interface
+1 -1
View File
@@ -73,7 +73,7 @@ Shown as an inline AI badge next to the expiry estimate. Does not block the form
### Recipe Generation
Tap **🍳 Ricette** → **Genera ricetta** to get a recipe using:
Tap **🍳 Recipes** → **Generate Recipe** to get a recipe using:
- Ingredients about to expire (prioritised)
- What's currently in your pantry
- Your language preference
+11 -11
View File
@@ -46,18 +46,18 @@ All data stays on your server. No cloud, no subscriptions.
## 🆕 What's New
### v1.7.1 (2026-05-04)
- Destructive actions ("Butta tutto", "Finisci tutto") now require a **5-second countdown confirmation** before executing
- History undo button ↩ is now clearly visible (red tint, larger)
- Undo confirmation uses the in-app modal instead of the native browser `confirm()`
### v1.7.13 (2026-05-16)
- **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.0 (2026-05-04)
- Smart auto-discovery rewrite (kiosk)
- Gateway auto-pre-configuration after install
- ErrorReporter init at setup start
- Graceful Bring! no-key state
- Use-quantity guard with shake animation
- Demo mode (`?demo=1`)
### v1.7.12 (2026-05-13)
- "Use first" banner now shows opening date and location instead of a confusing calculated expiry
- "Use All / Done" in recipes no longer deletes the inventory row — uses exact quantity instead
- Scan page fully redesigned: 2× zoom, torch, camera flip, 3 input tabs, AI Number OCR, recent products chips
- Anomaly detection: false positives eliminated (untracked direction removed, minimum 5 txn + 7-day span)
- AI price estimation for each Bring! shopping item with real-time dashboard total badge
- Kiosk v1.6.0: BLE scale gateway is now built-in — no separate APK needed
- Complete i18n: 934 keys per language
→ See the full [CHANGELOG](https://github.com/dadaloop82/EverShelf/blob/main/CHANGELOG.md)
+11 -4
View File
@@ -1,6 +1,15 @@
# Scale Gateway
# Scale Gateway — Deprecated
The EverShelf Scale Gateway is an Android app that bridges a Bluetooth LE smart scale to EverShelf, enabling automatic weight-based inventory tracking.
> **As of EverShelf Kiosk v1.6.0, BLE scale support is fully integrated into the Kiosk app.**
> You no longer need to install or configure this separate gateway.
>
> 📱 **Using the EverShelf Kiosk app?** → See [Android Kiosk](Android-Kiosk) — configure your scale in Step 5 of the setup wizard.
>
> 💻 **Not using the kiosk app?** The legacy gateway APK below still works for non-kiosk setups, but receives no new updates.
---
# Scale Gateway (legacy)
---
@@ -52,8 +61,6 @@ The Gateway runs a local WebSocket server on port **8765**. The EverShelf server
Download and install the APK. You may need to enable "Install from unknown sources" in Android Settings.
> **Kiosk users:** the Setup Wizard installs the gateway automatically in Step 5.
### 2. Launch the app
The gateway server starts immediately. Note the **Gateway URL** shown (e.g. `ws://192.168.1.100:8765`).
+2 -2
View File
@@ -63,7 +63,7 @@ The kiosk app is fully self-contained. No separate gateway app is required.
3. Choose your language
4. Grant camera, microphone and Bluetooth permissions when prompted
5. Enter your EverShelf server URL (e.g. `https://192.168.1.100/dispensa`) or use auto-discovery
6. If you have a Bluetooth scale: tap **"Sì, ho una bilancia"**, wait for the BLE scan, then tap your scale in the list
6. If you have a Bluetooth scale: tap **"Yes, I have a scale"**, wait for the BLE scan, then tap your scale in the list
7. Done — the web app loads in full-screen kiosk mode
### Scale Configuration
@@ -76,7 +76,7 @@ BLE scale setup happens inside the kiosk app itself — **no external app needed
### Exiting Kiosk Mode
Tap the **✕** button in the header. A confirmation dialog appears — tap "Esci" to exit.
Tap the **✕** button in the header. A confirmation dialog appears — tap **"Exit"** to confirm.
---
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "it.dadaloop.evershelf.kiosk"
minSdk = 24
targetSdk = 34
versionCode = 11
versionName = "1.7.0"
versionCode = 13
versionName = "1.7.2"
}
signingConfigs {
@@ -1,8 +1,11 @@
package it.dadaloop.evershelf.kiosk
import android.app.ActivityManager
import android.app.ApplicationExitInfo
import android.content.Context
import android.os.Build
import android.util.Log
import androidx.annotation.RequiresApi
import org.json.JSONObject
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
@@ -40,7 +43,9 @@ object ErrorReporter {
// SharedPreferences for crash persistence
private const val PREFS_NAME = "evershelf_kiosk_errors"
private const val KEY_PENDING = "pending_crash_json"
private const val KEY_PENDING = "pending_crash_json"
private const val KEY_WAS_RUNNING = "was_running_dirty"
private const val KEY_LAST_EXIT_TS = "last_reported_exit_ts"
private val executor = Executors.newSingleThreadExecutor()
@@ -76,6 +81,9 @@ object ErrorReporter {
// Send any crash that was saved to prefs during a previous session
sendPendingCrash()
// Detect ANR / OOM / native crashes from the previous run
detectPreviousCrash()
// Install a global UncaughtExceptionHandler so ANY unhandled crash is reported
val previousHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
@@ -96,6 +104,17 @@ object ErrorReporter {
}
}
/**
* Call from Activity.onDestroy() on a *clean* exit (back-pressed, settings, shutdown).
* Clears the dirty-launch sentinel so the next start does not report a false positive.
*/
fun markCleanStop() {
if (::appContext.isInitialized) {
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit().putBoolean(KEY_WAS_RUNNING, false).apply()
}
}
/**
* Report a caught [Throwable] asynchronously (does not block UI thread).
*/
@@ -132,6 +151,96 @@ object ErrorReporter {
// ── Internal ─────────────────────────────────────────────────────────────
/**
* Detects whether the *previous* run of the app ended with a crash, ANR or OOM kill.
*
* On Android 11+ (API 30) we use [ActivityManager.getHistoricalProcessExitReasons] which
* gives the exact reason and (for Java crashes) a stack trace.
*
* On Android 710 we use a "dirty-launch sentinel": a boolean in SharedPreferences that is
* set to `true` on every start and `false` only when the activity is destroyed cleanly via
* [markCleanStop]. If it is still `true` on the next start, the previous run was not clean.
*/
private fun detectPreviousCrash() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
detectExitReasonApi30()
} else {
// API 2429: dirty-launch sentinel
val prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
if (prefs.getBoolean(KEY_WAS_RUNNING, false)) {
reportAsync(
type = "crash-sentinel",
message = "App was not cleanly shut down on previous run (ANR / OOM / native crash suspected).",
stack = "",
context = mapOf(
"device" to deviceInfo,
"note" to "Detected via dirty-launch sentinel (API ${Build.VERSION.SDK_INT})"
)
)
}
}
// Mark this launch as running — will be cleared by markCleanStop() on clean exit
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit().putBoolean(KEY_WAS_RUNNING, true).apply()
}
@RequiresApi(Build.VERSION_CODES.R)
private fun detectExitReasonApi30() {
try {
val am = appContext.getSystemService(ActivityManager::class.java) ?: return
// Check the last 5 exits; stop at the first we already reported
val exits = am.getHistoricalProcessExitReasons(null, 0, 5)
if (exits.isEmpty()) return
val prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val lastReportedTs = prefs.getLong(KEY_LAST_EXIT_TS, 0L)
val crashReasons = setOf(
ApplicationExitInfo.REASON_CRASH,
ApplicationExitInfo.REASON_CRASH_NATIVE,
ApplicationExitInfo.REASON_ANR,
ApplicationExitInfo.REASON_LOW_MEMORY
)
var newestTs = lastReportedTs
for (exit in exits) {
if (exit.timestamp <= lastReportedTs) continue // already reported
if (exit.reason !in crashReasons) continue
val reasonName = when (exit.reason) {
ApplicationExitInfo.REASON_CRASH -> "crash-java"
ApplicationExitInfo.REASON_CRASH_NATIVE -> "crash-native"
ApplicationExitInfo.REASON_ANR -> "anr"
ApplicationExitInfo.REASON_LOW_MEMORY -> "oom-kill"
else -> "exit-${exit.reason}"
}
val msg = exit.description?.takeIf { it.isNotEmpty() }
?: "${exit.processName ?: "app"} terminated (reason ${exit.reason})"
// Java crashes include a tombstone trace — read up to 4KB
var stack = ""
try {
exit.traceInputStream?.bufferedReader()?.use { stack = it.readText().take(4000) }
} catch (_: Exception) {}
val ctx = mutableMapOf<String, Any?>(
"device" to deviceInfo,
"reason" to exit.reason,
"process" to (exit.processName ?: ""),
"crash_ts" to SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date(exit.timestamp)),
"note" to "Detected via ApplicationExitInfo on restart (API ${Build.VERSION.SDK_INT})"
)
reportAsync(type = reasonName, message = msg, stack = stack, context = ctx)
if (exit.timestamp > newestTs) newestTs = exit.timestamp
}
if (newestTs > lastReportedTs) {
prefs.edit().putLong(KEY_LAST_EXIT_TS, newestTs).apply()
}
} catch (_: Exception) {}
}
private fun fingerprint(type: String, message: String): String {
val key = "$type:${message.take(120)}"
return key.hashCode().toString(16)
@@ -83,6 +83,16 @@ class KioskActivity : AppCompatActivity() {
private val pollHandler = Handler(Looper.getMainLooper())
private var activeDownloadId: Long = -1
// Periodic update-check handler (fires every 30 min; internal throttle in checkForUpdates limits real API calls to every 6h)
private val updateCheckHandler = Handler(Looper.getMainLooper())
private val updateCheckRunnable = Runnable { schedulePeriodicUpdateCheck() }
private val UPDATE_CHECK_INTERVAL_MS = 30L * 60 * 1000 // 30 minutes
private fun schedulePeriodicUpdateCheck() {
checkForUpdates(forceCheck = false)
updateCheckHandler.postDelayed(updateCheckRunnable, UPDATE_CHECK_INTERVAL_MS)
}
// File chooser
private var fileChooserCallback: ValueCallback<Array<Uri>>? = null
@@ -104,6 +114,9 @@ class KioskActivity : AppCompatActivity() {
private const val KIOSK_DOWNLOAD_URL = "https://github.com/dadaloop82/EverShelf/releases/download/kiosk-latest/evershelf-kiosk.apk"
private const val SPLASH_DURATION = 1500L
private const val GITHUB_RELEASES_API = "https://api.github.com/repos/dadaloop82/EverShelf/releases/latest"
// Keys for persisting a pending update across restarts
private const val KEY_PENDING_UPDATE_VERSION = "pending_update_version"
private const val KEY_PENDING_UPDATE_URL = "pending_update_url"
}
override fun attachBaseContext(newBase: Context) {
@@ -492,6 +505,16 @@ class KioskActivity : AppCompatActivity() {
if (apkUrl.isBlank()) return
runOnUiThread { triggerApkDownload(apkUrl) }
}
/**
* Called by the webapp when a modal is shown / hidden so the native settings
* button does not intercept touches that belong to the HTML modal content.
*/
@JavascriptInterface
fun setNativeSettingsVisible(visible: Boolean) {
runOnUiThread {
btnSettings.visibility = if (visible) View.VISIBLE else View.GONE
}
}
}, "_kioskBridge")
val url = prefs.getString(KEY_URL, "http://evershelf.local") ?: "http://evershelf.local"
@@ -638,7 +661,17 @@ class KioskActivity : AppCompatActivity() {
notifyJs(result)
if (!kioskNeedsUpdate) return@Thread
if (!kioskNeedsUpdate) {
// Clear any stale pending update if the current version is now up to date
prefs.edit().remove(KEY_PENDING_UPDATE_VERSION).remove(KEY_PENDING_UPDATE_URL).apply()
return@Thread
}
// Persist the pending update so the banner reappears after a crash/restart
prefs.edit()
.putString(KEY_PENDING_UPDATE_VERSION, latestTag)
.putString(KEY_PENDING_UPDATE_URL, kioskApkUrl)
.apply()
val label = if (isSemver) "$currentKiosk$latestTag" else latestTag
runOnUiThread { showNativeUpdateBanner("🔄 Kiosk $label", kioskApkUrl) }
@@ -648,6 +681,33 @@ class KioskActivity : AppCompatActivity() {
}.start()
}
/**
* On resume: if a previous session detected an available update and saved it to prefs,
* restore the update banner immediately without a network round-trip.
*/
private fun restorePendingUpdateBanner() {
val savedVersion = prefs.getString(KEY_PENDING_UPDATE_VERSION, null) ?: return
val savedUrl = prefs.getString(KEY_PENDING_UPDATE_URL, null) ?: return
val currentKiosk = try { packageManager.getPackageInfo(packageName, 0).versionName ?: "" } catch (_: Exception) { "" }
// Normalise: strip non-numeric prefix for comparison
val norm = { v: String -> v.replace(Regex("^[^0-9]*"), "") }
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 }
for (i in 0 until maxOf(r.size, l.size)) {
val rv = r.getOrElse(i) { 0 }; val lv = l.getOrElse(i) { 0 }
if (rv != lv) return rv > lv
}
return false
}
if (currentKiosk.isNotEmpty() && semverNewer(norm(savedVersion), norm(currentKiosk))) {
showNativeUpdateBanner("🔄 Kiosk $currentKiosk$savedVersion", savedUrl)
} else {
// Update was installed or is no longer applicable — clear the saved entry
prefs.edit().remove(KEY_PENDING_UPDATE_VERSION).remove(KEY_PENDING_UPDATE_URL).apply()
}
}
private fun showNativeUpdateBanner(message: String, apkDownloadUrl: String) {
pendingApkDownloadUrl = apkDownloadUrl
tvUpdateMessage.text = "⬆️ Aggiornamento disponibile: $message"
@@ -952,6 +1012,16 @@ class KioskActivity : AppCompatActivity() {
// Re-apply screensaver flag in case the user changed it in Settings
applyScreensaverFlag()
}
// Show banner immediately if there is a pending update detected in a previous session
restorePendingUpdateBanner()
// Start (or restart) the periodic update check
updateCheckHandler.removeCallbacks(updateCheckRunnable)
updateCheckHandler.postDelayed(updateCheckRunnable, UPDATE_CHECK_INTERVAL_MS)
}
override fun onPause() {
super.onPause()
updateCheckHandler.removeCallbacks(updateCheckRunnable)
}
@Suppress("DEPRECATION")
@@ -1040,6 +1110,8 @@ class KioskActivity : AppCompatActivity() {
}
override fun onDestroy() {
ErrorReporter.markCleanStop()
updateCheckHandler.removeCallbacks(updateCheckRunnable)
tts?.stop()
tts?.shutdown()
tts = null
+29 -4
View File
@@ -1320,6 +1320,30 @@
<button class="btn btn-large btn-success full-width mt-2" onclick="saveSettings()" data-i18n="btn.save_config">💾 Salva Configurazione</button>
<div id="settings-status" class="settings-status" style="display:none"></div>
<!-- About & Support -->
<div class="settings-section" style="margin-top:24px">
<h3 class="settings-section-title" data-i18n="about.title">About</h3>
<div class="settings-row" style="justify-content:space-between;align-items:center">
<span class="settings-label" data-i18n="about.version">Version</span>
<span id="about-version-label" class="settings-hint" style="font-family:monospace"></span>
</div>
<div style="margin-top:10px;display:flex;flex-direction:column;gap:8px">
<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? 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"
target="_blank" rel="noopener" data-i18n="about.changelog">Changelog</a>
<a class="btn btn-outline full-width" style="text-decoration:none;text-align:center"
href="https://github.com/dadaloop82/EverShelf"
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>
<!-- ===== GEMINI CHAT ===== -->
@@ -1430,9 +1454,6 @@
</div>
<div id="recipe-result" style="display:none" class="recipe-result">
<div id="recipe-content"></div>
<button class="btn btn-large btn-cooking full-width mt-2" onclick="startCookingMode()" data-i18n="recipes.start_cooking">
👨‍🍳 Modalità Cucina
</button>
<button class="btn btn-large btn-secondary full-width mt-2" onclick="regenerateRecipe()" data-i18n="recipes.regenerate">
🔄 Generane un'altra
</button>
@@ -1507,7 +1528,11 @@
<button class="cooking-restart-btn" onclick="restartCookingMode()" title="Ricomincia dall'inizio">↺ Ricomincia</button>
</div>
<div class="cooking-progress-dots" id="cooking-progress-dots"></div>
<div class="cooking-step-text" id="cooking-step-text"></div>
<div class="cooking-wheel" id="cooking-wheel" tabindex="0" aria-label="Navigazione passi ricetta">
<div class="cooking-step-ghost cooking-step-prev" id="cooking-step-prev"></div>
<div class="cooking-step-text" id="cooking-step-text"></div>
<div class="cooking-step-ghost cooking-step-next" id="cooking-step-next"></div>
</div>
<button class="cooking-replay-btn" id="cooking-replay" onclick="replayCookingTTS()" title="Rileggi questo passo">🔊 Rileggi</button>
<div class="cooking-timer-suggest" id="cooking-timer-suggest" style="display:none">
<button class="cooking-timer-add-btn" onclick="addSuggestedCookingTimer()">
+15 -4
View File
@@ -91,8 +91,8 @@
"banner_review_action_edit": "Korrigieren",
"banner_review_action_weigh": "Wiegen",
"banner_review_dismiss": "Ignorieren",
"banner_prediction_title": "Ungewöhnlicher Verbrauch",
"banner_prediction_hint": "Laut Vorhersage stimmt diese Menge nicht mit dem erwarteten Verbrauch überein.",
"banner_prediction_title": "Verbrauch zur Prüfung",
"banner_prediction_hint": "Die Verbrauchsschätzung passt sich aktuellen Daten an: bitte nur die aktuelle Menge bestätigen.",
"banner_prediction_action_confirm": "{qty} {unit} bestätigen",
"banner_prediction_action_weigh": "Jetzt wiegen",
"banner_prediction_action_edit": "Menge aktualisieren",
@@ -128,8 +128,8 @@
"banner_prediction_rate_day": "Durchschnitt ~{n} {unit}/Tag",
"banner_prediction_rate_week": "Durchschnitt ~{n} {unit}/Woche",
"banner_prediction_days_ago": "Vor {n} Tagen aufgefüllt",
"banner_prediction_more": "Ich erwartete {expected} {unit}{time}, du hast aber {actual} {unit}. Hast du Bestand ohne Buchung hinzugefügt?",
"banner_prediction_less": "Ich erwartete {expected} {unit}{time}, du hast aber nur {actual} {unit}. Hast du mehr als üblich verbraucht?",
"banner_prediction_more": "frühere Schätzung: {expected} {unit}{time}; aktuelle Menge: {actual} {unit}.",
"banner_prediction_less": "Schätzung: {expected} {unit}{time}; aktuelle Menge: {actual} {unit}. Wenn sich dein Verbrauch geändert hat, aktualisiert sich die Prognose automatisch.",
"banner_finished_zero": "Bestand zeigt null, aber gespeicherte Buchungen deuten an, dass es nicht leer sein sollte.",
"banner_finished_expected": "Laut Aufzeichnungen solltest du noch {qty} {unit} haben.",
"banner_finished_check": "Kannst du nachschauen?",
@@ -1085,5 +1085,16 @@
},
"appliances": {
"empty": "Kein Haushaltsgerät hinzugefügt"
},
"about": {
"title": "Über",
"version": "Version",
"report_bug": "Fehler melden",
"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.",
"changelog": "Changelog",
"github": "GitHub-Repository"
}
}
+16 -5
View File
@@ -91,9 +91,9 @@
"banner_review_action_edit": "Correct",
"banner_review_action_weigh": "Weigh",
"banner_review_dismiss": "Dismiss",
"banner_prediction_title": "Anomalous consumption",
"banner_prediction_hint": "Based on predictions, this quantity doesn't match expected consumption.",
"banner_prediction_action_confirm": "Confirm {qty} {unit} is correct",
"banner_prediction_title": "Consumption to review",
"banner_prediction_hint": "The consumption estimate adapts to recent data: confirm only if the current quantity is correct.",
"banner_prediction_action_confirm": "Confirm {qty} {unit}",
"banner_prediction_action_weigh": "Weigh now",
"banner_prediction_action_edit": "Update quantity",
"banner_expired_title": "Expired product",
@@ -128,8 +128,8 @@
"banner_prediction_rate_day": "Average ~{n} {unit}/day",
"banner_prediction_rate_week": "Average ~{n} {unit}/week",
"banner_prediction_days_ago": "{n} days ago you restocked",
"banner_prediction_more": "I expected {expected} {unit}{time}, but you have {actual} {unit}. Did you add stock without recording it?",
"banner_prediction_less": "I expected {expected} {unit}{time}, but you only have {actual} {unit}. Did you use more than usual?",
"banner_prediction_more": "previous estimate: {expected} {unit}{time}; current quantity: {actual} {unit}.",
"banner_prediction_less": "estimate: {expected} {unit}{time}; current quantity: {actual} {unit}. If your usage pace changed, the forecast updates automatically.",
"banner_finished_zero": "Inventory shows zero, but recorded movements suggest it shouldn't be empty.",
"banner_finished_expected": "According to records you should still have {qty} {unit}.",
"banner_finished_check": "Can you check?",
@@ -1085,5 +1085,16 @@
},
"appliances": {
"empty": "No appliances added"
},
"about": {
"title": "About",
"version": "Version",
"report_bug": "Report a Bug",
"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.",
"changelog": "Changelog",
"github": "GitHub Repository"
}
}
+16 -5
View File
@@ -91,9 +91,9 @@
"banner_review_action_edit": "Correggi",
"banner_review_action_weigh": "Pesa",
"banner_review_dismiss": "Ignora",
"banner_prediction_title": "Consumo anomalo",
"banner_prediction_hint": "Secondo le previsioni, questa quantità non corrisponde al consumo previsto.",
"banner_prediction_action_confirm": "Confermo la quantità di {qty} {unit}",
"banner_prediction_title": "Consumo da verificare",
"banner_prediction_hint": "La stima di consumo si adatta ai dati recenti: verifica solo se la quantità corrente è corretta.",
"banner_prediction_action_confirm": "Confermo {qty} {unit}",
"banner_prediction_action_weigh": "Pesa ora",
"banner_prediction_action_edit": "Aggiorna quantità",
"banner_expired_title": "Prodotto scaduto",
@@ -128,8 +128,8 @@
"banner_prediction_rate_day": "Media ~{n} {unit}/giorno",
"banner_prediction_rate_week": "Media ~{n} {unit}/settimana",
"banner_prediction_days_ago": "{n} giorni fa hai rifornito",
"banner_prediction_more": "mi aspettavo {expected} {unit}{time}, ne hai invece {actual} {unit}. Hai aggiunto scorte senza registrarle?",
"banner_prediction_less": "mi aspettavo {expected} {unit}{time}, ne hai solo {actual} {unit}. Hai consumato di più del solito?",
"banner_prediction_more": "stima precedente: {expected} {unit}{time}; quantità attuale: {actual} {unit}.",
"banner_prediction_less": "stima: {expected} {unit}{time}; quantità attuale: {actual} {unit}. Se hai cambiato ritmo d'uso, la previsione si aggiorna automaticamente.",
"banner_finished_zero": "L'inventario segna zero, ma i movimenti registrati dicono che non dovrebbe essere finito.",
"banner_finished_expected": "Secondo le registrazioni dovresti averne ancora {qty} {unit}.",
"banner_finished_check": "Puoi controllare?",
@@ -1085,5 +1085,16 @@
},
"appliances": {
"empty": "Nessun elettrodomestico aggiunto"
},
"about": {
"title": "Informazioni",
"version": "Versione",
"report_bug": "Segnala un problema",
"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.",
"changelog": "Changelog",
"github": "Repository GitHub"
}
}