Compare commits

...

72 Commits

Author SHA1 Message Date
dadaloop82 45dc79e5b7 chore(kiosk): trigger CI build for v1.7.14 with openNativeSettings 2026-05-16 13:32:28 +00:00
dadaloop82 8508993441 chore(kiosk): trigger CI build for v1.7.14 2026-05-16 13:32:03 +00:00
dadaloop82 a3147d704e chore: bump to v1.7.14 — kiosk versionCode 15, CHANGELOG 2026-05-16 13:31:54 +00:00
dadaloop82 834d8efab4 chore: bump to v1.7.14 — kiosk versionCode 15, CHANGELOG 2026-05-16 13:31:31 +00:00
github-actions[bot] 8894a5a2c7 chore: auto-merge develop → main
Triggered by: 5f4c29b feat: in-app bug report form (replaces GitHub link)
2026-05-16 13:27:29 +00:00
dadaloop82 5f4c29bd5a feat: in-app bug report form (replaces GitHub link) 2026-05-16 13:25:51 +00:00
github-actions[bot] 460875430b chore: auto-merge develop → main
Triggered by: 8a596cb fix: openNativeSettings uses try/catch instead of fragile typeof check
2026-05-16 13:19:44 +00:00
dadaloop82 8a596cb7d8 fix: openNativeSettings uses try/catch instead of fragile typeof check 2026-05-16 13:18:07 +00:00
github-actions[bot] 99b8953ccf chore: auto-merge develop → main
Triggered by: c87d7d2 fix: bump manifest.json version to 1.7.13 (was showing false update badge)
2026-05-16 13:14:25 +00:00
dadaloop82 c87d7d2cde fix: bump manifest.json version to 1.7.13 (was showing false update badge) 2026-05-16 13:12:49 +00:00
github-actions[bot] 424fc7bbe3 chore: auto-merge develop → main
Triggered by: 61a2372 feat(kiosk): add native settings shortcut in webapp settings page
2026-05-16 13:09:08 +00:00
dadaloop82 61a2372caa feat(kiosk): add native settings shortcut in webapp settings page 2026-05-16 13:07:29 +00:00
github-actions[bot] ad9be3b705 chore: auto-merge develop → main
Triggered by: bd8dc05 fix(kiosk): restore native settings gear — remove JS ⚙️ (opens wrong settings), restore visibility on modal close
2026-05-16 13:04:28 +00:00
dadaloop82 bd8dc0501a fix(kiosk): restore native settings gear — remove JS ⚙️ (opens wrong settings), restore visibility on modal close 2026-05-16 13:02:49 +00:00
github-actions[bot] c9a6f8ec42 chore: auto-merge develop → main
Triggered by: 0afdf60 fix(kiosk): settings gear lost when Kotlin pre-injects #_kiosk_overlay before JS runs
2026-05-16 12:59:52 +00:00
dadaloop82 0afdf60d38 fix(kiosk): settings gear lost when Kotlin pre-injects #_kiosk_overlay before JS runs 2026-05-16 12:58:10 +00:00
dadaloop82 6ab1da4bd5 ci(kiosk): trigger APK build — versionName 1.7.13 fix 2026-05-16 12:51:43 +00:00
dadaloop82 1566e32a85 ci(kiosk): trigger APK build for v1.7.13 (versionName fix) 2026-05-16 12:50:59 +00:00
github-actions[bot] fe7a047656 chore: auto-merge develop → main
Triggered by: 9c285b4 fix(tts): guard getVoices() against browser extension crash (Brave anti-fingerprinting, issue #61)
2026-05-16 12:48:12 +00:00
dadaloop82 9c285b426f fix(tts): guard getVoices() against browser extension crash (Brave anti-fingerprinting, issue #61) 2026-05-16 12:46:31 +00:00
github-actions[bot] c58705f35c chore: auto-merge develop → main
Triggered by: 8d87494 fix(kiosk): versionName 1.7.2→1.7.13, versionCode 13→14 (stops false update loop)
2026-05-16 12:44:27 +00:00
dadaloop82 8d874944b5 fix(kiosk): versionName 1.7.2→1.7.13, versionCode 13→14 (stops false update loop) 2026-05-16 12:42:46 +00:00
github-actions[bot] b6f85b8e29 chore: auto-merge develop → main
Triggered by: 68693e7 fix(expiry): sealed potatoes shelf life 14→30 days (aligns with JS)
2026-05-16 12:33:04 +00:00
dadaloop82 68693e7168 fix(expiry): sealed potatoes shelf life 14→30 days (aligns with JS) 2026-05-16 12:31:26 +00:00
github-actions[bot] 84c3bb6e4c chore: auto-merge develop → main
Triggered by: d8aec91 fix(cooking): extract tools from step text as fallback for old cached recipes
2026-05-16 10:02:40 +00:00
dadaloop82 d8aec91599 fix(cooking): extract tools from step text as fallback for old cached recipes 2026-05-16 10:01:05 +00:00
github-actions[bot] 11d3209482 chore: auto-merge develop → main
Triggered by: e19c256 feat(cooking): show required tools/appliances bar in cooking mode
2026-05-16 10:00:18 +00:00
dadaloop82 e19c2564f6 feat(cooking): show required tools/appliances bar in cooking mode 2026-05-16 09:58:39 +00:00
github-actions[bot] 6c0ae6627b chore: auto-merge develop → main
Triggered by: 8928c75 feat(recipes): add tools_needed field — appliances shown as chips above ingredients
2026-05-16 09:57:43 +00:00
dadaloop82 8928c75a9d feat(recipes): add tools_needed field — appliances shown as chips above ingredients 2026-05-16 09:56:10 +00:00
dadaloop82 b09b485e80 Merge branch 'main' of github-evershelf:dadaloop82/EverShelf 2026-05-16 09:36:15 +00:00
dadaloop82 9e9528054e merge: develop → main (v1.7.13 — cooking mode kiosk fix, potato shelf life, move-after-use preference) 2026-05-16 09:36:05 +00:00
github-actions[bot] 12cbcb1a29 chore: auto-merge develop → main
Triggered by: 9b9a196 fix(ux): skip move-after-use modal after 2 consistent choices; hide single-location picker
2026-05-16 09:34:22 +00:00
dadaloop82 9b9a196f73 fix(ux): skip move-after-use modal after 2 consistent choices; hide single-location picker 2026-05-16 09:32:46 +00:00
github-actions[bot] 9ce3fbcb9e chore: auto-merge develop → main
Triggered by: 3065b80 fix(expiry): potato shelf life 14→30 days in pantry; add explicit rules for onion/garlic/carrot
2026-05-16 09:26:41 +00:00
dadaloop82 3065b80370 fix(expiry): potato shelf life 14→30 days in pantry; add explicit rules for onion/garlic/carrot 2026-05-16 09:25:04 +00:00
github-actions[bot] 93acc58191 chore: auto-merge develop → main
Triggered by: d9f7755 fix(ux): hide kiosk overlay during cooking mode
2026-05-16 09:21:28 +00:00
dadaloop82 d9f775562f fix(ux): hide kiosk overlay during cooking mode 2026-05-16 09:19:51 +00:00
github-actions[bot] 85d957be2b chore: auto-merge develop → main
Triggered by: 7774fc4 docs: remove stale scale-gateway reference from README
2026-05-16 09:13:50 +00:00
dadaloop82 7774fc4cc8 docs: remove stale scale-gateway reference from README 2026-05-16 09:12:18 +00:00
dadaloop82 a0b0ed0cd7 Merge branch 'develop' 2026-05-16 09:11:31 +00:00
dadaloop82 1e831f05db ci: auto-create GitHub Release on main with version from index.html
After every develop→main merge, reads the version tag from index.html
(e.g. v1.7.13), checks if that tag already exists, and creates a new
GitHub Release if not. Body is pulled from CHANGELOG.md.

This powers the in-app update badge (`check_update` action) so
self-hosted Docker users see a notification when a new version is
available.
2026-05-16 09:10:41 +00:00
dadaloop82 855300cca1 Merge branch 'develop' 2026-05-16 08:47:06 +00:00
dadaloop82 141fca27cf docs: add Ko-fi sponsor button to README 2026-05-16 08:47:04 +00:00
dadaloop82 0ee540210a Merge branch 'develop' 2026-05-16 08:44:55 +00:00
dadaloop82 71c5b16d48 chore: fix Ko-fi username in FUNDING.yml (evershelfproject) 2026-05-16 08:44:53 +00:00
dadaloop82 5ed1fc9ac0 Merge branch 'develop' 2026-05-16 08:35:44 +00:00
dadaloop82 42149012a1 chore: remove deprecated scale-gateway app
The BLE scale gateway is fully integrated into the EverShelf Kiosk app
since v1.6.0. This standalone Android app is no longer needed or maintained.

Removal also resolves GitHub secret scanning alert #1 (legacy plain-text
GitHub PAT in ErrorReporter.kt — already revoked by GitHub automatically).
2026-05-16 08:35:36 +00:00
dadaloop82 c050ec9fa3 Merge branch 'develop' 2026-05-16 07:38:17 +00:00
dadaloop82 3cd439e068 fix(tts): filter null/undefined voices to handle Brave anti-fingerprinting
Brave browser's anti-fingerprinting user-script (makeFakeVoiceFromVoice)
intercepts the SpeechSynthesis voices array and crashes with
'undefined is not an object (evaluating Object.getPrototypeOf(voice))'
when iterating over null voice entries.

Defensive fix: filter null/undefined/no-lang entries from getVoices()
before processing, so Brave's proxy never receives invalid objects.

Fixes #58
2026-05-16 07:38:15 +00:00
dadaloop82 3430e56dfc Merge branch 'develop' 2026-05-16 07:30:36 +00:00
dadaloop82 e75b004ebc ci: trigger security scan also on security.yml changes 2026-05-16 07:30:34 +00:00
dadaloop82 f3b62ed3a1 Merge branch 'develop' 2026-05-16 07:28:33 +00:00
dadaloop82 ba5a52c5dc fix(ci): trivy-action version 0.31.0 → v0.36.0 (correct tag format) 2026-05-16 07:28:31 +00:00
dadaloop82 8366e0691d Merge branch 'develop' 2026-05-16 07:24:03 +00:00
dadaloop82 68906b2f28 fix: switch to php:8.2-apache-bookworm, add apt upgrade, Trivy ignore-unfixed
- Base image: php:8.2-apache → php:8.2-apache-bookworm (Debian 12)
  Reduces OS-level CVEs from ~1200+ to only fixable ones
- Add apt-get upgrade -y before package installs
- Trivy: add ignore-unfixed: true (suppress CVEs with no available fix)
- Pin trivy-action@0.31.0 instead of @master
- Upgrade codeql-action upload-sarif v3 → v4
2026-05-16 07:23:39 +00:00
dadaloop82 5f7d3e71ae merge: fix migration crash 'no such column: undone' (#56) 2026-05-16 07:16:03 +00:00
dadaloop82 6b982b6730 fix: migration crash 'no such column: undone' on old DBs
When migrateDB() upgraded the transactions table to add the 'waste'
CHECK constraint, the new table was created WITHOUT the 'undone' column.
The migration then tried to build idx_transactions_pid_type_undone, which
references 'undone' → PDOException SQLSTATE[HY000].

Fix:
- Add undone INTEGER DEFAULT 0 to the migration CREATE TABLE
- Replace 'INSERT INTO transactions SELECT * FROM transactions_old'
  with explicit column list (transactions_old may predate undone column)

Fixes: #56
2026-05-16 07:15:03 +00:00
dadaloop82 ef0c10ca6b merge: v1.7.14 — shelf life fix + README roadmap 2026-05-16 06:38:26 +00:00
dadaloop82 f121b8804c fix: jam/confiture opened shelf life in fridge 60→180 days
Both PHP and JS rules for opened confettura/marmellata in
section G (fridge condiments) were returning 60 days — too short.
An opened jar of jam lasts ~6 months in the fridge.

Also: update README roadmap with comprehensive, grouped view
matching the internal memory roadmap (high/medium/low/completed).

Fixes: database.php line ~412, app.js line ~1707
2026-05-16 06:38:18 +00:00
dadaloop82 bab6993e5b chore: merge develop into main (wiki English pass + v1.7.13 docs) 2026-05-16 06:33:03 +00:00
dadaloop82 80303f7900 docs(wiki): full English pass + update for v1.7.13 and built-in scale gateway
- Features.md: translate all Italian UI strings to English (chat examples,
  Avvia cottura → Start Cooking, Spiega → Explain, La quantità è giusta → correct)
- Android-Kiosk.md: translate Italian button labels (Concedi permessi →
  Grant permissions, Rileva automaticamente → Auto-discover); fix
  REQUEST_INSTALL_PACKAGES description (OTA kiosk self-updates, not scale APK);
  fix REORDER_TASKS description; add 'Header Overlay Buttons' section documenting
  the three web overlay buttons (✕ ↻ ⚙️) and the permanent native button hiding
- Scale-Gateway.md: translate Italian button labels (Cerca Bilance Bluetooth →
  Find Bluetooth Scales, Leggi bilancia → Read Scale, Disconnetti/Riconnetti →
  Disconnect/Reconnect)
- FAQ.md: translate all Italian strings (AI non disponibile → AI not available,
  Bring! non configurato, Leggi bilancia, Carica altri → Load more); replace
  outdated 'Gateway install fails' section (separate APK no longer exists for
  kiosk users) with 'Kiosk app update fails'; update ✕ button description to
  reflect the new 3-button overlay (✕ ↻ ⚙️); restore missing Getting Help section
- Home.md: update What's New v1.7.13 with complete list of changes; mark
  evershelf-scale-gateway/ as DEPRECATED in repo structure
2026-05-16 06:32:53 +00:00
dadaloop82 46ba537bec chore: merge develop into main (v1.7.13 + cooking wheel UI) 2026-05-16 06:14:03 +00:00
dadaloop82 e21b76ad7f feat(cooking): 3D wheel UI for recipe steps + cooking mode polish
- Replace flat .cooking-step-text with a perspective-based cooking wheel
  (.cooking-wheel) that shows current step, previous ghost (amber/warm)
  and next ghost (blue/cool) in a 3D card-flip layout
- CSS-only 3D: perspective 1100px, rotateX transforms for prev/next ghosts
- Smooth turn-next / turn-prev / snap animations via keyframes
- Float animation on the active step card (subtle translateY loop)
- Radial gradient glow overlay on the wheel container (CSS variable
  --wheel-glow) ready for JS tilt interaction
- prefers-reduced-motion: all animations/transitions disabled
- Mobile (<= 640px): smaller min-height and padding adjustments
- gitignore: add data/category_ai_cache.json (runtime AI cache)
2026-05-16 06:13:53 +00:00
github-actions[bot] 5f69967c7a chore: auto-merge develop → main
Triggered by: 24954cb fix: kiosk settings button position + opened-item expiry badge consistency
2026-05-16 06:11:35 +00:00
dadaloop82 24954cb893 fix: kiosk settings button position + opened-item expiry badge consistency
- kiosk: add gear button (⚙) to the left overlay (between ✕ and ↻)
  so settings are reachable from within kiosk mode without a native
  Android button. The web button calls showPage('settings').
- kiosk: permanently hide the native Android settings button via
  setNativeSettingsVisible(false) after overlay injection. Removes the
  touch bleed-through that caused the camera button tap to open kiosk
  settings instead of the scan page.
- kiosk: closeModal() no longer restores native settings visibility
  (native button is replaced, must stay hidden)
- dashboard opened-items panel: items expired by opened shelf-life but
  classified as safe by getExpiredSafety (level='ok', e.g. jam,
  condiments) now show a gentler amber 'Check soon' badge instead of
  the red  'Scaduto!' that was misleading users. Red  is now
  reserved for warning/danger safety levels only, consistent with the
  top banner which already filtered out safe-level expired items.
- header: version label corrected to v1.7.13
- translations: added expiry.badge_check_soon (it/en/de)
2026-05-16 06:10:01 +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
65 changed files with 1919 additions and 2805 deletions
+1
View File
@@ -0,0 +1 @@
ko_fi: evershelfproject
+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"
+8 -7
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: on:
push:
branches:
- main
- develop
paths:
- 'evershelf-scale-gateway/**'
workflow_dispatch: workflow_dispatch:
inputs:
confirm:
description: "Type 'yes' to confirm you want to build the deprecated gateway APK"
required: true
permissions: permissions:
contents: write contents: write
+60
View File
@@ -119,3 +119,63 @@ jobs:
Triggered by: $LAST" Triggered by: $LAST"
git push origin main git push origin main
# ── Auto-create GitHub Release on main ───────────────────────────────────
# Runs after auto-merge succeeds. Reads version from index.html,
# creates a release tag vX.Y.Z if it doesn't exist yet.
# This powers the in-app update badge for self-hosted users.
create-release:
name: Create GitHub Release
needs: [auto-merge-to-main]
if: github.ref == 'refs/heads/develop'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout main
uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Extract version from index.html
id: version
run: |
VER=$(grep -oP 'header-version">v\K[\d.]+' index.html | head -1)
echo "version=v${VER}" >> $GITHUB_OUTPUT
echo "Detected version: v${VER}"
- name: Check if tag already exists
id: tag_check
run: |
if git ls-remote --tags origin "refs/tags/${{ steps.version.outputs.version }}" | grep -q .; then
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Read CHANGELOG entry for this version
id: changelog
if: steps.tag_check.outputs.exists == 'false'
run: |
VER="${{ steps.version.outputs.version }}"
# Extract the section for this version from CHANGELOG.md
BODY=$(awk "/^## \[?${VER#v}\]?|^## ${VER}/,/^## [0-9]/" CHANGELOG.md | head -50 | tail -n +1 | grep -v "^## [0-9]" || true)
if [ -z "$BODY" ]; then
BODY="See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md) for details."
fi
# Multiline output
echo "body<<EOF" >> $GITHUB_OUTPUT
echo "$BODY" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Create release
if: steps.tag_check.outputs.exists == 'false'
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.version }}
name: "EverShelf ${{ steps.version.outputs.version }}"
body: ${{ steps.changelog.outputs.body }}
target_commitish: main
make_latest: true
+71
View File
@@ -0,0 +1,71 @@
name: Security Scan (Trivy)
on:
push:
branches: [main, develop]
paths:
- 'Dockerfile'
- 'docker-compose.yml'
- 'api/**'
- '.github/workflows/security.yml'
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@v0.36.0
with:
image-ref: 'evershelf:scan'
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
ignore-unfixed: true
exit-code: '0' # don't fail the build, just report
- name: Upload Trivy SARIF to GitHub Security tab
uses: github/codeql-action/upload-sarif@v4
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@v0.36.0
with:
scan-type: 'fs'
scan-ref: '.'
format: 'sarif'
output: 'trivy-fs-results.sarif'
severity: 'CRITICAL,HIGH'
ignore-unfixed: true
exit-code: '0'
- name: Upload Trivy FS SARIF
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: 'trivy-fs-results.sarif'
category: 'trivy-fs'
+1
View File
@@ -49,3 +49,4 @@ evershelf-kiosk/local.properties
data/error_reports.log data/error_reports.log
data/latest_release_cache.json data/latest_release_cache.json
data/food_facts_cache.json data/food_facts_cache.json
data/category_ai_cache.json
+69 -67
View File
@@ -5,105 +5,107 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] — Ideas & Roadmap
> Ideas collected during development. No priority or date implied.
- **Recipe scraps tips** — During cooking steps, detect "waste" generated (peels, cores, bones, eggshells, coffee grounds, citrus zest, etc.) and surface AI-powered tips on how to reuse them (compost, natural cleaner, broth, candied peel, etc.). Could be shown as an optional collapsible hint card below the step that generates the scrap.
## [1.7.14] - 2026-05-16
### Added
- **In-app bug report form** — "Segnala un problema" now opens a modal form instead of redirecting to GitHub. Users can select type (Bug / Feature / Question), write title and description, optionally add reproduction steps. A GitHub issue is created directly with labels and app metadata attached.
### Fixed
- **Kiosk settings button** — "Apri configurazione kiosk" in webapp settings was showing a toast asking to tap a gear icon that no longer exists. Now calls `openNativeSettings()` bridge directly (opens Android SettingsActivity). Fallback for old APKs shows a proper "update the kiosk app" hint.
- **False update badge** — `manifest.json` version was `1.7.12` while the app header showed `v1.7.13`, causing the server to report an older deployed version and triggering a spurious update notification.
- **Kiosk settings gear disappeared** — Race condition where Kotlin's `onPageFinished` injects `#_kiosk_overlay` before JS runs; JS found the element already present and returned early without ever restoring the native gear button. Fixed: JS no longer hides the native gear on load; `closeModal()` restores it with `setNativeSettingsVisible(true)`.
- **`openNativeSettings()` fragile typeof check** — Android `@JavascriptInterface` methods are not always detected as `'function'` by typeof; replaced with try/catch.
## [1.7.13] - 2026-05-16
### Fixed
- **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 ## [1.7.12] - 2026-05-13
### Fixed ### 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`. - **"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.
- **"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. - **"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.
- **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. - **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 ### 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 ## [1.7.11] - 2026-05-12
### Added ### Added
- **Scan page redesign** — La pagina di scansione è stata completamente ridisegnata per tablet e mobile: - **Scan page redesign** — The scanner page has been completely redesigned for tablet and mobile:
- **2× zoom fisso** — zoom hardware se disponibile, altrimenti CSS `scale(2)` automatico. - **2× fixed zoom** — hardware zoom if available, otherwise automatic CSS `scale(2)`.
- **Torcia** — bottone nel viewport con feedback toast e stato visivo. - **Torch** — in-viewport button with toast feedback and visual state indicator.
- **Flip fotocamera** — switch front/back con persistenza in settings. - **Camera flip** — front/back switch with persistence in settings.
- **3 tab input** — Barcode / Nome / AI per un accesso rapido a ciascuna modalità. - **3 input tabs** — Barcode / Name / AI for quick access to each scanning mode.
- **Prodotti recenti** — chip degli ultimi 6 prodotti scansionati (localStorage), con icona categoria. - **Recent products** — chips for the last 6 scanned products (localStorage), with category icon.
- **Live code overlay** — codice barcode rilevato parzialmente mostrato in sovrimpressione nel viewport. - **Live code overlay** — partially detected barcode shown as overlay in the viewport during partial scan.
- **Confirm overlay** — checkmark + nome prodotto per 900ms al riconoscimento avvenuto. - **Confirm overlay** — checkmark + product name displayed for 900 ms on successful recognition.
- **Angoli guida** — frame visivo per inquadrare il barcode. - **Guide corners** — visual alignment frame for barcode centering.
- **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. - **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`** — 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`. - **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 ### 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`. - **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.
- **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. - **"~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.
- **Menu a tendina suggerimenti sul campo Nome (scan)** — Rimosso `list="common-products"` dal campo di input, il datalist non viene più aperto su tablet. - **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 ## [1.7.10] - 2026-05-11
### Fixed ### 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. - **"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.
- **"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`). - **"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`).
- **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. - **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.
- **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`). - **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`).
- **`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. - **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` 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. - **`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 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. - **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.
- **Banner "consumo anomalo" su quasi tutti i prodotti** — Due fix: - **Anomaly banner firing on almost all products** — Two fixes:
1. `expected = 0` non genera più anomalia "more" (il modello pensa che dovresti aver finito, ma hai ricomprato). 1. `expected = 0` no longer generates a "more" anomaly (the model assumed you should have run out, but you restocked).
2. Soglia "more than expected" alzata al 400% (era 30%); "less than expected" rimane al 30%. 2. "More than expected" threshold raised to 400% (was 30%); "less than expected" threshold remains at 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. - **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 `scade il` in banner** — Stringa italiana hardcodata nel dettaglio del banner scaduti rimossa. - **Hardcoded Italian string `scade il` in banner** — Replaced with the correct i18n key.
- **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. - **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 ### 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. - **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.
### 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.
## [1.7.8] - 2026-05-10 ## [1.7.8] - 2026-05-10
### Added ### 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. - **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.
- **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. - **"Open recipe" button** — After a successful transfer, the "📥 Transfer to Recipes" button transforms into "📖 Open recipe" (same DOM element), preventing overlap.
- **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). - **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 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. - **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 ### 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.) - **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.
- **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. - **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 ## [1.7.7] - 2026-05-10
### Fixed ### 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. - **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 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...". - **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 ## [1.7.6] - 2026-05-10
### Fixed ### Fixed
- **`shopping_name` troncato (Piadina)** — Il prodotto "Piadine medie" aveva `shopping_name='Pi'` (troncato), non veniva aggruppato correttamente nella famiglia. Corretto in `Piadina`. - **`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 DB** — Grana Padano ora sotto `Formaggio` (era `Grana` singleton), Prosciutto cotto ora sotto `Affettato`, Panna acida ora sotto `Panna`. - **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` 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. - **`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.
- **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. - **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: 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. - **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()` ora esegue `DELETE` dei messaggi oltre i 200 più recenti dopo ogni salvataggio, evitando crescita illimitata della tabella `chat_messages`. - **Chat pruning** — `chatSave()` now deletes messages beyond the 200 most recent after each save, preventing unbounded growth of the `chat_messages` table.
- **`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.
### 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 ## [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.
+2 -2
View File
@@ -1,7 +1,7 @@
FROM php:8.2-apache FROM php:8.2-apache-bookworm
# Install required PHP extensions + Tesseract OCR for offline expiry date reading # Install required PHP extensions + Tesseract OCR for offline expiry date reading
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get upgrade -y && apt-get install -y \
libsqlite3-dev \ libsqlite3-dev \
libcurl4-openssl-dev \ libcurl4-openssl-dev \
libonig-dev \ libonig-dev \
+41 -83
View File
@@ -25,54 +25,17 @@
[![SQLite](https://img.shields.io/badge/SQLite-3-blue.svg)](https://www.sqlite.org/) [![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) [![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/) [![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)
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/J3J01ZNETZ)
--- ---
## 🌍 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 ## ✨ Features
### 📦 Inventory Management ### 📦 Inventory Management
@@ -141,7 +104,7 @@
- **Stability + auto-confirm** — 10s stable wait + 5s countdown before confirming - **Stability + auto-confirm** — 10s stable wait + 5s countdown before confirming
- **Real-time status** — Scale connection indicator always visible in the header - **Real-time status** — Scale connection indicator always visible in the header
- **Multi-protocol** — Supports Bluetooth SIG Weight Scale, Body Composition, Xiaomi Mi Scale 2 and 100+ models - **Multi-protocol** — Supports Bluetooth SIG Weight Scale, Body Composition, Xiaomi Mi Scale 2 and 100+ models
- **Built into kiosk (v1.6.0+)** — BLE gateway runs as an integrated foreground service inside the [EverShelf Kiosk](evershelf-kiosk/) app; no separate APK needed. The standalone gateway app in [`evershelf-scale-gateway/`](evershelf-scale-gateway/) is deprecated but kept for non-kiosk use cases. - **Built into kiosk (v1.6.0+)** — BLE gateway runs as an integrated foreground service inside the [EverShelf Kiosk](evershelf-kiosk/) app; no separate APK needed.
### 📺 Android Kiosk Mode (Add-on) ### 📺 Android Kiosk Mode (Add-on)
- **Dedicated tablet app** — Full-screen WebView wrapper for wall-mounted kitchen tablets - **Dedicated tablet app** — Full-screen WebView wrapper for wall-mounted kitchen tablets
@@ -389,35 +352,35 @@ The application uses no build tools — edit files directly and refresh.
## 📋 Roadmap ## 📋 Roadmap
- [x] Multi-language support (i18n) — 3 languages (it/en/de), 347 keys ### High Priority
- [ ] User authentication / multi-user support - [ ] **Cooking mode — 3D wheel JS** — swipe navigation, gyroscope tilt, haptic feedback
- [x] Docker container for easy deployment — see [Dockerfile](Dockerfile) + [docker-compose.yml](docker-compose.yml) - [ ] **Cooking mode — step timers** — auto-detect "X minutes" in recipe steps, countdown + alert
- [x] REST API documentation (OpenAPI/Swagger) — see [docs/openapi.yaml](docs/openapi.yaml) - [ ] **Push notifications** — daily expiry alerts via PWA Service Worker + VAPID
- [x] First-run setup wizard — 4-step guided configuration - [ ] **Quick search / quick-add bar** — always-visible search above the nav, PWA shortcuts
- [x] API rate limiting — file-based, 3 tiers (120/15/5 req/min)
- [x] CI/CD pipeline — GitHub Actions (lint, Docker build, translation validation) ### Medium Priority
- [x] Android kiosk mode — dedicated tablet app with screen pinning - [ ] **Receipt OCR → bulk add** — photo of receipt → Gemini Vision → auto-fill inventory
- [x] Anomaly detection banner — suspicious quantities + consumption predictions - [ ] **CSV/JSON export & import** — download/upload inventory from Settings
- [x] AI scan local matching — suggest existing pantry products before OFF lookup - [ ] **Custom storage locations** — user-defined locations beyond Fridge/Freezer/Pantry
- [x] Scale auto-fill improvements — 10g threshold, ml conversion hints - [ ] **Multi-user support** — PIN-based user distinction, action log with user label
- [x] Update notification system — inline header pill (webapp) + kiosk checks GitHub releases - [ ] **AI optimal purchase prediction** — suggest "buy X units of Y within Z days"
- [x] Kiosk OTA update — forced check button, `installUpdate()` bridge, graceful old-APK fallback - [ ] **Price history sparklines** — per-product price chart from the AI cache data
- [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 ### Low Priority / Nice to Have
- [x] Kiosk live scale diagnostics — device, battery, real-time weight in Settings when connected - [ ] **Dark mode** — CSS custom properties are already structured to support it
- [x] Nutrition analysis dashboard — category pie + health/variety/freshness scores, alternates with waste section - [ ] **Full offline mode** — Service Worker cache to show inventory read-only when server is down
- [x] Screensaver nutrition panel — animated pie + donut ring scores rotate with facts - [ ] **French & Spanish translations** (`fr.json`, `es.json`)
- [x] Automatic error reporting — JS/Android/PHP errors → GitHub Issues with deduplication - [ ] **Swipe actions on inventory rows** — swipe left to use/discard, right to edit
- [x] Generic shopping name grouping — compound-phrase + keyword map (100+ entries) + Gemini AI fallback - [ ] **PHP unit tests** — PHPUnit coverage for shelf-life, price calc, and key helpers
- [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 ### Completed ✅
- [x] AI product storage hint — background Gemini call suggests location + shelf-life in the add form - ✅ AI price estimation in shopping list
- [x] AI shopping tips enrichment — each suggestion enriched with a short practical tip - ✅ Server heartbeat + offline banner
- [x] AI anomaly explanation — "🤖 Spiega" button explains discrepancies in plain language - ✅ In-app bug reporter → automatic GitHub issue creation
- [x] Security hardening — no raw key exposure, SETTINGS_TOKEN auth, DEMO_MODE native blocking - ✅ Cooking mode (start, steps, 3D wheel CSS)
- [ ] Offline mode with service worker - ✅ Kiosk ⚙️ Settings overlay button (replaces Android native button)
- [ ] Export/import inventory data - ✅ Adaptive consumption anomaly detection
- [ ] Notification system (Telegram, email) for expiring products - ✅ CI/CD pipeline (PHP lint, JS lint, Docker build, Trivy security scan)
--- ---
@@ -464,11 +427,6 @@ This project is licensed under the **MIT License** — see the [LICENSE](LICENSE
## 📸 Screenshots ## 📸 Screenshots
| | | | For a live walkthrough with real data and full AI enabled, visit the **[live demo](https://evershelfproject.dadaloop.it/demo)** — no installation required.
|:---:|:---:|:---:|
| ![Dashboard](assets/img/screenshots/01_dashboard.jpg) | ![Inventory](assets/img/screenshots/02_inventory.jpg) | ![Barcode Scanner](assets/img/screenshots/03_barcode_scanner.jpg) | > Want to contribute a GIF or screenshots? See [CONTRIBUTING.md](CONTRIBUTING.md) — PRs welcome!
| **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). |
+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)
+15 -5
View File
@@ -95,6 +95,7 @@ function initializeDB(PDO $db): void {
quantity REAL NOT NULL, quantity REAL NOT NULL,
location TEXT NOT NULL DEFAULT 'dispensa', location TEXT NOT NULL DEFAULT 'dispensa',
notes TEXT DEFAULT '', notes TEXT DEFAULT '',
undone INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE 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(); $cols = $db->query("PRAGMA table_info(products)")->fetchAll();
$colNames = array_column($cols, 'name'); $colNames = array_column($cols, 'name');
if (!in_array('package_unit', $colNames)) { 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)) { 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 // Migrate transactions CHECK constraint to allow 'waste' type
@@ -135,11 +138,14 @@ function migrateDB(PDO $db): void {
quantity REAL NOT NULL, quantity REAL NOT NULL,
location TEXT NOT NULL DEFAULT 'dispensa', location TEXT NOT NULL DEFAULT 'dispensa',
notes TEXT DEFAULT '', notes TEXT DEFAULT '',
undone INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE
) )
"); ");
$db->exec("INSERT INTO transactions SELECT * FROM transactions_old"); // Insert with explicit columns: transactions_old may lack 'undone' (pre-v1.7.x DB)
$db->exec("INSERT INTO transactions (id, product_id, type, quantity, location, notes, created_at)
SELECT id, product_id, type, quantity, location, notes, created_at FROM transactions_old");
$db->exec("DROP TABLE transactions_old"); $db->exec("DROP TABLE transactions_old");
$db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_product ON transactions(product_id)"); $db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_product ON transactions(product_id)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(created_at)"); $db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(created_at)");
@@ -357,6 +363,10 @@ function estimateOpenedExpiryDaysPHP(string $name, string $category, string $loc
if (preg_match('/\b(yogurt|yaourt|yoghurt)\b/', $n)) return 2; if (preg_match('/\b(yogurt|yaourt|yoghurt)\b/', $n)) return 2;
if (preg_match('/\blatte\b/', $n)) return 1; if (preg_match('/\blatte\b/', $n)) return 1;
if (preg_match('/\bformaggio\b/', $n)) return 2; if (preg_match('/\bformaggio\b/', $n)) return 2;
// Root vegetables / tubers in pantry: sfusi in un sacchetto, durano 3-5 settimane
if (preg_match('/\b(patata|patate|tubero)\b/', $n)) return 30;
if (preg_match('/\b(cipolla|cipolle|aglio|scalogno|porro)\b/', $n)) return 30;
if (preg_match('/\b(carota|carote)\b/', $n)) return 14;
return 60; // generic pantry fallback return 60; // generic pantry fallback
} }
@@ -406,7 +416,7 @@ function estimateOpenedExpiryDaysPHP(string $name, string $category, string $loc
if (preg_match('/\b(senape|mustard)\b/', $n)) return 90; if (preg_match('/\b(senape|mustard)\b/', $n)) return 90;
if (preg_match('/salsa\s+di\s+soia|soy\s*sauce/', $n)) return 90; if (preg_match('/salsa\s+di\s+soia|soy\s*sauce/', $n)) return 90;
if (preg_match('/\b(tabasco|worcestershire|sriracha)\b/', $n)) return 180; if (preg_match('/\b(tabasco|worcestershire|sriracha)\b/', $n)) return 180;
if (preg_match('/confettura|marmellata/', $n)) return 60; if (preg_match('/confettura|marmellata/', $n)) return 180;
if (preg_match('/nutella|cioccolat/', $n)) return 60; if (preg_match('/nutella|cioccolat/', $n)) return 60;
// ── H: Category fallbacks ──────────────────────────────────────────── // ── H: Category fallbacks ────────────────────────────────────────────
@@ -464,7 +474,7 @@ function estimateSealedExpiryDaysPHP(string $name, string $category, string $loc
elseif (preg_match('/carota|carote|zucchina|zucchine|peperoni|melanzane/', $n)) $days = 7; elseif (preg_match('/carota|carote|zucchina|zucchine|peperoni|melanzane/', $n)) $days = 7;
elseif (preg_match('/broccoli|cavolfiore|cavolo|spinaci|bietola/', $n)) $days = 5; elseif (preg_match('/broccoli|cavolfiore|cavolo|spinaci|bietola/', $n)) $days = 5;
elseif (preg_match('/cipolla|cipolle/', $n)) $days = 10; elseif (preg_match('/cipolla|cipolle/', $n)) $days = 10;
elseif (preg_match('/patata|patate/', $n)) $days = 14; elseif (preg_match('/patata|patate/', $n)) $days = 30; // whole tubers in a bag, pantry: 3-5 weeks
elseif (preg_match('/biscott|cracker|grissini|fette\s+biscott/', $n)) $days = 180; elseif (preg_match('/biscott|cracker|grissini|fette\s+biscott/', $n)) $days = 180;
elseif (preg_match('/nutella|marmellata|miele/', $n)) $days = 365; elseif (preg_match('/nutella|marmellata|miele/', $n)) $days = 365;
elseif (preg_match('/passata|pelati|pomodor/', $n)) $days = 730; elseif (preg_match('/passata|pelati|pomodor/', $n)) $days = 730;
+122 -12
View File
@@ -438,6 +438,10 @@ try {
reportError(); reportError();
break; break;
case 'report_bug':
reportBugManual();
break;
case 'check_update': case 'check_update':
checkUpdate(); checkUpdate();
break; break;
@@ -2132,9 +2136,9 @@ function getConsumptionPredictions(PDO $db): void {
$daySpan = ($lastDate - $firstDate) / 86400; $daySpan = ($lastDate - $firstDate) / 86400;
// If all transactions are clustered within a week, the rate is unreliable // If all transactions are clustered within a week, the rate is unreliable
if ($daySpan < 7) continue; 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) // Get the most recent restock (last 'in' transaction)
$lastIn = $db->prepare(" $lastIn = $db->prepare("
@@ -2166,16 +2170,35 @@ function getConsumptionPredictions(PDO $db): void {
$baselineQty = floatval($item['quantity']) + $usedSinceRestock; $baselineQty = floatval($item['quantity']) + $usedSinceRestock;
$daysSinceRestock = max(1, (time() - $restockDate) / 86400); $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 // 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: // in this period, the daily rate is too low to make reliable predictions:
// any single normal use will look like an anomaly. Skip it. // any single normal use will look like an anomaly. Skip it.
$predictedConsumption = $dailyRate * $daysSinceRestock; $predictedConsumption = $dailyRate * $daysSinceRestock;
if ($baselineQty > 0 && $predictedConsumption < $baselineQty * 0.15) continue; 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)); $expectedQty = max(0, $baselineQty - ($dailyRate * $daysSinceRestock));
$actualQty = floatval($item['quantity']); $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 // Flag if deviation > 30% and absolute diff > meaningful threshold
$deviation = abs($actualQty - $expectedQty); $deviation = abs($actualQty - $expectedQty);
$threshold = max($dailyRate * 3, 0.5); // at least 3 days worth or 0.5 units $threshold = max($dailyRate * 3, 0.5); // at least 3 days worth or 0.5 units
@@ -2188,10 +2211,12 @@ function getConsumptionPredictions(PDO $db): void {
$pctDev = $expectedQty > 0 ? ($deviation / $expectedQty) : ($actualQty > 0 ? 1 : 0); $pctDev = $expectedQty > 0 ? ($deviation / $expectedQty) : ($actualQty > 0 ? 1 : 0);
// "more than expected" is almost always a restock the model doesn't know about yet. // "More than expected" usually means slower real consumption, not bad data.
// Only flag it at very high deviation (>400%) to catch truly impossible values. // Suppress this direction to avoid noisy/accusatory banners.
// "less than expected" is more actionable: user may have consumed without registering. if ($actualQty > $expectedQty) continue;
$flagThreshold = ($actualQty > $expectedQty) ? 4.0 : 0.30;
// Only keep meaningful "less than expected" deviations.
$flagThreshold = 0.45;
if ($pctDev > $flagThreshold && $deviation > $threshold) { if ($pctDev > $flagThreshold && $deviation > $threshold) {
$unit = $item['unit']; $unit = $item['unit'];
@@ -2220,7 +2245,7 @@ function getConsumptionPredictions(PDO $db): void {
'daily_rate' => round($dailyRate, 3), 'daily_rate' => round($dailyRate, 3),
'deviation_pct' => round($pctDev * 100), 'deviation_pct' => round($pctDev * 100),
'days_since_restock' => (int)round($daysSinceRestock), 'days_since_restock' => (int)round($daysSinceRestock),
'direction' => $actualQty > $expectedQty ? 'more' : 'less', 'direction' => 'less',
'tx_count' => count($rows), 'tx_count' => count($rows),
]; ];
} }
@@ -2999,6 +3024,7 @@ PROMPT;
'error_empty_reply' => 'Risposta vuota da Gemini', 'error_empty_reply' => 'Risposta vuota da Gemini',
'prompt_lang_rule' => 'IMPORTANTE: scrivi tutti i campi testuali della ricetta in Italiano.', 'prompt_lang_rule' => 'IMPORTANTE: scrivi tutti i campi testuali della ricetta in Italiano.',
'prompt_step_example' => 'Passo 1…', 'prompt_step_example' => 'Passo 1…',
'tools_title' => 'Strumenti necessari',
], ],
'en' => [ 'en' => [
'status_analyze_pantry' => '📦 Analyzing pantry...', 'status_analyze_pantry' => '📦 Analyzing pantry...',
@@ -3021,6 +3047,7 @@ PROMPT;
'error_empty_reply' => 'Empty response from Gemini', 'error_empty_reply' => 'Empty response from Gemini',
'prompt_lang_rule' => 'IMPORTANT: write all textual recipe fields in English only. Do not use Italian or German.', 'prompt_lang_rule' => 'IMPORTANT: write all textual recipe fields in English only. Do not use Italian or German.',
'prompt_step_example' => 'Step 1…', 'prompt_step_example' => 'Step 1…',
'tools_title' => 'Equipment needed',
], ],
'de' => [ 'de' => [
'status_analyze_pantry' => '📦 Vorrat wird analysiert...', 'status_analyze_pantry' => '📦 Vorrat wird analysiert...',
@@ -3043,6 +3070,7 @@ PROMPT;
'error_empty_reply' => 'Leere Antwort von Gemini', 'error_empty_reply' => 'Leere Antwort von Gemini',
'prompt_lang_rule' => 'WICHTIG: schreibe alle textuellen Rezeptfelder nur auf Deutsch. Verwende kein Italienisch oder Englisch.', 'prompt_lang_rule' => 'WICHTIG: schreibe alle textuellen Rezeptfelder nur auf Deutsch. Verwende kein Italienisch oder Englisch.',
'prompt_step_example' => 'Schritt 1…', 'prompt_step_example' => 'Schritt 1…',
'tools_title' => 'Benötigte Geräte',
], ],
]; ];
$text = $dict[$lang][$key] ?? $dict['it'][$key] ?? $key; $text = $dict[$lang][$key] ?? $dict['it'][$key] ?? $key;
@@ -3420,14 +3448,15 @@ REGOLE:
4. "qty_number": valore NUMERICO nella STESSA unità della dispensa (g/ml/pz/conf, MAI kg o litri). Per non-dispensa: 0. IMPORTANTE: per ingredienti con unità "pz" scrivi qty_number come numero di PEZZI (es. 2, non 200g). 4. "qty_number": valore NUMERICO nella STESSA unità della dispensa (g/ml/pz/conf, MAI kg o litri). Per non-dispensa: 0. IMPORTANTE: per ingredienti con unità "pz" scrivi qty_number come numero di PEZZI (es. 2, non 200g).
5. "name": usa ESATTAMENTE il nome dalla lista (il sistema lo usa per scalare l'inventario). 5. "name": usa ESATTAMENTE il nome dalla lista (il sistema lo usa per scalare l'inventario).
6. Includi nella lista ingredienti TUTTI quelli citati nei passi (tranne acqua/sale/pepe/olio). 6. Includi nella lista ingredienti TUTTI quelli citati nei passi (tranne acqua/sale/pepe/olio).
7. Language rule: {$recipeLangName} only for all textual fields (`title`, `tags`, `expiry_note`, `ingredients.qty`, `steps`, `nutrition_note`). Keep `meal` unchanged. 7. Language rule: {$recipeLangName} only for all textual fields (`title`, `tags`, `expiry_note`, `ingredients.qty`, `steps`, `nutrition_note`, `tools_needed`). Keep `meal` unchanged.
8. `tools_needed`: array of kitchen tools/appliances actually required by this recipe (e.g. ["Forno","Frullatore"]). Use the same language as all other text fields. Empty array [] if only stovetop/knife/pan needed.
DISPENSA: DISPENSA:
$ingredientsText $ingredientsText
Rispondi SOLO JSON valido (no markdown): Rispondi SOLO JSON valido (no markdown):
{$promptLanguageRule} {$promptLanguageRule}
{"title":"","meal":"$mealType","persons":$persons,"prep_time":"","cook_time":"","tags":[""],"expiry_note":"","ingredients":[{"name":"","qty":"200 g","qty_number":200,"from_pantry":true}],"steps":["{$promptStepExample}"],"nutrition_note":""} {"title":"","meal":"$mealType","persons":$persons,"prep_time":"","cook_time":"","tags":[""],"expiry_note":"","tools_needed":[""],"ingredients":[{"name":"","qty":"200 g","qty_number":200,"from_pantry":true}],"steps":["{$promptStepExample}"],"nutrition_note":""}
PROMPT; PROMPT;
$payload = [ $payload = [
@@ -4296,14 +4325,15 @@ REGOLE:
4. "qty_number": valore NUMERICO nella STESSA unità della dispensa (g/ml/pz/conf, MAI kg o litri). Per non-dispensa: 0. IMPORTANTE: per ingredienti con unità "pz" scrivi qty_number come numero di PEZZI (es. 2, non 200g). 4. "qty_number": valore NUMERICO nella STESSA unità della dispensa (g/ml/pz/conf, MAI kg o litri). Per non-dispensa: 0. IMPORTANTE: per ingredienti con unità "pz" scrivi qty_number come numero di PEZZI (es. 2, non 200g).
5. "name": usa ESATTAMENTE il nome dalla lista (il sistema lo usa per scalare l'inventario). 5. "name": usa ESATTAMENTE il nome dalla lista (il sistema lo usa per scalare l'inventario).
6. Includi nella lista ingredienti TUTTI quelli citati nei passi (tranne acqua/sale/pepe/olio). 6. Includi nella lista ingredienti TUTTI quelli citati nei passi (tranne acqua/sale/pepe/olio).
7. Language rule: {$recipeLangName} only for all textual fields (`title`, `tags`, `expiry_note`, `ingredients.qty`, `steps`, `nutrition_note`). Keep `meal` unchanged. 7. Language rule: {$recipeLangName} only for all textual fields (`title`, `tags`, `expiry_note`, `ingredients.qty`, `steps`, `nutrition_note`, `tools_needed`). Keep `meal` unchanged.
8. `tools_needed`: array of kitchen tools/appliances actually required by this recipe (e.g. ["Forno","Frullatore"]). Use the same language as all other text fields. Empty array [] if only stovetop/knife/pan needed.
DISPENSA: DISPENSA:
$ingredientsText $ingredientsText
Rispondi SOLO JSON valido (no markdown): Rispondi SOLO JSON valido (no markdown):
{$promptLanguageRule} {$promptLanguageRule}
{"title":"","meal":"$mealType","persons":$persons,"prep_time":"","cook_time":"","tags":[""],"expiry_note":"","ingredients":[{"name":"","qty":"200 g","qty_number":200,"from_pantry":true}],"steps":["{$promptStepExample}"],"nutrition_note":""} {"title":"","meal":"$mealType","persons":$persons,"prep_time":"","cook_time":"","tags":[""],"expiry_note":"","tools_needed":[""],"ingredients":[{"name":"","qty":"200 g","qty_number":200,"from_pantry":true}],"steps":["{$promptStepExample}"],"nutrition_note":""}
PROMPT; PROMPT;
$genConfig = [ $genConfig = [
@@ -6871,6 +6901,86 @@ function reportError(): void {
echo json_encode(['ok' => true]); echo json_encode(['ok' => true]);
} }
/**
* POST /api/?action=report_bug
*
* Manual bug/feature/question report submitted by the user via the in-app form.
* Creates a GitHub issue directly with the provided title and description.
*
* Expected JSON body:
* type string 'bug'|'feature'|'question'
* title string Issue title (required, max 150 chars)
* description string Main description (required, max 3000 chars)
* steps string? Steps to reproduce (optional, max 2000 chars)
* lang string? UI language the user is running
* url string? Page URL
* user_agent string? Navigator UA
* version string? App version
*/
function reportBugManual(): void {
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$allowedTypes = ['bug', 'feature', 'question'];
$type = in_array($input['type'] ?? '', $allowedTypes, true) ? $input['type'] : 'bug';
$title = substr(trim($input['title'] ?? ''), 0, 150);
$desc = substr(trim($input['description'] ?? ''), 0, 3000);
$steps = substr(trim($input['steps'] ?? ''), 0, 2000);
$ua = substr(trim($input['user_agent'] ?? ($_SERVER['HTTP_USER_AGENT'] ?? '')), 0, 300);
$url = substr(trim($input['url'] ?? ''), 0, 300);
$ver = substr(trim($input['version'] ?? ''), 0, 50);
$lang = preg_replace('/[^a-z\-]/', '', strtolower($input['lang'] ?? 'it'));
if (empty($title) || empty($desc)) {
echo json_encode(['ok' => false, 'error' => 'title and description required']);
return;
}
$token = _ghToken();
if (!$token) {
// No GitHub token configured — log locally and return ok so the UX is not broken
_appendErrorLog('pwa', 'manual_report', $title, $desc, $url, $ua, ['type' => $type, 'version' => $ver, 'lang' => $lang]);
echo json_encode(['ok' => true, 'issue' => null]);
return;
}
// Labels: always 'user-report' + type-specific label
$labelMap = [
'bug' => ['bug', 'user-report'],
'feature' => ['enhancement', 'user-report'],
'question' => ['question', 'user-report'],
];
$labels = $labelMap[$type];
$typeEmoji = ['bug' => '🐛', 'feature' => '💡', 'question' => '❓'][$type];
$ts = date('Y-m-d H:i:s T');
$body = "## {$typeEmoji} User Report\n\n";
$body .= "**Description:**\n{$desc}\n\n";
if ($steps) {
$body .= "**Steps to reproduce:**\n{$steps}\n\n";
}
$body .= "---\n";
$body .= "**Version:** `{$ver}` \n";
$body .= "**Language:** `{$lang}` \n";
if ($url) $body .= "**URL:** `{$url}` \n";
if ($ua) $body .= "**User-Agent:** `{$ua}` \n";
$body .= "**Reported at:** {$ts}\n\n";
$body .= "_This issue was submitted via the in-app bug report form._";
$res = _githubRequest($token, 'POST',
'https://api.github.com/repos/' . GH_REPO . '/issues',
['title' => $title, 'body' => $body, 'labels' => $labels]
);
$issueNum = $res['body']['number'] ?? null;
$issueUrl = $res['body']['html_url'] ?? null;
if ($issueNum) {
echo json_encode(['ok' => true, 'issue' => $issueNum, 'url' => $issueUrl]);
} else {
echo json_encode(['ok' => false, 'error' => 'github_api_error']);
}
}
/** /**
* Append to data/error_reports.log (local safety net, max 500 KB) * Append to data/error_reports.log (local safety net, max 500 KB)
*/ */
+247 -7
View File
@@ -3103,6 +3103,36 @@ body.server-offline .bottom-nav {
justify-content: center; justify-content: center;
} }
/* ── Bug report form ── */
.bug-type-pills {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.bug-type-pill {
flex: 1;
min-width: 80px;
padding: 7px 10px;
border: 1.5px solid #cbd5e1;
border-radius: 20px;
background: #fff;
color: #475569;
font-size: 0.88rem;
cursor: pointer;
transition: border-color 0.15s, background 0.15s, color 0.15s;
-webkit-tap-highlight-color: transparent;
}
.bug-type-pill.active {
border-color: var(--primary, #2d5016);
background: var(--primary, #2d5016);
color: #fff;
font-weight: 600;
}
.bug-type-pill:not(.active):hover {
border-color: var(--primary, #2d5016);
color: var(--primary, #2d5016);
}
.modal-detail { .modal-detail {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -3918,6 +3948,29 @@ body.server-offline .bottom-nav {
line-height: 1.5; line-height: 1.5;
} }
.recipe-tools-banner {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
background: #f0f4ff;
border: 1px solid #c7d2fe;
border-radius: var(--radius-sm);
padding: 8px 12px;
font-size: 0.85rem;
color: #3730a3;
margin-bottom: 12px;
line-height: 1.5;
}
.recipe-tool-chip {
background: #e0e7ff;
border-radius: 20px;
padding: 2px 10px;
font-size: 0.8rem;
color: #3730a3;
white-space: nowrap;
}
/* Recipe ingredient use buttons */ /* Recipe ingredient use buttons */
.recipe-ingredients { .recipe-ingredients {
list-style: none; list-style: none;
@@ -4294,14 +4347,153 @@ body.cooking-mode-active .app-header {
transform: scale(1.35); transform: scale(1.35);
} }
.cooking-step-text { .cooking-wheel {
font-size: clamp(1.4rem, 5vw, 2.2rem); position: relative;
line-height: 1.5; --wheel-tilt-x: 0deg;
font-weight: 500; --wheel-tilt-y: 0deg;
color: #fff; --wheel-glow: 0.45;
width: min(90vw, 680px);
max-width: 680px;
min-height: clamp(240px, 36vh, 340px);
display: flex;
align-items: center;
justify-content: center;
perspective: 1100px;
overflow: hidden;
border-radius: 24px;
background: radial-gradient(circle at 50% 50%, rgba(255,255,255,0.07), rgba(255,255,255,0.02) 58%, transparent 100%);
border: 1px solid rgba(255,255,255,0.08);
box-shadow: inset 0 0 48px rgba(0,0,0,0.35);
touch-action: pan-y;
}
.cooking-wheel::before,
.cooking-wheel::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
}
.cooking-wheel::before {
background: radial-gradient(circle at 50% 50%, rgba(56, 189, 248, 0.18), rgba(251, 191, 36, 0.08) 36%, transparent 72%);
opacity: var(--wheel-glow);
transition: opacity 0.2s ease;
}
.cooking-wheel::after {
background: linear-gradient(180deg, rgba(0,0,0,0.34) 0%, transparent 20%, transparent 80%, rgba(0,0,0,0.34) 100%);
}
.cooking-step-text,
.cooking-step-ghost {
width: min(88vw, 620px);
text-align: center; text-align: center;
max-width: 560px; line-height: 1.5;
width: 100%; padding: 18px 20px;
border-radius: 20px;
transform-style: preserve-3d;
transition: transform 0.22s ease, opacity 0.22s ease;
}
.cooking-step-text {
position: relative;
z-index: 3;
font-size: clamp(1.35rem, 4.6vw, 2.1rem);
font-weight: 600;
color: #fff;
background: linear-gradient(180deg, rgba(255,255,255,0.14), rgba(255,255,255,0.06));
border: 1px solid rgba(255,255,255,0.20);
box-shadow: 0 18px 35px rgba(0,0,0,0.45), inset 0 1px 0 rgba(255,255,255,0.24);
transform: rotateX(var(--wheel-tilt-x)) rotateY(var(--wheel-tilt-y));
animation: cookingCardFloat 4.2s ease-in-out infinite;
}
.cooking-step-ghost {
position: absolute;
left: 50%;
top: 0;
transform-origin: center center;
font-size: clamp(1.08rem, 3.9vw, 1.52rem);
font-weight: 560;
color: rgba(255,255,255,0.95);
background: linear-gradient(180deg, rgba(255,255,255,0.18), rgba(255,255,255,0.08));
border: 1px solid rgba(255,255,255,0.22);
box-shadow: 0 12px 28px rgba(0,0,0,0.38), inset 0 1px 0 rgba(255,255,255,0.22);
text-shadow: 0 1px 2px rgba(0,0,0,0.34);
opacity: 0.66;
max-height: 42%;
overflow: hidden;
text-overflow: ellipsis;
}
.cooking-step-prev {
color: rgba(255, 243, 210, 0.97);
background: linear-gradient(180deg, rgba(251, 191, 36, 0.24), rgba(251, 191, 36, 0.10));
border-color: rgba(251, 191, 36, 0.36);
transform: translateX(-50%) rotateX(56deg) rotateY(calc(var(--wheel-tilt-y) * 0.28)) scale(0.9);
}
.cooking-step-next {
color: rgba(220, 243, 255, 0.97);
background: linear-gradient(180deg, rgba(56, 189, 248, 0.24), rgba(56, 189, 248, 0.10));
border-color: rgba(56, 189, 248, 0.36);
transform: translateX(-50%) rotateX(-56deg) rotateY(calc(var(--wheel-tilt-y) * 0.28)) scale(0.9);
}
.cooking-step-ghost.is-empty {
opacity: 0;
}
.cooking-wheel.turn-next .cooking-step-text {
animation: cookingWheelCenterNext 0.34s ease;
}
.cooking-wheel.turn-prev .cooking-step-text {
animation: cookingWheelCenterPrev 0.34s ease;
}
.cooking-wheel.turn-next .cooking-step-prev {
animation: cookingWheelGhostNext 0.34s ease;
}
.cooking-wheel.turn-prev .cooking-step-next {
animation: cookingWheelGhostPrev 0.34s ease;
}
.cooking-wheel.snap .cooking-step-text {
animation: cookingWheelSnap 0.28s ease;
}
@keyframes cookingWheelCenterNext {
from { transform: translateY(20px) rotateX(-10deg); opacity: 0.75; }
to { transform: translateY(0) rotateX(0); opacity: 1; }
}
@keyframes cookingWheelCenterPrev {
from { transform: translateY(-20px) rotateX(10deg); opacity: 0.75; }
to { transform: translateY(0) rotateX(0); opacity: 1; }
}
@keyframes cookingWheelGhostNext {
from { opacity: 0.1; transform: translateX(-50%) rotateX(68deg) scale(0.84); }
to { opacity: 0.66; transform: translateX(-50%) rotateX(56deg) scale(0.9); }
}
@keyframes cookingWheelGhostPrev {
from { opacity: 0.1; transform: translateX(-50%) rotateX(-68deg) scale(0.84); }
to { opacity: 0.66; transform: translateX(-50%) rotateX(-56deg) scale(0.9); }
}
@keyframes cookingCardFloat {
0%, 100% { transform: rotateX(var(--wheel-tilt-x)) rotateY(var(--wheel-tilt-y)) translateY(0); }
50% { transform: rotateX(var(--wheel-tilt-x)) rotateY(var(--wheel-tilt-y)) translateY(-3px); }
}
@keyframes cookingWheelSnap {
0% { transform: rotateX(var(--wheel-tilt-x)) rotateY(var(--wheel-tilt-y)) scale(0.97); }
70% { transform: rotateX(var(--wheel-tilt-x)) rotateY(var(--wheel-tilt-y)) scale(1.018); }
100% { transform: rotateX(var(--wheel-tilt-x)) rotateY(var(--wheel-tilt-y)) scale(1); }
} }
.cooking-replay-btn { .cooking-replay-btn {
@@ -4341,6 +4533,28 @@ body.cooking-mode-active .app-header {
} }
.cooking-timers-bar::-webkit-scrollbar { display: none; } .cooking-timers-bar::-webkit-scrollbar { display: none; }
.cooking-tools-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 7px 14px;
background: rgba(99,102,241,0.15);
border-bottom: 1px solid rgba(99,102,241,0.25);
flex-shrink: 0;
font-size: 0.78rem;
color: #c7d2fe;
}
.cooking-tool-chip {
background: rgba(99,102,241,0.25);
border: 1px solid rgba(99,102,241,0.4);
border-radius: 20px;
padding: 2px 9px;
font-size: 0.76rem;
color: #e0e7ff;
white-space: nowrap;
}
.cooking-timer-card { .cooking-timer-card {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -4576,6 +4790,32 @@ body.cooking-mode-active .app-header {
background: rgba(255,255,255,0.2); background: rgba(255,255,255,0.2);
} }
@media (max-width: 640px) {
.cooking-wheel {
min-height: clamp(210px, 34vh, 300px);
border-radius: 18px;
}
.cooking-step-text,
.cooking-step-ghost {
padding: 14px 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.cooking-step-text,
.cooking-step-ghost,
.cooking-wheel.turn-next .cooking-step-text,
.cooking-wheel.turn-prev .cooking-step-text,
.cooking-wheel.turn-next .cooking-step-prev,
.cooking-wheel.turn-prev .cooking-step-next,
.cooking-wheel.snap .cooking-step-text {
animation: none !important;
transition: none !important;
}
}
/* Cooking button in recipe dialog */ /* Cooking button in recipe dialog */
.btn-cooking { .btn-cooking {
background: linear-gradient(135deg, #1e3a5f, #2d5016); background: linear-gradient(135deg, #1e3a5f, #2d5016);
+693 -49
View File
@@ -476,14 +476,14 @@ function _scaleAutoFillUse(msg) {
else if (srcUnit === 'ml') { grams = rawVal; scaleAlreadyMl = true; } else if (srcUnit === 'ml') { grams = rawVal; scaleAlreadyMl = true; }
else grams = rawVal; else grams = rawVal;
// Reject if raw grams < 2 (tara / rumore) // Reject if raw grams < 10 (piatto vuoto / tara / rumore)
if (grams < 2) { if (grams < 10) {
_cancelScaleStabilityWait(); // stop bar only; keep sentinel & userDismissed _cancelScaleStabilityWait(); // stop bar only; keep sentinel & userDismissed
return; return;
} }
// Reject if weight hasn't changed enough from last confirmed reading (same product still on scale) // Reject if weight hasn't changed enough from last confirmed reading (same product still on scale)
if (_scaleLastConfirmedGrams !== null && Math.abs(grams - _scaleLastConfirmedGrams) < 2) { if (_scaleLastConfirmedGrams !== null && Math.abs(grams - _scaleLastConfirmedGrams) < 10) {
return; return;
} }
@@ -508,8 +508,8 @@ function _scaleAutoFillUse(msg) {
} }
} }
// Reject if converted value < 2 (density edge case) // Reject if converted value < 10 (density edge case)
if (val < 2) { if (val < 10) {
_scaleUserDismissed = false; _scaleUserDismissed = false;
_cancelScaleTimersOnly(); _cancelScaleTimersOnly();
_startScaleStabilityWait(() => { _startScaleStabilityWait(() => {
@@ -612,15 +612,15 @@ function _scaleAutoFillRecipeUse(msg) {
hint.style.display = ''; hint.style.display = '';
} }
if (val < 2) { if (val < 10) {
_cancelScaleStabilityWait(); // stop bar only; keep sentinel _cancelScaleStabilityWait(); // stop bar only; keep sentinel
if (livLabel) livLabel.textContent = t('scale.weight_too_low'); if (livLabel) livLabel.textContent = t('scale.weight_too_low');
return; return;
} }
// Reject if weight hasn't changed enough from last confirmed reading. // Reject if weight hasn't changed enough from last confirmed reading.
// Threshold: 2g — noise filter. // Threshold: 5g — gives enough time to tare after opening the modal.
if (_scaleLastConfirmedGrams !== null && Math.abs(grams - _scaleLastConfirmedGrams) < 2) { if (_scaleLastConfirmedGrams !== null && Math.abs(grams - _scaleLastConfirmedGrams) < 5) {
return; return;
} }
@@ -1553,7 +1553,7 @@ function estimateExpiryDays(product, location) {
else if (/carota|carote|zucchina|zucchine|peperoni|melanzane/.test(name)) days = 7; else if (/carota|carote|zucchina|zucchine|peperoni|melanzane/.test(name)) days = 7;
else if (/broccoli|cavolfiore|cavolo|spinaci|bietola/.test(name)) days = 5; else if (/broccoli|cavolfiore|cavolo|spinaci|bietola/.test(name)) days = 5;
else if (/cipolla|cipolle/.test(name)) days = 10; else if (/cipolla|cipolle/.test(name)) days = 10;
else if (/patata|patate/.test(name)) days = 14; else if (/patata|patate/.test(name)) days = 30; // whole tubers in a bag, pantry: 3-5 weeks
else if (/biscott|cracker|grissini|fette\s+biscott/.test(name)) days = 180; else if (/biscott|cracker|grissini|fette\s+biscott/.test(name)) days = 180;
else if (/nutella|marmellata|miele/.test(name)) days = 365; else if (/nutella|marmellata|miele/.test(name)) days = 365;
else if (/passata|pelati|pomodor/.test(name)) days = 730; else if (/passata|pelati|pomodor/.test(name)) days = 730;
@@ -1573,7 +1573,7 @@ function estimateExpiryDays(product, location) {
else if (/arancia|arance|agrumi|mandarini|limone|limoni/.test(name)) days = Math.max(days, 21); else if (/arancia|arance|agrumi|mandarini|limone|limoni/.test(name)) days = Math.max(days, 21);
else if (/carota|carote/.test(name)) days = Math.max(days, 21); else if (/carota|carote/.test(name)) days = Math.max(days, 21);
else if (/cipolla/.test(name)) days = Math.max(days, 14); else if (/cipolla/.test(name)) days = Math.max(days, 14);
else if (/patata|patate/.test(name)) days = Math.max(days, 21); else if (/patata|patate/.test(name)) days = Math.max(days, 30);
else if (/pera|pere/.test(name)) days = Math.max(days, 21); else if (/pera|pere/.test(name)) days = Math.max(days, 21);
else if (/kiwi/.test(name)) days = Math.max(days, 28); else if (/kiwi/.test(name)) days = Math.max(days, 28);
else if (/uva/.test(name)) days = Math.max(days, 14); else if (/uva/.test(name)) days = Math.max(days, 14);
@@ -1704,7 +1704,7 @@ function estimateOpenedExpiryDays(product, location) {
if (/\b(senape|mustard)\b/.test(name)) return 90; if (/\b(senape|mustard)\b/.test(name)) return 90;
if (/salsa\s+di\s+soia|soy\s*sauce/.test(name)) return 90; if (/salsa\s+di\s+soia|soy\s*sauce/.test(name)) return 90;
if (/\b(tabasco|worcestershire|sriracha)\b/.test(name)) return 180; if (/\b(tabasco|worcestershire|sriracha)\b/.test(name)) return 180;
if (/confettura|marmellata/.test(name)) return 60; if (/confettura|marmellata/.test(name)) return 180;
if (/nutella|cioccolat/.test(name)) return 60; if (/nutella|cioccolat/.test(name)) return 60;
// ── H: Category fallbacks ──────────────────────────────────────────── // ── H: Category fallbacks ────────────────────────────────────────────
@@ -2079,6 +2079,148 @@ 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.
*/
function reportBugManual() {
const mc = document.getElementById('modal-content');
if (!mc) return;
mc.innerHTML = `
<div class="modal-header">
<h3>${t('about.report_bug_modal_title')}</h3>
<button class="modal-close" onclick="closeModal()"></button>
</div>
<div style="padding:16px 20px 20px">
<div style="margin-bottom:14px">
<div class="bug-type-pills">
<button type="button" class="bug-type-pill active" data-btype="bug">🐛 ${t('about.report_type_bug')}</button>
<button type="button" class="bug-type-pill" data-btype="feature">💡 ${t('about.report_type_feature')}</button>
<button type="button" class="bug-type-pill" data-btype="question"> ${t('about.report_type_question')}</button>
</div>
</div>
<div style="margin-bottom:12px">
<label class="settings-label" style="display:block;margin-bottom:4px">${t('about.report_field_title')} *</label>
<input type="text" id="bug-form-title" maxlength="150" autocomplete="off"
placeholder="${t('about.report_field_title_ph')}"
style="width:100%;box-sizing:border-box;padding:9px 11px;border:1.5px solid #cbd5e1;border-radius:8px;font-size:0.95rem;background:#fff;color:#1e293b;outline:none">
</div>
<div style="margin-bottom:12px">
<label class="settings-label" style="display:block;margin-bottom:4px">${t('about.report_field_desc')} *</label>
<textarea id="bug-form-desc" rows="4" maxlength="3000"
placeholder="${t('about.report_field_desc_ph')}"
style="width:100%;box-sizing:border-box;padding:9px 11px;border:1.5px solid #cbd5e1;border-radius:8px;font-size:0.95rem;resize:vertical;background:#fff;color:#1e293b;outline:none;font-family:inherit"></textarea>
</div>
<div id="bug-form-steps-group" style="margin-bottom:12px">
<label class="settings-label" style="display:block;margin-bottom:4px">${t('about.report_field_steps')}</label>
<textarea id="bug-form-steps" rows="3" maxlength="2000"
placeholder="${t('about.report_field_steps_ph')}"
style="width:100%;box-sizing:border-box;padding:9px 11px;border:1.5px solid #cbd5e1;border-radius:8px;font-size:0.95rem;resize:vertical;background:#fff;color:#1e293b;outline:none;font-family:inherit"></textarea>
</div>
<p class="settings-hint" style="margin:0 0 14px;font-size:0.78rem">
${t('about.report_auto_info').replace('{version}', _loadedVersion || '—').replace('{lang}', _currentLang || '—')}
</p>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button type="button" class="btn btn-secondary" onclick="closeModal()">${t('common.cancel') || 'Annulla'}</button>
<button type="button" class="btn btn-primary" id="bug-form-submit" onclick="_submitBugReport()">
${t('about.report_send_btn')}
</button>
</div>
<div id="bug-form-status" style="display:none;margin-top:10px;text-align:center;font-size:0.88rem;padding:8px;border-radius:6px"></div>
</div>
`;
document.getElementById('modal-overlay').style.display = 'flex';
// Pill click: switch type, show/hide steps field
mc.querySelectorAll('.bug-type-pill').forEach(pill => {
pill.addEventListener('click', () => {
mc.querySelectorAll('.bug-type-pill').forEach(p => p.classList.remove('active'));
pill.classList.add('active');
const stepsGroup = document.getElementById('bug-form-steps-group');
if (stepsGroup) stepsGroup.style.display = (pill.dataset.btype === 'bug') ? '' : 'none';
});
});
}
async function _submitBugReport() {
const submitBtn = document.getElementById('bug-form-submit');
const statusEl = document.getElementById('bug-form-status');
const titleEl = document.getElementById('bug-form-title');
const descEl = document.getElementById('bug-form-desc');
const stepsEl = document.getElementById('bug-form-steps');
const activePill = document.querySelector('#modal-content .bug-type-pill.active');
const title = titleEl?.value.trim() || '';
const desc = descEl?.value.trim() || '';
const steps = stepsEl?.value.trim() || '';
const type = activePill?.dataset.btype || 'bug';
// Inline validation
if (!title) {
titleEl.style.borderColor = '#dc2626';
titleEl.focus();
return;
}
if (!desc) {
descEl.style.borderColor = '#dc2626';
descEl.focus();
return;
}
submitBtn.disabled = true;
statusEl.style.display = '';
statusEl.style.background = '#f1f5f9';
statusEl.style.color = '#64748b';
statusEl.textContent = t('about.report_bug_sending');
try {
const res = await api('report_bug', null, 'POST', {
type,
title,
description: desc,
steps,
user_agent: navigator.userAgent,
url: location.href,
version: _loadedVersion || '',
lang: _currentLang || 'it',
});
if (res.ok) {
statusEl.style.background = '#dcfce7';
statusEl.style.color = '#15803d';
const issueRef = res.issue ? ` (#${res.issue})` : '';
statusEl.textContent = t('about.report_bug_sent') + issueRef;
submitBtn.style.display = 'none';
setTimeout(() => closeModal(), 3500);
} else {
throw new Error(res.error || 'error');
}
} catch(e) {
statusEl.style.background = '#fee2e2';
statusEl.style.color = '#dc2626';
statusEl.textContent = t('about.report_bug_error');
submitBtn.disabled = false;
}
}
async function loadSettingsUI() { async function loadSettingsUI() {
const s = getSettings(); const s = getSettings();
document.getElementById('setting-gemini-key').value = s.gemini_key || ''; document.getElementById('setting-gemini-key').value = s.gemini_key || '';
@@ -2268,7 +2410,13 @@ async function loadSettingsUI() {
// Show kiosk self-update panel // Show kiosk self-update panel
const updatePanel = document.getElementById('kiosk-update-panel'); const updatePanel = document.getElementById('kiosk-update-panel');
if (updatePanel) updatePanel.style.display = ''; if (updatePanel) updatePanel.style.display = '';
// Show kiosk native settings shortcut panel
const nativePanel = document.getElementById('kiosk-native-settings-panel');
if (nativePanel) nativePanel.style.display = '';
} }
// Populate About section version
_loadAboutSection();
} }
// ── Kiosk: trigger native BLE scale reconfiguration wizard ──────────── // ── Kiosk: trigger native BLE scale reconfiguration wizard ────────────
@@ -2284,6 +2432,19 @@ function _kioskReconfigureScale() {
} }
} }
// ── Kiosk: open native SettingsActivity (server URL, BLE, screensaver) ──
function _openKioskNativeSettings() {
if (typeof _kioskBridge === 'undefined') return;
// Use try/catch directly: Android @JavascriptInterface methods are not always
// detected as 'function' by typeof, so we just call and catch if unavailable.
try {
_kioskBridge.openNativeSettings();
} catch(e) {
// Older APK without openNativeSettings bridge — inform user to update
showToast(t('settings.kiosk.native_update_hint') || 'Aggiorna l\'app kiosk per usare questa funzione', 'warning', 4000);
}
}
// ── Kiosk: manual update check ──────────────────────────────────────── // ── Kiosk: manual update check ────────────────────────────────────────
let _kioskPendingApkUrl = ''; let _kioskPendingApkUrl = '';
@@ -2394,6 +2555,10 @@ function _injectKioskOverlay() {
const appHeader = document.querySelector('.app-header'); const appHeader = document.querySelector('.app-header');
if (appHeader) appHeader.classList.add('kiosk-mode'); if (appHeader) appHeader.classList.add('kiosk-mode');
const btnStyle = 'background:rgba(255,255,255,0.2);border:none;color:#fff;width:34px;height:34px;border-radius:50%;font-size:15px;cursor:pointer;display:flex;align-items:center;justify-content:center;-webkit-tap-highlight-color:transparent;touch-action:manipulation;';
// If the Kotlin onPageFinished already injected #_kiosk_overlay (with only ✕ and ↻),
// nothing more to do — the native Android btnSettings (top-right) opens SettingsActivity.
if (document.getElementById('_kiosk_overlay')) return; if (document.getElementById('_kiosk_overlay')) return;
const headerLeft = document.getElementById('header-left'); const headerLeft = document.getElementById('header-left');
@@ -2403,8 +2568,6 @@ function _injectKioskOverlay() {
wrap.id = '_kiosk_overlay'; wrap.id = '_kiosk_overlay';
wrap.style.cssText = 'display:flex;gap:6px;align-items:center;'; wrap.style.cssText = 'display:flex;gap:6px;align-items:center;';
const btnStyle = 'background:rgba(255,255,255,0.2);border:none;color:#fff;width:34px;height:34px;border-radius:50%;font-size:15px;cursor:pointer;display:flex;align-items:center;justify-content:center;-webkit-tap-highlight-color:transparent;touch-action:manipulation;';
// Exit button // Exit button
const exitBtn = document.createElement('button'); const exitBtn = document.createElement('button');
exitBtn.id = '_kiosk_exit_btn'; exitBtn.id = '_kiosk_exit_btn';
@@ -2427,6 +2590,10 @@ function _injectKioskOverlay() {
_kioskBridge.hardReload(); _kioskBridge.hardReload();
}); });
// NOTE: No ⚙️ button here — the native Android settings button (top-right, injected by
// Kotlin) opens SettingsActivity (server URL, BLE scale, screensaver). Do NOT call
// setNativeSettingsVisible(false) — that would hide the only way to reconfigure the kiosk.
wrap.appendChild(exitBtn); wrap.appendChild(exitBtn);
wrap.appendChild(refBtn); wrap.appendChild(refBtn);
headerLeft.appendChild(wrap); headerLeft.appendChild(wrap);
@@ -3532,8 +3699,16 @@ async function loadDashboard() {
if (days !== null && days !== undefined) { if (days !== null && days !== undefined) {
let expiryClass, expiryText; let expiryClass, expiryText;
if (!isEdible) { if (!isEdible) {
expiryClass = 'opened-expiry-spoiled'; // Only show the red ⛔ badge for items that are genuinely dangerous.
expiryText = t('expiry.badge_expired'); // For conserve/condiments classified as safe, use a gentler amber badge.
const spoiledSafety = getExpiredSafety(item, Math.abs(item.days_to_expiry ?? 1));
if (spoiledSafety.level === 'ok') {
expiryClass = 'opened-expiry-soon';
expiryText = '\u26A0\uFE0F ' + t('expiry.badge_check_soon');
} else {
expiryClass = 'opened-expiry-spoiled';
expiryText = t('expiry.badge_expired');
}
} else if (days > 365) { } else if (days > 365) {
expiryClass = 'opened-expiry-ok'; expiryClass = 'opened-expiry-ok';
expiryText = t('expiry.badge_stable'); expiryText = t('expiry.badge_stable');
@@ -4815,7 +4990,7 @@ function showItemDetail(inventoryId, productId) {
function closeModal() { function closeModal() {
document.getElementById('modal-overlay').style.display = 'none'; document.getElementById('modal-overlay').style.display = 'none';
clearMoveModalTimer(); clearMoveModalTimer();
// Restore native kiosk settings button visibility // Restore the native kiosk settings button when the modal closes.
try { if (typeof _kioskBridge !== 'undefined') _kioskBridge.setNativeSettingsVisible(true); } catch (_) {} try { if (typeof _kioskBridge !== 'undefined') _kioskBridge.setNativeSettingsVisible(true); } catch (_) {}
_cancelScaleAutoConfirm(false); _cancelScaleAutoConfirm(false);
_scaleRecipeAutoFillPaused = false; _scaleRecipeAutoFillPaused = false;
@@ -7531,6 +7706,9 @@ async function loadUseInventoryInfo() {
// Build location buttons only for locations where the product exists // Build location buttons only for locations where the product exists
const productLocations = [...new Set(items.map(i => i.location))]; const productLocations = [...new Set(items.map(i => i.location))];
const locSelector = document.getElementById('use-location-selector'); const locSelector = document.getElementById('use-location-selector');
// Hide the location row when the product is in only one location (nothing to choose)
const locGroup = document.getElementById('use-location-group');
if (locGroup) locGroup.style.display = productLocations.length > 1 ? '' : 'none';
// Prefer the remembered location (if confirmed), else use the opened-package heuristic // Prefer the remembered location (if confirmed), else use the opened-package heuristic
const prefLoc = _getPreferredUseLocation(currentProduct.id); const prefLoc = _getPreferredUseLocation(currentProduct.id);
@@ -7729,6 +7907,42 @@ function selectUseLocation(btn, loc) {
const _PREF_LOC_KEY = '_prefUseLoc'; const _PREF_LOC_KEY = '_prefUseLoc';
const _PREF_LOC_NEEDED = 2; // choices needed to confirm a preference const _PREF_LOC_NEEDED = 2; // choices needed to confirm a preference
// ── PREFERRED MOVE-AFTER-USE LOCATION ────────────────────────────────────
// Tracks where the user puts the remainder after using a product.
// After _PREF_MOVE_NEEDED consistent choices, the modal is skipped entirely.
const _PREF_MOVE_KEY = '_prefMoveLoc';
const _PREF_MOVE_NEEDED = 2;
let _pendingMoveCtx = null; // { productId, fromLoc, openedId } — set before showing modal
function _getMoveLocHistory(productId, fromLoc) {
try {
const all = JSON.parse(localStorage.getItem(_PREF_MOVE_KEY) || '{}');
return all[`${productId}|${fromLoc}`] || [];
} catch { return []; }
}
function _recordMoveLocChoice(productId, fromLoc, toLoc) {
try {
const all = JSON.parse(localStorage.getItem(_PREF_MOVE_KEY) || '{}');
const key = `${productId}|${fromLoc}`;
const hist = all[key] || [];
hist.push(toLoc);
if (hist.length > 8) hist.splice(0, hist.length - 8);
all[key] = hist;
localStorage.setItem(_PREF_MOVE_KEY, JSON.stringify(all));
} catch { }
}
function _getPreferredMoveLoc(productId, fromLoc) {
const hist = _getMoveLocHistory(productId, fromLoc);
if (hist.length < _PREF_MOVE_NEEDED) return null;
const recent = hist.slice(-5);
const counts = {};
for (const loc of recent) counts[loc] = (counts[loc] || 0) + 1;
const [topLoc, topCount] = Object.entries(counts).sort((a, b) => b[1] - a[1])[0];
return topCount >= _PREF_MOVE_NEEDED ? topLoc : null;
}
function _getPrefLocHistory(productId) { function _getPrefLocHistory(productId) {
try { try {
const all = JSON.parse(localStorage.getItem(_PREF_LOC_KEY) || '{}'); const all = JSON.parse(localStorage.getItem(_PREF_LOC_KEY) || '{}');
@@ -8081,6 +8295,22 @@ function startMoveModalCountdown(btnId, onExpire) {
} }
function showMoveAfterUseModal(product, fromLoc, remaining, openedId, openedVacuumSealed, unit) { function showMoveAfterUseModal(product, fromLoc, remaining, openedId, openedVacuumSealed, unit) {
// Store context so _saveVacuumAndStay can record the choice
_pendingMoveCtx = { productId: product.id, fromLoc, openedId };
// If a preference is established, skip the modal entirely and auto-apply
const prefMoveLoc = _getPreferredMoveLoc(product.id, fromLoc);
if (prefMoveLoc) {
if (prefMoveLoc === fromLoc) {
// Preference: stay in place — silent, no modal
_saveVacuumAndStay(openedId || 0);
} else {
// Preference: move to another location — apply silently
confirmMoveAfterUse(product.id, fromLoc, prefMoveLoc, openedId || 0, !!(openedVacuumSealed ?? product.vacuum_sealed));
}
return;
}
const otherLocs = Object.entries(LOCATIONS).filter(([k]) => k !== fromLoc); const otherLocs = Object.entries(LOCATIONS).filter(([k]) => k !== fromLoc);
const locButtons = otherLocs.map(([k, v]) => const locButtons = otherLocs.map(([k, v]) =>
`<button type="button" class="loc-btn" onclick="clearMoveModalTimer();confirmMoveAfterUse(${product.id}, '${fromLoc}', '${k}', ${openedId || 0})">${v.icon} ${v.label}</button>` `<button type="button" class="loc-btn" onclick="clearMoveModalTimer();confirmMoveAfterUse(${product.id}, '${fromLoc}', '${k}', ${openedId || 0})">${v.icon} ${v.label}</button>`
@@ -8114,6 +8344,11 @@ function showMoveAfterUseModal(product, fromLoc, remaining, openedId, openedVacu
/** Save vacuum state when user chooses to keep the item at the current location. */ /** Save vacuum state when user chooses to keep the item at the current location. */
async function _saveVacuumAndStay(openedId) { async function _saveVacuumAndStay(openedId) {
// Record the "stay" preference before closing
if (_pendingMoveCtx) {
_recordMoveLocChoice(_pendingMoveCtx.productId, _pendingMoveCtx.fromLoc, _pendingMoveCtx.fromLoc);
_pendingMoveCtx = null;
}
closeModal(); closeModal();
if (openedId) { if (openedId) {
const isVacuum = document.getElementById('move-vacuum-check')?.checked ? 1 : 0; const isVacuum = document.getElementById('move-vacuum-check')?.checked ? 1 : 0;
@@ -8125,9 +8360,14 @@ async function _saveVacuumAndStay(openedId) {
showPage('dashboard'); showPage('dashboard');
} }
async function confirmMoveAfterUse(productId, fromLoc, toLoc, openedId) { async function confirmMoveAfterUse(productId, fromLoc, toLoc, openedId, forcedVacuum) {
clearMoveModalTimer(); clearMoveModalTimer();
const newVacuum = document.getElementById('move-vacuum-check')?.checked ? 1 : 0; const newVacuum = forcedVacuum !== undefined ? (forcedVacuum ? 1 : 0) : (document.getElementById('move-vacuum-check')?.checked ? 1 : 0);
// Record preference
if (_pendingMoveCtx && _pendingMoveCtx.productId === productId) {
_recordMoveLocChoice(productId, fromLoc, toLoc);
_pendingMoveCtx = null;
}
closeModal(); closeModal();
showLoading(true); showLoading(true);
try { try {
@@ -11777,6 +12017,36 @@ async function confirmRecipeMove(productId, fromLoc, toLoc, openedId) {
} }
} }
/**
* Extract tools/appliances from recipe steps text when tools_needed is absent (old cached recipes).
* Returns an array of localised tool names found in the steps.
*/
function _extractToolsFromSteps(steps) {
const text = (steps || []).join(' ').toLowerCase();
// Map: regex keyword → display name per language
const patterns = [
{ re: /\bforn[oi]\b|oven|backofen/, it: 'Forno', en: 'Oven', de: 'Backofen' },
{ re: /\bmicroond[ea]\b|microwave|mikrowelle/, it: 'Microonde', en: 'Microwave', de: 'Mikrowelle' },
{ re: /\bfrullator[ei]\b|blender|mixer\b|pimer|frullatore a immersione|stabmixer/,
it: 'Frullatore', en: 'Blender', de: 'Mixer' },
{ re: /\bfritteuse\b|friggitrici[ae]\b|air\s*fry|friggitric[ae]\b|friggi\b/, it: 'Friggitrice', en: 'Air fryer', de: 'Fritteuse' },
{ re: /\bpentola\s+a\s+pressione\b|pressure\s+cook|schnellkochtopf|cookeo|instant\s*pot/, it: 'Pentola a pressione', en: 'Pressure cooker', de: 'Schnellkochtopf' },
{ re: /\bbimby\b|thermomix\b|monsieur\s+cuisine/,it: 'Bimby/Thermomix', en: 'Thermomix', de: 'Thermomix' },
{ re: /\bimpastatric[ae]\b|planetari[ao]\b|stand\s*mixer|knetmaschine/, it: 'Impastatrice', en: 'Stand mixer', de: 'Knetmaschine' },
{ re: /\bvapore\b|steamer\b|dampfgarer\b/, it: 'Vaporiera', en: 'Steamer', de: 'Dampfgarer' },
{ re: /\bslow\s*cook|cottura\s+lenta\b|schongarer/, it: 'Slow cooker', en: 'Slow cooker', de: 'Schongarer' },
{ re: /\bgrill[eo]?\b|griglia\b|grillpfanne/, it: 'Griglia', en: 'Grill', de: 'Grill' },
{ re: /\bmacchina\s+del\s+pane\b|bread\s*machine|brotbackautomat/, it: 'Macchina del pane', en: 'Bread machine', de: 'Brotbackautomat' },
{ re: /\bessiccator[ei]\b|dehydrator\b|dörrgerät/, it: 'Essiccatore', en: 'Dehydrator', de: 'Dörrgerät' },
];
const lang = _currentLang || 'it';
const found = [];
for (const p of patterns) {
if (p.re.test(text)) found.push(p[lang] || p.it);
}
return found;
}
function renderRecipe(r) { function renderRecipe(r) {
let html = `<h2>${r.title}</h2>`; let html = `<h2>${r.title}</h2>`;
@@ -11794,6 +12064,14 @@ function renderRecipe(r) {
html += `<div class="recipe-expiry-note">⚠️ ${r.expiry_note}</div>`; html += `<div class="recipe-expiry-note">⚠️ ${r.expiry_note}</div>`;
} }
// Tools/appliances banner (shown only when specific equipment is needed)
const tools = (r.tools_needed && r.tools_needed.length > 0)
? r.tools_needed.filter(t => t && t.trim())
: _extractToolsFromSteps(r.steps);
if (tools.length > 0) {
html += `<div class="recipe-tools-banner">🔧 <strong>${t('recipes.tools_title')}:</strong> ${tools.map(t => `<span class="recipe-tool-chip">${t}</span>`).join('')}</div>`;
}
// Ingredients // Ingredients
html += `<h3>${t('recipes.ingredients_title')}</h3><ul class="recipe-ingredients">`; html += `<h3>${t('recipes.ingredients_title')}</h3><ul class="recipe-ingredients">`;
(r.ingredients || []).forEach((ing, idx) => { (r.ingredients || []).forEach((ing, idx) => {
@@ -11831,6 +12109,9 @@ function renderRecipe(r) {
}); });
html += '</ul>'; 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 // Steps
html += `<h3>${t('recipes.steps_title')}</h3><ol>`; html += `<h3>${t('recipes.steps_title')}</h3><ol>`;
(r.steps || []).forEach(step => { (r.steps || []).forEach(step => {
@@ -11852,6 +12133,62 @@ let _cookingRecipe = null;
let _cookingStep = 0; let _cookingStep = 0;
let _cookingTTS = true; let _cookingTTS = true;
let _cookingVisited = new Set(); // indices of steps already seen 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() { function startCookingMode() {
const recipe = _cachedRecipe && _cachedRecipe.recipe ? _cachedRecipe.recipe : null; const recipe = _cachedRecipe && _cachedRecipe.recipe ? _cachedRecipe.recipe : null;
@@ -11870,8 +12207,28 @@ function startCookingMode() {
_cookingTTS = true; _cookingTTS = true;
document.getElementById('cooking-title').textContent = _cookingRecipe.title || ''; document.getElementById('cooking-title').textContent = _cookingRecipe.title || '';
document.getElementById('cooking-tts-btn').textContent = '🔊'; document.getElementById('cooking-tts-btn').textContent = '🔊';
// Tools bar
const toolsBar = document.getElementById('cooking-tools-bar');
if (toolsBar) {
const tools = (_cookingRecipe.tools_needed && _cookingRecipe.tools_needed.length > 0)
? _cookingRecipe.tools_needed.filter(t => t && t.trim())
: _extractToolsFromSteps(_cookingRecipe.steps);
if (tools.length > 0) {
toolsBar.innerHTML = '🔧 ' + tools.map(t => `<span class="cooking-tool-chip">${t}</span>`).join('');
toolsBar.style.display = '';
} else {
toolsBar.style.display = 'none';
toolsBar.innerHTML = '';
}
}
document.getElementById('cooking-overlay').style.display = 'flex'; document.getElementById('cooking-overlay').style.display = 'flex';
document.body.classList.add('cooking-mode-active'); document.body.classList.add('cooking-mode-active');
// Hide kiosk overlay — it lives outside <body> with z-index:2147483647 and would overlap cooking UI
const _kioskOvl = document.getElementById('_kiosk_overlay');
if (_kioskOvl) _kioskOvl.style.display = 'none';
_bindCookingWheelControls();
const wheelEl = document.getElementById('cooking-wheel');
if (wheelEl) setTimeout(() => wheelEl.focus(), 20);
try { screen.orientation?.lock('portrait').catch(() => {}); } catch (_) { /* ignore */ } try { screen.orientation?.lock('portrait').catch(() => {}); } catch (_) { /* ignore */ }
renderCookingStep(); renderCookingStep();
if (_cookingTTS) { if (_cookingTTS) {
@@ -11882,6 +12239,9 @@ function startCookingMode() {
function closeCookingMode() { function closeCookingMode() {
document.getElementById('cooking-overlay').style.display = 'none'; document.getElementById('cooking-overlay').style.display = 'none';
document.body.classList.remove('cooking-mode-active'); document.body.classList.remove('cooking-mode-active');
// Restore kiosk overlay
const _kioskOvl = document.getElementById('_kiosk_overlay');
if (_kioskOvl) _kioskOvl.style.display = 'flex';
// NOTE: intentionally keep _cookingRecipe, _cookingStep, _cookingVisited // NOTE: intentionally keep _cookingRecipe, _cookingStep, _cookingVisited
// so the user can resume from the same step when they reopen // so the user can resume from the same step when they reopen
try { screen.orientation?.unlock().catch(() => {}); } catch (_) { /* ignore */ } try { screen.orientation?.unlock().catch(() => {}); } catch (_) { /* ignore */ }
@@ -11889,11 +12249,135 @@ function closeCookingMode() {
function restartCookingMode() { function restartCookingMode() {
_cookingStep = 0; _cookingStep = 0;
_cookingWheelLastDelta = 0;
_cookingVisited = new Set(); _cookingVisited = new Set();
clearAllCookingTimers(); clearAllCookingTimers();
renderCookingStep(); 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() { function renderCookingStep() {
if (!_cookingRecipe) return; if (!_cookingRecipe) return;
const steps = _cookingRecipe.steps || []; const steps = _cookingRecipe.steps || [];
@@ -11907,6 +12391,30 @@ function renderCookingStep() {
document.getElementById('cooking-step-num').textContent = `${_cookingStep + 1} / ${total}`; document.getElementById('cooking-step-num').textContent = `${_cookingStep + 1} / ${total}`;
document.getElementById('cooking-step-text').textContent = cleanStep; 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 // Progress dots
const dotsEl = document.getElementById('cooking-progress-dots'); const dotsEl = document.getElementById('cooking-progress-dots');
if (dotsEl) { if (dotsEl) {
@@ -12066,7 +12574,10 @@ function _initBrowserTtsVoices(selectedVoice) {
sel.innerHTML = '<option value="">— Caricamento voci… —</option>'; sel.innerHTML = '<option value="">— Caricamento voci… —</option>';
const populate = () => { const populate = () => {
const voices = window.speechSynthesis.getVoices(); let voices = [];
try {
voices = (window.speechSynthesis.getVoices() || []).filter(v => v != null && v.lang);
} catch (_) { return false; }
if (!voices.length) return false; if (!voices.length) return false;
// Italian voices first, then others // Italian voices first, then others
const it = voices.filter(v => v.lang.startsWith('it')); const it = voices.filter(v => v.lang.startsWith('it'));
@@ -12212,6 +12723,53 @@ let _cookingTimerIdCounter = 0;
let _cookingSuggestedSeconds = 0; let _cookingSuggestedSeconds = 0;
let _cookingSuggestedLabel = ''; 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. * Parse time durations from step text.
* Returns total seconds or 0 if no time found. * Returns total seconds or 0 if no time found.
@@ -12245,18 +12803,88 @@ function _formatTimerDisplay(sec) {
/** Extract a short 2-3 word label from the step text for the timer. */ /** Extract a short 2-3 word label from the step text for the timer. */
function _extractTimerLabel(text, stepNum) { function _extractTimerLabel(text, stepNum) {
const raw = String(text || '');
const fillers = new Set(['il','la','lo','le','gli','i','dell','della','dello','delle','degli','dei', 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', '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', '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]; 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; } let timeIdx = raw.length;
const beforeTime = (text.slice(0, timeIdx).trim() || text); for (const p of timePatterns) {
const words = beforeTime.replace(/[.,!?;:'"()\[\]]/g, '').split(/\s+/).filter(w => w.length > 2 && !/^\d+$/.test(w)); const r = p.exec(raw);
const meaningful = words.filter(w => !fillers.has(w.toLowerCase())); if (r && r.index < timeIdx) timeIdx = r.index;
if (meaningful.length >= 1) return meaningful.slice(0, 3).join(' '); }
return `Passo ${stepNum + 1}`;
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) { function setupCookingTimerSuggestion(stepText) {
@@ -12290,28 +12918,34 @@ function addCookingTimer(seconds, label) {
} }
function removeCookingTimer(id) { function removeCookingTimer(id) {
const t = _cookingTimers.find(t => t.id === id); const timer = _cookingTimers.find(ti => ti.id === id);
if (t && t.interval) clearInterval(t.interval); if (timer && timer.interval) clearInterval(timer.interval);
_cookingTimers = _cookingTimers.filter(t => t.id !== id); _cookingTimers = _cookingTimers.filter(ti => ti.id !== id);
renderTimersBar(); renderTimersBar();
_updateScreenFlash(); _updateScreenFlash();
} }
function toggleCookingTimerById(id) { function toggleCookingTimerById(id) {
const t = _cookingTimers.find(t => t.id === id); const timer = _cookingTimers.find(ti => ti.id === id);
if (!t) return; if (!timer) return;
if (t.running) { if (timer.running) {
clearInterval(t.interval); clearInterval(timer.interval);
t.interval = null; timer.interval = null;
t.running = false; timer.running = false;
} else { } else {
t.running = true; timer.running = true;
t.interval = setInterval(() => { timer.interval = setInterval(() => {
t.seconds--; timer.seconds = Math.max(0, timer.seconds - 1);
if (t.seconds === 10 && _cookingTTS) {
speakCookingStep(t('cooking.timer_warning_tts').replace('{label}', t.label)); if (timer.seconds === 10) {
_notifyCookingTimer('warning', timer.label);
} }
if (t.seconds === 0) _cookingTimerDoneById(id);
if (timer.seconds === 0) {
_cookingTimerDoneById(id);
return;
}
_updateTimerCard(id); _updateTimerCard(id);
}, 1000); }, 1000);
} }
@@ -12319,19 +12953,27 @@ function toggleCookingTimerById(id) {
} }
function resetCookingTimerById(id) { function resetCookingTimerById(id) {
const t = _cookingTimers.find(t => t.id === id); const timer = _cookingTimers.find(ti => ti.id === id);
if (!t) return; if (!timer) return;
clearInterval(t.interval); clearInterval(timer.interval);
t.interval = null; timer.interval = null;
t.running = false; timer.running = false;
t.seconds = t.total; timer.seconds = timer.total;
_updateTimerCard(id); _updateTimerCard(id);
} }
function _cookingTimerDoneById(id) { function _cookingTimerDoneById(id) {
if (navigator.vibrate) navigator.vibrate([300, 100, 300, 100, 300]); if (navigator.vibrate) navigator.vibrate([300, 100, 300, 100, 300]);
const timer = _cookingTimers.find(ti => ti.id === id); 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) { function _updateTimerCard(id) {
@@ -12436,8 +13078,10 @@ function navigateCookingStep(delta) {
closeCookingMode(); closeCookingMode();
return; return;
} }
_cookingWheelLastDelta = delta;
_cookingStep = next; _cookingStep = next;
renderCookingStep(); renderCookingStep();
_cookingStepFeedback();
if (_cookingTTS) { if (_cookingTTS) {
const text = ((_cookingRecipe.steps || [])[_cookingStep] || '').replace(/^Passo\s*\d+\s*[:.]\s*/i, ''); const text = ((_cookingRecipe.steps || [])[_cookingStep] || '').replace(/^Passo\s*\d+\s*[:.]\s*/i, '');
speakCookingStep(text); speakCookingStep(text);
+28 -17
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)** **[⬇ 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) - 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 - **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 - 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** - Auto-detects your EverShelf server on the LAN with a **smart discovery scanner**
- Reports errors and install failures back to the developer automatically - Reports errors and install failures back to the developer automatically
@@ -36,21 +36,23 @@ Overview of what the wizard will configure.
### Step 3 — Permissions ### Step 3 — Permissions
Grant camera, microphone, and storage permissions needed by the web app. Grant camera, microphone, and storage permissions needed by the web app.
The button transforms from "Concedi permessi" to **"✅ Permessi concessi — Continua →"** (green) once all permissions are granted. The button transforms from **"Grant permissions"** to **"✅ Permissions granted — Continue →"** (green) once all permissions are granted.
### Step 4 — Server URL ### Step 4 — Server URL
Enter your EverShelf server URL (e.g. `https://192.168.1.100/dispensa`). Enter your EverShelf server URL (e.g. `https://192.168.1.100/dispensa`).
**Or tap "Rileva automaticamente"** to let the wizard scan your LAN: **Or tap "Auto-discover"** to let the wizard scan your LAN:
- 60 parallel threads, TCP pre-check, ports 80/443/8080/8443 - 60 parallel threads, TCP pre-check, ports 80/443/8080/8443
- Only scans your actual Wi-Fi/Ethernet subnet (VPN and cellular interfaces ignored) - Only scans your actual Wi-Fi/Ethernet subnet (VPN and cellular interfaces ignored)
- Real-time feedback as hosts are tested - Real-time feedback as hosts are tested
### Step 5 — Scale Gateway ### Step 5 — Smart Scale
If you have a BLE smart scale, install and configure the Scale Gateway: If you have a Bluetooth LE smart scale, configure it here:
1. Tap **"Installa Gateway"** — the APK is downloaded from GitHub and installed via `PackageInstaller` 1. Tap **"Yes, I have a scale"** — the app scans for nearby BLE devices
2. If installation fails, a diagnostic dialog shows: status code, error message, APK size, Android version, and device model — plus a "Riprova" button 2. Tap your scale in the list (devices most likely to be scales are marked with ⭐)
3. On success, the wizard automatically writes `scale_enabled=true` and `scale_gateway_url=ws://127.0.0.1:8765` to your EverShelf server 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 ### Step 6 — Screensaver
Choose whether the screen should go dark after inactivity. Choose whether the screen should go dark after inactivity.
@@ -60,9 +62,23 @@ All done — the web app loads in full-screen kiosk mode.
--- ---
## Header Overlay Buttons
Three buttons are injected into the top-left of the web header by the kiosk app:
| Button | Action |
|--------|--------|
| **✕** | Exit kiosk mode (confirmation dialog) |
| **↻** | Hard-refresh — clears WebView cache and reloads the app |
| **⚙️** | Open EverShelf Settings |
The native Android settings button is permanently hidden once the overlay is injected — the **⚙️** web button replaces it entirely.
---
## Exiting Kiosk Mode ## Exiting Kiosk Mode
Tap the **✕** button in the header (top-left). A confirmation dialog appears. Tap the **✕** button in the header overlay (top-left). A confirmation dialog appears.
--- ---
@@ -97,11 +113,6 @@ The WebView accepts self-signed certificates automatically. No configuration nee
## Troubleshooting ## 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 ### "Server non trovato" during auto-discovery
- Make sure your tablet and server are on the same Wi-Fi network - Make sure your tablet and server are on the same Wi-Fi network
- Ensure the server is not on a VPN-only interface - Ensure the server is not on a VPN-only interface
@@ -136,6 +147,6 @@ Requires Android Studio or JDK 17+ with the Android SDK.
| `CAMERA` | Barcode scanning and AI photo identification | | `CAMERA` | Barcode scanning and AI photo identification |
| `RECORD_AUDIO` | Voice input in AI chat | | `RECORD_AUDIO` | Voice input in AI chat |
| `WAKE_LOCK` | Keep the screen on | | `WAKE_LOCK` | Keep the screen on |
| `REQUEST_INSTALL_PACKAGES` | Install the Scale Gateway APK | | `REQUEST_INSTALL_PACKAGES` | Over-the-air kiosk self-updates (installs new APK from GitHub releases) |
| `ACCESS_WIFI_STATE` | LAN auto-discovery | | `ACCESS_WIFI_STATE` | LAN auto-discovery |
| `REORDER_TASKS` | Bring app to foreground after gateway launch | | `REORDER_TASKS` | Bring the kiosk app to foreground when needed |
+12 -14
View File
@@ -43,15 +43,13 @@ docker compose up -d
## AI Features ## AI Features
### AI features don't work / "AI non disponibile" ### "AI not available" error
1. Check that `GEMINI_API_KEY` is set in `.env` 1. Check that `GEMINI_API_KEY` is set in `.env`
2. Verify the key is valid at [aistudio.google.com](https://aistudio.google.com) 2. Verify the key is valid at [aistudio.google.com](https://aistudio.google.com)
3. Check that you haven't exceeded the free tier quota (15 req/min, 1500 req/day) 3. Check that you haven't exceeded the free tier quota (15 req/min, 1500 req/day)
4. Look for errors in the PHP error log 4. Look for errors in the PHP error log
### Recipe generation stops midway
This is usually a Gemini API timeout. The app streams results via SSE — if the server PHP timeout is too low, the stream is cut short. Increase `max_execution_time` in `php.ini`: This is usually a Gemini API timeout. The app streams results via SSE — if the server PHP timeout is too low, the stream is cut short. Increase `max_execution_time` in `php.ini`:
```ini ```ini
@@ -62,7 +60,7 @@ max_execution_time = 120
## Shopping List (Bring!) ## Shopping List (Bring!)
### "Bring! non configurato" message in the shopping tab ### "Bring! not configured" message in the shopping tab
Add your Bring! credentials to `.env`: Add your Bring! credentials to `.env`:
@@ -90,7 +88,7 @@ BRING_PASSWORD=yourpassword
### Scale shows weight but form doesn't auto-fill ### Scale shows weight but form doesn't auto-fill
- The auto-fill only triggers for products with unit `g` or `ml` - The auto-fill only triggers for products with unit `g` or `ml`
- Make sure you tapped "⚖️ Leggi bilancia" first to activate the scale modal - Make sure you tapped **"⚖️ Read Scale"** first to activate the scale modal
- The weight must stabilize (stay within 10g) for the countdown to start - The weight must stabilize (stay within 10g) for the countdown to start
### Bluetooth scale not appearing in the gateway app ### Bluetooth scale not appearing in the gateway app
@@ -109,19 +107,19 @@ BRING_PASSWORD=yourpassword
- Try entering the URL manually instead of using auto-discovery - Try entering the URL manually instead of using auto-discovery
- Check that the server responds on the expected port (80/443/8080/8443) - Check that the server responds on the expected port (80/443/8080/8443)
### Gateway install fails with an error dialog ### Kiosk app update fails
The dialog shows the exact failure code. Common causes: The kiosk checks for a new release every 6 hours and downloads it from GitHub. If the install fails:
| Code | Cause | Fix | | Symptom | Fix |
|------|-------|-----| |---------|-----|
| `STATUS_FAILURE` (1) | Generic install failure — often OEM restriction | Enable "Install from unknown sources" for the kiosk app in Android Settings | | "Install from unknown sources" dialog | Enable the setting for the EverShelf Kiosk app in Android Settings |
| `STATUS_FAILURE_CONFLICT` (3) | Signature mismatch with existing install | Uninstall the old gateway app, then retry | | Persistent failure after download | Force-stop the app, clear its data, and relaunch the update flow |
| `STATUS_FAILURE_STORAGE` (6) | Not enough storage | Free up space on the device | | Not enough space | Free up storage on the device |
### Exit button (✕) is not visible ### Exit button (✕) is not visible
The ✕ button is injected into the header by the kiosk app. If the web app's header is covered or the page failed to load, try the hard refresh (↻) button. If neither is visible, triple-tap the page title area to access the developer settings. Three buttons are always visible in the kiosk header overlay: **✕** (exit), **↻** (refresh), **⚙️** (settings). If the page failed to load entirely, tap **↻** first. If nothing is visible, restart the device.
### App is stuck in kiosk mode after a crash ### App is stuck in kiosk mode after a crash
@@ -139,7 +137,7 @@ The version is cached by the browser. Do a hard refresh:
### Transactions are missing from the log ### Transactions are missing from the log
The log shows the last 50 entries by default. Tap "Carica altri" to load more. Entries older than the database creation date won't appear. The log shows the last 50 entries by default. Tap **"Load more"** to load more. Entries older than the database creation date won't appear.
### "Can only undo transactions within 24 hours" ### "Can only undo transactions within 24 hours"
+8 -8
View File
@@ -73,7 +73,7 @@ Shown as an inline AI badge next to the expiry estimate. Does not block the form
### Recipe Generation ### 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) - Ingredients about to expire (prioritised)
- What's currently in your pantry - What's currently in your pantry
- Your language preference - Your language preference
@@ -83,9 +83,9 @@ Recipes stream live via Server-Sent Events so results appear as they are generat
### AI Chat Assistant ### AI Chat Assistant
Open **💬 Chat** to ask questions like: Open **💬 Chat** to ask questions like:
- "Cosa posso fare con le uova e la pasta?" - "What can I make with eggs and pasta?"
- "Quanti giorni dura il prosciutto cotto aperto in frigo?" - "How long does cooked ham last once opened in the fridge?"
- "Suggeriscimi uno spuntino veloce" - "Suggest a quick snack"
The assistant knows your current inventory. The assistant knows your current inventory.
@@ -121,7 +121,7 @@ Configure `BRING_EMAIL` and `BRING_PASSWORD` in `.env` to enable.
## 🍳 Cooking Mode ## 🍳 Cooking Mode
Start cooking mode from any recipe by tapping **Avvia cottura**. Start cooking mode from any recipe by tapping **Start Cooking**.
### Features ### Features
@@ -132,7 +132,7 @@ Start cooking mode from any recipe by tapping **▶ Avvia cottura**.
- Custom REST endpoint (e.g. Home Assistant) - Custom REST endpoint (e.g. Home Assistant)
- **Built-in timers** — automatic timer suggestions based on recipe text; 10-second vocal countdown warning before expiry - **Built-in timers** — automatic timer suggestions based on recipe text; 10-second vocal countdown warning before expiry
- **Ingredient tracking** — mark ingredients as used; leftover quantities prompt a "move to another location" flow - **Ingredient tracking** — mark ingredients as used; leftover quantities prompt a "move to another location" flow
- **Recipe completion** — "Buon appetito!" spoken on the last step - **Recipe completion** — "Buon appetito!" *(Enjoy your meal!)* spoken on the last step
--- ---
@@ -155,8 +155,8 @@ Actions per item: Use, Throw away, Edit, Dismiss. Swipe or tap arrows to navigat
Highlights suspicious quantities (e.g. "You have 0 eggs but used 12 this month"). Actions: Highlights suspicious quantities (e.g. "You have 0 eggs but used 12 this month"). Actions:
- One-tap correction to the suggested quantity - One-tap correction to the suggested quantity
- Inline edit with free-form quantity - Inline edit with free-form quantity
- "🤖 Spiega" for AI explanation - "🤖 Explain" for AI explanation
- Dismiss (with current quantity shown: "La quantità è giusta (2 pz)") - Dismiss (with current quantity shown: "The quantity is correct (2 pcs)")
### Anti-Waste Report ### Anti-Waste Report
+15 -12
View File
@@ -46,18 +46,21 @@ All data stays on your server. No cloud, no subscriptions.
## 🆕 What's New ## 🆕 What's New
### v1.7.1 (2026-05-04) ### v1.7.13 (2026-05-16)
- Destructive actions ("Butta tutto", "Finisci tutto") now require a **5-second countdown confirmation** before executing - **Fix:** Kiosk Settings button (⚙️) added to the web overlay — tapping the camera button no longer accidentally opens kiosk settings
- History undo button ↩ is now clearly visible (red tint, larger) - **Fix:** Opened-item expiry badge is now consistent with the top banner: low-risk items (jams, condiments) show amber ⚠️ "Check soon" instead of misleading red ⛔ "Expired"
- Undo confirmation uses the in-app modal instead of the native browser `confirm()` - **Cooking Mode:** 3D wheel UI with perspective card flip, ghost steps (prev/next), float animation, and full `prefers-reduced-motion` support
- **CI:** `data/category_ai_cache.json` added to `.gitignore`
- **Critical fix (DB):** Fresh-install crash resolved — `transactions` schema was missing the `undone` column
### v1.7.0 (2026-05-04) ### v1.7.12 (2026-05-13)
- Smart auto-discovery rewrite (kiosk) - "Use first" banner now shows opening date and location instead of a confusing calculated expiry
- Gateway auto-pre-configuration after install - "Use All / Done" in recipes no longer deletes the inventory row — uses exact quantity instead
- ErrorReporter init at setup start - Scan page fully redesigned: 2× zoom, torch, camera flip, 3 input tabs, AI Number OCR, recent products chips
- Graceful Bring! no-key state - Anomaly detection: false positives eliminated (untracked direction removed, minimum 5 txn + 7-day span)
- Use-quantity guard with shake animation - AI price estimation for each Bring! shopping item with real-time dashboard total badge
- Demo mode (`?demo=1`) - 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) → See the full [CHANGELOG](https://github.com/dadaloop82/EverShelf/blob/main/CHANGELOG.md)
@@ -81,7 +84,7 @@ EverShelf/
├── translations/ # i18n JSON files (it, en, de) ├── translations/ # i18n JSON files (it, en, de)
├── docs/openapi.yaml # OpenAPI 3.0 spec ├── docs/openapi.yaml # OpenAPI 3.0 spec
├── evershelf-kiosk/ # Android kiosk app (Kotlin) ├── evershelf-kiosk/ # Android kiosk app (Kotlin)
└── evershelf-scale-gateway/ # Android BLE gateway app (Kotlin) └── evershelf-scale-gateway/ # Android BLE gateway app (Kotlin) — DEPRECATED, built into kiosk since v1.6.0
``` ```
--- ---
+14 -7
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. 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 ### 2. Launch the app
The gateway server starts immediately. Note the **Gateway URL** shown (e.g. `ws://192.168.1.100:8765`). The gateway server starts immediately. Note the **Gateway URL** shown (e.g. `ws://192.168.1.100:8765`).
@@ -68,7 +75,7 @@ In EverShelf **Settings → Scale**:
### 4. Connect your scale ### 4. Connect your scale
Tap **"Cerca Bilance Bluetooth"** (Find Bluetooth Scales). Make sure your scale is powered on. Tap it in the list to pair and connect. Tap **"Find Bluetooth Scales"**. Make sure your scale is powered on. Tap it in the list to pair and connect.
--- ---
@@ -77,7 +84,7 @@ Tap **"Cerca Bilance Bluetooth"** (Find Bluetooth Scales). Make sure your scale
When scale integration is enabled: When scale integration is enabled:
1. Open the **Add** or **Use** form for any product with unit `g` or `ml` 1. Open the **Add** or **Use** form for any product with unit `g` or `ml`
2. A **"⚖️ Leggi bilancia"** button appears 2. A **"⚖️ Read Scale"** button appears
3. Tap it — a live weight display appears with a stability indicator 3. Tap it — a live weight display appears with a stability indicator
4. Step on or place the product on the scale 4. Step on or place the product on the scale
5. When the reading stabilizes, a **5-second countdown** starts 5. When the reading stabilizes, a **5-second countdown** starts
@@ -120,7 +127,7 @@ Every 6 hours the gateway app checks GitHub releases. If a newer version is avai
### Weight not appearing in EverShelf ### Weight not appearing in EverShelf
- Confirm the Gateway URL in EverShelf Settings matches the URL shown in the gateway app - Confirm the Gateway URL in EverShelf Settings matches the URL shown in the gateway app
- Check that the Android device and the EverShelf server are on the same network - Check that the Android device and the EverShelf server are on the same network
- Tap "Disconnetti / Riconnetti" in the gateway app to refresh the WebSocket connection - Tap "Disconnect / Reconnect" in the gateway app to refresh the WebSocket connection
### "Mixed content" error in browser ### "Mixed content" error in browser
- Make sure you are accessing EverShelf over HTTPS (not plain HTTP) - Make sure you are accessing EverShelf over HTTPS (not plain HTTP)
+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 3. Choose your language
4. Grant camera, microphone and Bluetooth permissions when prompted 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 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 7. Done — the web app loads in full-screen kiosk mode
### Scale Configuration ### Scale Configuration
@@ -76,7 +76,7 @@ BLE scale setup happens inside the kiosk app itself — **no external app needed
### Exiting Kiosk Mode ### 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" applicationId = "it.dadaloop.evershelf.kiosk"
minSdk = 24 minSdk = 24
targetSdk = 34 targetSdk = 34
versionCode = 13 versionCode = 15
versionName = "1.7.2" versionName = "1.7.14"
} }
signingConfigs { signingConfigs {
@@ -515,6 +515,17 @@ class KioskActivity : AppCompatActivity() {
btnSettings.visibility = if (visible) View.VISIBLE else View.GONE btnSettings.visibility = if (visible) View.VISIBLE else View.GONE
} }
} }
/**
* Open the native SettingsActivity from the webapp settings page.
* Allows configuring server URL, BLE scale and screensaver without
* the user having to find the native gear button.
*/
@JavascriptInterface
fun openNativeSettings() {
runOnUiThread {
startActivity(Intent(this@KioskActivity, SettingsActivity::class.java))
}
}
}, "_kioskBridge") }, "_kioskBridge")
val url = prefs.getString(KEY_URL, "http://evershelf.local") ?: "http://evershelf.local" val url = prefs.getString(KEY_URL, "http://evershelf.local") ?: "http://evershelf.local"
+2
View File
@@ -1,3 +1,5 @@
android.useAndroidX=true android.useAndroidX=true
android.enableJetifier=true android.enableJetifier=true
# Build trigger: versionName 1.7.13 fix (8d87494)
# Build trigger: TTS bridge fix (95389eb) # Build trigger: TTS bridge fix (95389eb)
# Build trigger: v1.7.14 with openNativeSettings fix (834d8ef)
-7
View File
@@ -1,7 +0,0 @@
.gradle/
build/
local.properties
*.apk
*.aab
*.class
*.dex
-156
View File
@@ -1,156 +0,0 @@
# ~~EverShelf Scale Gateway~~ — DEPRECATED
> ⚠️ **This app is deprecated and no longer maintained.**
>
> As of **EverShelf Kiosk v1.6.0**, BLE scale support is fully integrated into the kiosk app itself. You no longer need to install or configure this separate gateway app.
>
> **If you are using the EverShelf Kiosk app** → the scale gateway runs automatically as a background service. Configure your Bluetooth scale in **step 4 of the setup wizard**.
>
> **If you are NOT using the kiosk app** (standalone Android tablet) → you may still use this APK, but no new releases will be published.
---
# EverShelf Scale Gateway (legacy)
> Android gateway app that bridges Bluetooth LE smart scales with EverShelf via WebSocket.
---
## How it works
```
Smart Scale ──(BLE)──► Android Gateway App ──(WebSocket/LAN)──► EverShelf Server ──(SSE)──► Browser
```
The app runs a local WebSocket server (port **8765**) on your Android device. The EverShelf server connects to it via a server-side relay (`api/scale_relay.php` SSE + `api/scale_ping.php` WebSocket client), avoiding mixed-content (HTTPS→WS) issues. Weight readings are streamed to the browser in real time.
> **Kiosk integration (v1.6.0+):** The gateway is now **built into the EverShelf Kiosk app** as a foreground service. This separate app is not needed when using the kiosk.
---
## Supported scale protocols
| Protocol | Service UUID | Notes |
|---|---|---|
| **Bluetooth SIG Weight Scale** | `0x181D` / char `0x2A9D` | Most compatible; works with most smart scales |
| **Bluetooth SIG Body Composition** | `0x181B` / char `0x2A9C` | Reports weight + body fat %, BMI |
| **Generic fallback** | Any notifiable characteristic | Auto-heuristic parsing for 100+ models |
### Verified compatible scales (community list)
- Xiaomi Mi Body Composition Scale 2
- Renpho Smart Body Fat Scale
- INEVIFIT Smart Body Fat Scale
- Any OpenScale-compatible scale (see [openScale supported devices](https://github.com/oliexdev/openScale/wiki/Supported-scales))
> **Your scale (B09MRXVBV6):** If it implements the standard BLE Weight Scale or Body Composition profile (very likely for modern Amazon smart scales), the gateway will connect automatically. If not, check the [openScale wiki](https://github.com/oliexdev/openScale/wiki/Supported-scales) and open an issue.
---
## Download
Download the latest APK directly: **[evershelf-scale-gateway.apk](https://github.com/dadaloop82/EverShelf/releases/latest/download/evershelf-scale-gateway.apk)**
---
## Requirements
- Android **7.0** (API 24) or later
- Bluetooth LE (BLE) support
- Both the Android device and the device running EverShelf must be on the **same Wi-Fi network**
---
## Setup (step by step)
### 1. Install the APK
Download and install the APK from the Releases page. You may need to allow "Install from unknown sources" in Android settings.
### 2. Launch the app
The app starts the WebSocket gateway server immediately. You will see the **gateway URL** (e.g. `ws://192.168.1.100:8765`) at the top.
### 3. Connect your scale
Tap **"Cerca Bilance Bluetooth"** (Find Bluetooth Scales). Make sure your scale is turned on. Tap it in the list to connect.
### 4. Configure EverShelf
In EverShelf → ⚙️ Settings → **⚖️ Bilancia Smart**:
1. Enable the toggle
2. Paste the gateway URL shown in the Android app
3. Tap **"Testa connessione"** — you should see ✅
### 5. Use it
When adding or consuming a product with unit **g** or **ml**, a **"⚖️ Leggi dalla bilancia"** button appears. Tap it, place the product on the scale, and the weight is filled in automatically.
---
## WebSocket protocol reference
All messages are JSON. The server sends these to connected clients:
```json
// Scale status update
{"type":"status","state":"connected","device":"Mi Scale 2","battery":85}
{"type":"status","state":"disconnected"}
// Weight reading (broadcast continuously while scale is active)
{"type":"weight","value":72.50,"unit":"kg","stable":true,"timestamp":1712345678000}
// Response to ping
{"type":"pong"}
```
Clients can send:
```json
{"type":"get_status"} // Request current status
{"type":"get_weight"} // Request next stable weight reading
{"type":"ping"} // Keep-alive
```
---
## Build from source
### Prerequisites
- Android Studio Hedgehog (2023.1) or later
- Java 8+
### Steps
```bash
# 1. Clone the repo
git clone https://github.com/dadaloop82/EverShelf.git
cd EverShelf/evershelf-scale-gateway
# 2. Download the Gradle wrapper (if not included)
gradle wrapper --gradle-version 8.4
# 3. Build debug APK
./gradlew assembleDebug
# APK is at: app/build/outputs/apk/debug/app-debug.apk
```
---
## Project structure
```
evershelf-scale-gateway/
├── app/src/main/
│ ├── kotlin/it/dadaloop/evershelf/scalegate/
│ │ ├── MainActivity.kt — UI, orchestration
│ │ ├── BleScaleManager.kt — BLE scanning & GATT connection
│ │ ├── ScaleProtocol.kt — Parsing for all supported protocols
│ │ └── GatewayWebSocketServer.kt — WebSocket server (Java-WebSocket)
│ ├── res/layout/
│ │ ├── activity_main.xml
│ │ └── item_device.xml
│ └── AndroidManifest.xml
├── build.gradle.kts
└── settings.gradle.kts
```
---
## License
MIT — see [LICENSE](../LICENSE)
@@ -1,41 +0,0 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "it.dadaloop.evershelf.scalegate"
compileSdk = 34
defaultConfig {
applicationId = "it.dadaloop.evershelf.scalegate"
minSdk = 24
targetSdk = 34
versionCode = 8
versionName = "2.1.1"
}
buildFeatures {
viewBinding = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.11.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
implementation("androidx.recyclerview:recyclerview:1.3.2")
// WebSocket server
implementation("org.java-websocket:Java-WebSocket:1.5.5")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
}
@@ -1,64 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- BLE permissions for Android < 12 -->
<uses-permission android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<!-- BLE permissions for Android 12+ -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Location (required for BLE scanning on Android 611) -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- Network (for WebSocket server) -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- Keep screen on while gateway is active -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Self-update: install APK downloaded at runtime -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.MaterialComponents.Light.NoActionBar">
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- FileProvider for serving the downloaded APK to the installer -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -1,455 +0,0 @@
package it.dadaloop.evershelf.scalegate
import android.Manifest
import android.bluetooth.*
import android.bluetooth.le.*
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.core.content.ContextCompat
private const val TAG = "BleScaleManager"
private const val SCAN_PERIOD_MS = 15_000L
private const val PREFS_NAME = "evershelf_gateway"
private const val PREF_LAST_DEVICE = "last_device_address"
/**
* Represents a discovered BLE device during scan.
*/
data class BleDeviceInfo(
val device: BluetoothDevice,
val name: String,
val rssi: Int,
val proximity: String,
val scaleScore: Int,
)
/**
* Callback interface for BLE events dispatched back to the UI.
*/
interface BleScaleListener {
fun onDeviceFound(info: BleDeviceInfo)
fun onConnecting(device: BluetoothDevice)
fun onConnected(deviceName: String)
fun onDisconnected()
fun onWeightReceived(reading: WeightReading)
fun onBatteryReceived(level: Int)
fun onError(message: String)
fun onScanStopped()
fun onDebugEvent(message: String)
}
/**
* Manages BLE scanning and connection to a smart scale.
* All listener callbacks are dispatched on the main thread.
*/
class BleScaleManager(
private val context: Context,
private val listener: BleScaleListener,
) {
private val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
private val bluetoothAdapter: BluetoothAdapter? get() = bluetoothManager.adapter
private val mainHandler = Handler(Looper.getMainLooper())
private var leScanner: BluetoothLeScanner? = null
private var gatt: BluetoothGatt? = null
private var isScanning = false
private var connectedDeviceName: String = ""
private var autoConnectAddress: String? = null
// The characteristics we will subscribe to (multiple may exist).
private val pendingSubscriptions = ArrayDeque<BluetoothGattCharacteristic>()
// ─── Public state ──────────────────────────────────────────────────────────
val isConnected: Boolean get() = gatt != null && connectedDeviceName.isNotEmpty()
// ─── Saved device (auto-reconnect) ─────────────────────────────────────────
fun getSavedDeviceAddress(): String? {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getString(PREF_LAST_DEVICE, null)
}
private fun saveDeviceAddress(address: String) {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit().putString(PREF_LAST_DEVICE, address).apply()
}
fun enableAutoConnect() {
autoConnectAddress = getSavedDeviceAddress()
}
// ─── Permissions helper ────────────────────────────────────────────────────
fun hasRequiredPermissions(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED
} else {
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
}
}
// ─── Scanning ──────────────────────────────────────────────────────────────
fun startScan() {
val adapter = bluetoothAdapter ?: run {
listener.onError("Bluetooth not available on this device.")
return
}
if (!adapter.isEnabled) {
listener.onError("Bluetooth is off. Enable it and try again.")
return
}
if (isScanning) stopScan()
leScanner = adapter.bluetoothLeScanner
val settings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.build()
// No service UUID filters — many consumer scales use proprietary UUIDs
// and would be invisible with strict filtering. We show all named BLE devices.
isScanning = true
try {
leScanner?.startScan(null, settings, scanCallback)
} catch (e: Exception) {
leScanner?.startScan(scanCallback)
}
// Auto-stop after SCAN_PERIOD_MS
mainHandler.postDelayed({
stopScan()
listener.onScanStopped()
}, SCAN_PERIOD_MS)
}
fun stopScan() {
if (!isScanning) return
isScanning = false
try {
leScanner?.stopScan(scanCallback)
} catch (e: Exception) { /* ignore */ }
leScanner = null
}
private val scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
val device = result.device
val name = result.scanRecord?.deviceName?.takeIf { it.isNotBlank() }
?: getDeviceName(device)
val proximity = rssiToProximity(result.rssi)
val score = scoreLikelyScale(name, result.scanRecord)
val info = BleDeviceInfo(device, name, result.rssi, proximity, score)
mainHandler.post { listener.onDeviceFound(info) }
// Auto-connect to saved device
if (autoConnectAddress != null && device.address == autoConnectAddress && !isConnected) {
autoConnectAddress = null // prevent re-trigger
mainHandler.post {
listener.onDebugEvent("\uD83D\uDD04 Auto-connecting to $name (${device.address})")
connect(device)
}
}
}
override fun onScanFailed(errorCode: Int) {
isScanning = false
mainHandler.post { listener.onError("BLE scan failed (code: $errorCode)") }
}
}
private fun getDeviceName(device: BluetoothDevice): String {
return try {
device.name?.takeIf { it.isNotBlank() } ?: "Unnamed"
} catch (e: SecurityException) {
"Unnamed"
}
}
private fun rssiToProximity(rssi: Int) = when {
rssi >= -60 -> "📶 Near"
rssi >= -80 -> "📶 Medium"
else -> "📶 Far"
}
private fun scoreLikelyScale(name: String, scanRecord: android.bluetooth.le.ScanRecord?): Int {
var score = 0
val lower = name.lowercase()
// Kitchen / food scale brand and model keywords
val foodKeywords = listOf(
"scale", "bilancia", "kitchen", "food", "cucina",
"coffee", "caffe", "balance", "weight", "waage",
"arboleaf", "ck10", "ck20", "ek-",
"acaia", "felicita", "decent", "skale",
"timemore", "brewista", "hario",
"greater goods", "ozeri", "etekcity", "nutri",
"nicewell", "koios", "renpho", "eatsmart",
)
if (foodKeywords.any { lower.contains(it) }) score += 10
// Negative: body/fitness scale keywords (demote but don't hide)
val bodyKeywords = listOf(
"body", "fat", "bmi", "composition", "fitness",
"mi body", "lepulse", "qardio", "garmin", "withings",
)
if (bodyKeywords.any { lower.contains(it) }) score -= 5
// Service UUID scoring
scanRecord?.serviceUuids?.let { uuids ->
val us = uuids.map { it.uuid.toString().lowercase() }
// SIG Weight Scale service
if (us.any { it.startsWith("0000181d") }) score += 15
// Common vendor services on kitchen scales
if (us.any { it.startsWith("0000ffe0") || it.startsWith("0000fff0") }) score += 10
// Acaia coffee scale
if (us.any { it.startsWith("49535343") }) score += 20
// Body Composition service = body scale, demote
if (us.any { it.startsWith("0000181b") }) score -= 10
}
return score
}
// ─── Connection ────────────────────────────────────────────────────────────
fun connect(device: BluetoothDevice) {
stopScan()
disconnect()
connectedDeviceName = ""
ScaleProtocol.resetState()
mainHandler.post { listener.onConnecting(device) }
try {
gatt = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
} else {
device.connectGatt(context, false, gattCallback)
}
} catch (e: SecurityException) {
mainHandler.post { listener.onError("Missing permission: ${e.message}") }
}
}
fun disconnect() {
pendingSubscriptions.clear()
try {
gatt?.disconnect()
gatt?.close()
} catch (e: Exception) { /* ignore */ }
gatt = null
connectedDeviceName = ""
}
// ─── GATT callbacks ────────────────────────────────────────────────────────
private val gattCallback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
when (newState) {
BluetoothProfile.STATE_CONNECTED -> {
Log.d(TAG, "Connected — discovering services…")
mainHandler.postDelayed({ gatt.discoverServices() }, 500)
}
BluetoothProfile.STATE_DISCONNECTED -> {
Log.d(TAG, "Disconnected (status=$status)")
this@BleScaleManager.gatt?.close()
this@BleScaleManager.gatt = null
connectedDeviceName = ""
mainHandler.post { listener.onDisconnected() }
}
}
}
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
if (status != BluetoothGatt.GATT_SUCCESS) {
mainHandler.post { listener.onError("Servizi GATT non trovati (status=$status)") }
return
}
val targetChars = mutableListOf<BluetoothGattCharacteristic>()
// Priority 1: BLE SIG Weight Scale Service
gatt.getService(BleUuids.WEIGHT_SCALE_SERVICE)
?.getCharacteristic(BleUuids.WEIGHT_MEASUREMENT_CHAR)
?.let { targetChars.add(it) }
// Priority 2: Common vendor service FFE0 (arboleaf, generic kitchen scales)
gatt.getService(BleUuids.FFE0)?.let { svc ->
svc.getCharacteristic(BleUuids.FFE1)?.let { targetChars.add(it) }
}
// Priority 3: Common vendor service FFF0
gatt.getService(BleUuids.FFF0)?.let { svc ->
svc.getCharacteristic(BleUuids.FFF4)?.let { targetChars.add(it) }
?: svc.getCharacteristic(BleUuids.FFF1)?.let { targetChars.add(it) }
}
// Priority 4: Acaia coffee scale
gatt.getService(BleUuids.ACAIA_SERVICE)?.let { svc ->
svc.getCharacteristic(BleUuids.ACAIA_CHAR)?.let { targetChars.add(it) }
}
// Fallback: any notifiable characteristic from remaining services
if (targetChars.isEmpty()) {
for (service in gatt.services) {
if (service.uuid.toString().startsWith("00001800") ||
service.uuid.toString().startsWith("00001801")) continue
for (char in service.characteristics) {
val props = char.properties
if ((props and BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0 ||
(props and BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0) {
if (!targetChars.contains(char)) targetChars.add(char)
}
}
}
}
if (targetChars.isEmpty()) {
mainHandler.post { listener.onError("No weight characteristic found. Make sure it's a BLE kitchen scale.") }
return
}
// Battery (optional)
gatt.getService(BleUuids.BATTERY_SERVICE)
?.getCharacteristic(BleUuids.BATTERY_LEVEL_CHAR)
?.let { targetChars.add(it) }
// Debug: log all discovered services and characteristics
val dbg = buildString {
append("GATT services (${gatt.services.size}):\n")
for (svc in gatt.services) {
append(" SVC: ${svc.uuid}\n")
for (ch in svc.characteristics) {
val p = ch.properties
val flags = buildString {
if (p and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0) append("N")
if (p and BluetoothGattCharacteristic.PROPERTY_INDICATE != 0) append("I")
if (p and BluetoothGattCharacteristic.PROPERTY_READ != 0) append("R")
if (p and BluetoothGattCharacteristic.PROPERTY_WRITE != 0) append("W")
}
append(" CHAR: ${ch.uuid} [$flags]\n")
}
}
append("Subscribed to ${targetChars.size} characteristics")
}
mainHandler.post { listener.onDebugEvent(dbg) }
// Save device for auto-reconnect
try { gatt.device?.address?.let { saveDeviceAddress(it) } } catch (_: SecurityException) {}
pendingSubscriptions.clear()
pendingSubscriptions.addAll(targetChars)
val deviceName = try { gatt.device?.name ?: "Scale" } catch (e: SecurityException) { "Scale" }
connectedDeviceName = deviceName
mainHandler.post { listener.onConnected(deviceName) }
// Subscribe one at a time (Android BLE requires sequential descriptor writes)
subscribeNext(gatt)
}
override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
// Subscribe to the next characteristic
subscribeNext(gatt)
}
@Suppress("DEPRECATION")
override fun onCharacteristicChanged(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
) {
val data = characteristic.value ?: return
processCharacteristicData(characteristic, data)
}
// Android 13+ override
override fun onCharacteristicChanged(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray,
) {
processCharacteristicData(characteristic, value)
}
@Suppress("DEPRECATION")
override fun onCharacteristicRead(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
status: Int,
) {
if (status == BluetoothGatt.GATT_SUCCESS && characteristic.uuid == BleUuids.BATTERY_LEVEL_CHAR) {
val level = characteristic.value?.firstOrNull()?.toInt()?.and(0xFF)
if (level != null) mainHandler.post { listener.onBatteryReceived(level) }
}
}
}
// ─── Helpers ───────────────────────────────────────────────────────────────
private fun subscribeNext(gatt: BluetoothGatt) {
val char = pendingSubscriptions.removeFirstOrNull() ?: return
// Battery characteristic — read once instead of notify
if (char.uuid == BleUuids.BATTERY_LEVEL_CHAR) {
try { gatt.readCharacteristic(char) } catch (e: SecurityException) { /* ignore */ }
return
}
val props = char.properties
val notifyType = when {
(props and BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0 ->
BluetoothGattDescriptor.ENABLE_INDICATION_VALUE
else -> BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
}
try {
gatt.setCharacteristicNotification(char, true)
val descriptor = char.getDescriptor(CCCD_UUID) ?: run {
// No CCCD — skip and try next
subscribeNext(gatt)
return
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
gatt.writeDescriptor(descriptor, notifyType)
} else {
@Suppress("DEPRECATION")
descriptor.value = notifyType
@Suppress("DEPRECATION")
gatt.writeDescriptor(descriptor)
}
} catch (e: SecurityException) {
Log.e(TAG, "SecurityException enabling notification", e)
}
}
private fun processCharacteristicData(char: BluetoothGattCharacteristic, data: ByteArray) {
// Battery level
if (char.uuid == BleUuids.BATTERY_LEVEL_CHAR && data.isNotEmpty()) {
val level = data[0].toInt() and 0xFF
mainHandler.post { listener.onBatteryReceived(level) }
return
}
// Debug: log raw bytes received
val hex = data.joinToString(" ") { "%02X".format(it) }
mainHandler.post { listener.onDebugEvent("📡 ${char.uuid}\n HEX [${data.size}B]: $hex") }
// Parse weight data
val reading = ScaleProtocol.parse(char, data) { msg ->
mainHandler.post { listener.onDebugEvent(msg) }
}
if (reading != null && reading.value > 0f) {
mainHandler.post { listener.onWeightReceived(reading) }
} else {
val rawDump = data.mapIndexed { i, b ->
val v = b.toInt() and 0xFF
val h = "%02X".format(v)
"[$i]=$v(0x$h)"
}.joinToString(" ")
mainHandler.post { listener.onDebugEvent("\u26a0\ufe0f Weight not decoded\n RAW: $rawDump") }
}
}
}
@@ -1,249 +0,0 @@
package it.dadaloop.evershelf.scalegate
import android.content.Context
import android.os.Build
import android.util.Log
import org.json.JSONArray
import org.json.JSONObject
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.net.URL
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.Executors
/**
* Centralized error reporter for EverShelf Scale Gateway.
*
* Unlike the Kiosk (which relays errors through the EverShelf PHP backend),
* the Scale Gateway has no knowledge of the EverShelf server URL, so it
* calls the GitHub Issues REST API directly.
*
* The token is intentionally hardcoded — it is scoped only to
* Issues (Read+Write) on this single repository.
*
* Usage:
* ErrorReporter.init(applicationContext)
* ErrorReporter.report(exception, "methodName", mapOf("extra" to "info"))
* ErrorReporter.reportMessage("ble-disconnect", "Scale disconnected after 3 retries")
*/
object ErrorReporter {
private const val TAG = "ScaleGWErrorReporter"
// ── XOR-obfuscated GitHub token (scoped: Issues R+W on dadaloop82/EverShelf) ──
// Stored encoded so the literal token string never appears in source or git history.
private const val GH_TOKEN_ENC = "23580718460c2c444031290243627e7971622b29035e2a647726407d194f61440b6e05246a0c067c79730e77114b774501730043433d1866682225511b5443417170444443142941673c4046086c05737363293e7821006e470a466a1d"
private const val GH_TOKEN_KEY = "D1sp3ns4!Ev3r#26"
private const val GH_REPO = "dadaloop82/EverShelf"
private var _ghTokenCache: String? = null
private fun ghToken(): String {
_ghTokenCache?.let { return it }
val enc = GH_TOKEN_ENC.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val key = GH_TOKEN_KEY
val out = String(ByteArray(enc.size) { i -> (enc[i].toInt() xor key[i % key.length].code).toByte() })
_ghTokenCache = out
return out
}
// SharedPreferences key for pending (unsent) crash reports
private const val PREFS_NAME = "evershelf_scalegw_errors"
private const val KEY_PENDING = "pending_crash_json"
private val executor = Executors.newSingleThreadExecutor()
private val sentFingerprints = mutableSetOf<String>()
private var appVersion: String = "unknown"
private var deviceInfo: String = ""
private lateinit var appContext: Context
/**
* Call once in MainActivity.onCreate() or Application.onCreate().
*/
fun init(context: Context) {
appContext = context.applicationContext
deviceInfo = "${Build.MANUFACTURER} ${Build.MODEL} (Android ${Build.VERSION.RELEASE})"
try {
val pi = context.packageManager.getPackageInfo(context.packageName, 0)
appVersion = pi.versionName ?: "unknown"
} catch (_: Exception) {}
// Send any crash report that was saved from the previous session
sendPendingCrash()
// Install global UncaughtExceptionHandler
val previous = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
try {
val crash = buildPayload(
type = "uncaught-exception",
message = "${throwable.javaClass.simpleName}: ${throwable.message}",
stack = throwable.stackTraceToString(),
context = mapOf("thread" to thread.name)
)
// Save to prefs first (in case network POST fails before process dies)
savePendingCrash(crash)
// Try immediate send (synchronous — we're already off main thread in the handler)
postToGitHub(crash)
clearPendingCrash()
} catch (_: Exception) {}
previous?.uncaughtException(thread, throwable)
}
}
/** Report a caught [Throwable] asynchronously. */
fun report(throwable: Throwable, location: String = "", extra: Map<String, Any?> = emptyMap()) {
val ctx = mutableMapOf<String, Any?>("device" to deviceInfo)
if (location.isNotEmpty()) ctx["location"] = location
ctx.putAll(extra)
enqueue(
type = "scale-exception",
message = "${throwable.javaClass.simpleName}: ${throwable.message}",
stack = throwable.stackTraceToString(),
context = ctx
)
}
/** Report a non-exception event (e.g. BLE disconnect, WebSocket error). */
fun reportMessage(type: String, message: String, extra: Map<String, Any?> = emptyMap()) {
val ctx = mutableMapOf<String, Any?>("device" to deviceInfo)
ctx.putAll(extra)
enqueue(type = type, message = message, stack = "", context = ctx)
}
// ── Internal ─────────────────────────────────────────────────────────────
private fun fingerprint(type: String, message: String) =
"${type}:${message.take(120)}".hashCode().toString(16)
private fun enqueue(type: String, message: String, stack: String, context: Map<String, Any?>) {
val fp = fingerprint(type, message)
synchronized(sentFingerprints) {
if (!sentFingerprints.add(fp)) return
}
val payload = buildPayload(type, message, stack, context)
executor.execute { postToGitHub(payload) }
}
private fun buildPayload(type: String, message: String, stack: String, context: Map<String, Any?>): JSONObject {
val ctxJson = JSONObject()
context.forEach { (k, v) -> ctxJson.put(k, v) }
return JSONObject().apply {
put("source", "scale")
put("type", type)
put("message", message)
put("stack", stack)
put("context", ctxJson)
put("version", appVersion)
put("user_agent", "EverShelf-ScaleGateway/$appVersion (Android ${Build.VERSION.RELEASE}; ${Build.MODEL})")
put("ts", SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date()))
}
}
/** Persist crash payload to SharedPreferences so it survives a process kill. */
private fun savePendingCrash(payload: JSONObject) {
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit().putString(KEY_PENDING, payload.toString()).apply()
}
private fun clearPendingCrash() {
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit().remove(KEY_PENDING).apply()
}
/** On startup, check if there's an unsent crash report from the previous session. */
private fun sendPendingCrash() {
val json = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getString(KEY_PENDING, null) ?: return
clearPendingCrash() // remove before sending to prevent re-sending on next crash
executor.execute {
try {
val payload = JSONObject(json)
// Tag it as a "survived-crash" so we know it was saved and retried
payload.put("type", "uncaught-exception-survived")
payload.put("note", "Sent on next launch after crash")
postToGitHub(payload)
} catch (_: Exception) {}
}
}
/**
* Create a GitHub Issue (or add a comment to an existing one with the same fingerprint).
* Uses the GitHub Issues Search API to deduplicate.
*/
private fun postToGitHub(payload: JSONObject) {
val source = payload.optString("source", "scale")
val type = payload.optString("type", "error")
val message = payload.optString("message", "")
val stack = payload.optString("stack", "")
val version = payload.optString("version", "")
val ua = payload.optString("user_agent", "")
val ts = payload.optString("ts", "")
val ctxJson = payload.optJSONObject("context") ?: JSONObject()
val fp = fingerprint(type, message)
// ── 1. Search for existing open issue ──────────────────────────────
val searchQ = "repo:$GH_REPO is:issue is:open label:auto-report \"fp:$fp\" in:body"
val searchUrl = "https://api.github.com/search/issues?q=${java.net.URLEncoder.encode(searchQ, "UTF-8")}&per_page=1"
val searchResult = ghGet(searchUrl) ?: JSONObject()
val existingNumber = searchResult.optJSONArray("items")?.optJSONObject(0)?.optInt("number", 0)?.takeIf { it > 0 }
// ── 2. Build body ─────────────────────────────────────────────────
val ctxMd = if (ctxJson.length() > 0) "\n**Context:**\n```json\n${ctxJson.toString(2)}\n```\n" else ""
val stackMd = if (stack.isNotEmpty()) "\n**Stack trace:**\n```\n$stack\n```\n" else ""
if (existingNumber != null) {
// Comment on existing issue
val body = "### 🔁 Recurrence — $ts\n**Source:** `$source` | **Type:** `$type`\n**UA:** `$ua`\n$ctxMd$stackMd\n---\n_fp:${fp}_"
ghPost("https://api.github.com/repos/$GH_REPO/issues/$existingNumber/comments", JSONObject().put("body", body))
} else {
// Create new issue
val shortMsg = if (message.length > 70) "${message.take(70)}" else message
val title = "[SCALE] $shortMsg"
val body = "## 🚨 Automatic Error Report\n\n**Source:** `$source` \n**Type:** `$type` \n**Reported at:** $ts \n**UA:** `$ua` \n**Version:** `$version`\n\n**Error message:**\n> $message\n$stackMd$ctxMd\n---\n<!-- auto-report fp:$fp -->\n_This issue was created automatically by EverShelf Scale Gateway error reporter. fp:`${fp}`_"
ghPost(
"https://api.github.com/repos/$GH_REPO/issues",
JSONObject()
.put("title", title)
.put("body", body)
.put("labels", JSONArray().put("auto-report").put("scale-error"))
)
}
}
private fun ghGet(url: String): JSONObject? = try {
val conn = URL(url).openConnection() as HttpURLConnection
conn.setRequestProperty("Authorization", "token ${ghToken()}")
conn.setRequestProperty("Accept", "application/vnd.github+json")
conn.setRequestProperty("X-GitHub-Api-Version", "2022-11-28")
conn.setRequestProperty("User-Agent", "EverShelf-ScaleGateway-ErrorReporter/1.0")
conn.connectTimeout = 8000
conn.readTimeout = 8000
val raw = BufferedReader(InputStreamReader(conn.inputStream)).readText()
conn.disconnect()
JSONObject(raw)
} catch (e: Exception) { Log.w(TAG, "ghGet failed: ${e.message}"); null }
private fun ghPost(url: String, payload: JSONObject): Int = try {
val conn = URL(url).openConnection() as HttpURLConnection
conn.requestMethod = "POST"
conn.setRequestProperty("Authorization", "token ${ghToken()}")
conn.setRequestProperty("Accept", "application/vnd.github+json")
conn.setRequestProperty("X-GitHub-Api-Version", "2022-11-28")
conn.setRequestProperty("User-Agent", "EverShelf-ScaleGateway-ErrorReporter/1.0")
conn.setRequestProperty("Content-Type", "application/json; charset=utf-8")
conn.doOutput = true
conn.connectTimeout = 8000
conn.readTimeout = 8000
OutputStreamWriter(conn.outputStream, Charsets.UTF_8).use { it.write(payload.toString()) }
val code = conn.responseCode
conn.disconnect()
Log.d(TAG, "ghPost $url → HTTP $code")
code
} catch (e: Exception) { Log.w(TAG, "ghPost failed: ${e.message}"); -1 }
}
@@ -1,151 +0,0 @@
package it.dadaloop.evershelf.scalegate
import android.util.Log
import org.java_websocket.WebSocket
import org.java_websocket.handshake.ClientHandshake
import org.java_websocket.server.WebSocketServer
import org.json.JSONObject
import java.net.InetSocketAddress
import java.util.Collections
private const val TAG = "GatewayWsServer"
/**
* Callbacks for the WebSocket server, dispatched on the server's internal thread.
* The caller (MainActivity) is responsible for switching to the main thread if needed.
*/
interface ServerEventListener {
fun onClientConnected(address: String)
fun onClientDisconnected(address: String)
fun onClientRequestedWeight()
}
/**
* WebSocket server that exposes smart-scale data to EverShelf running in a browser.
*
* Message protocol (JSON):
*
* Server -> Client:
* {"type":"status","state":"connected"|"disconnected","device":"QN-KS","battery":80}
* {"type":"weight","value":17.0,"unit":"g","stable":true,"timestamp":1712345678000}
* {"type":"pong"}
*
* Client → Server:
* {"type":"get_status"} → server responds with current status message
* {"type":"get_weight"} → server will push the next stable weight reading
* {"type":"ping"} → server responds with {"type":"pong"}
*/
class GatewayWebSocketServer(
port: Int,
private val eventListener: ServerEventListener?,
) : WebSocketServer(InetSocketAddress(port)) {
// Thread-safe set of clients waiting for the next stable weight reading
private val pendingWeightRequests: MutableSet<WebSocket> =
Collections.synchronizedSet(mutableSetOf())
// Last known scale state (to send to new clients immediately)
@Volatile private var lastStatusJson: String = buildStatusJson("disconnected", null, null)
@Volatile private var lastWeightJson: String? = null
// ─── Server lifecycle ──────────────────────────────────────────────────────
override fun onStart() {
Log.i(TAG, "WebSocket server started on port ${address.port}")
connectionLostTimeout = 30
}
override fun onOpen(conn: WebSocket, handshake: ClientHandshake) {
val addr = conn.remoteSocketAddress?.toString() ?: "?"
Log.d(TAG, "Client connected: $addr")
// Immediately send current status so the web app knows the scale state
conn.send(lastStatusJson)
lastWeightJson?.let { conn.send(it) }
eventListener?.onClientConnected(addr)
}
override fun onClose(conn: WebSocket, code: Int, reason: String, remote: Boolean) {
val addr = conn.remoteSocketAddress?.toString() ?: "?"
Log.d(TAG, "Client disconnected: $addr (code=$code)")
pendingWeightRequests.remove(conn)
eventListener?.onClientDisconnected(addr)
}
override fun onMessage(conn: WebSocket, message: String) {
try {
val json = JSONObject(message)
when (json.optString("type")) {
"ping" -> conn.send("""{"type":"pong"}""")
"get_status" -> conn.send(lastStatusJson)
"get_weight" -> {
// Add to pending set; next stable weight will be sent to this client
pendingWeightRequests.add(conn)
eventListener?.onClientRequestedWeight()
// If we already have a recent weight, send it immediately
lastWeightJson?.let { conn.send(it) }
}
}
} catch (e: Exception) {
Log.w(TAG, "Malformed message: $message")
}
}
override fun onError(conn: WebSocket?, ex: Exception) {
Log.e(TAG, "WebSocket error on ${conn?.remoteSocketAddress}", ex)
ErrorReporter.report(ex, "GatewayWebSocketServer.onError",
mapOf("remote_addr" to (conn?.remoteSocketAddress?.toString() ?: "null")))
}
// ─── Publishing API ────────────────────────────────────────────────────────
/**
* Broadcast scale connection status to all connected WebSocket clients.
*/
fun publishStatus(state: String, deviceName: String?, battery: Int?) {
lastStatusJson = buildStatusJson(state, deviceName, battery)
broadcast(lastStatusJson)
}
/**
* Broadcast a weight reading to all clients.
* If [stable] is true, also fulfil pending on-demand weight requests.
*/
fun publishWeight(value: Float, unit: String, stable: Boolean, battery: Int? = null) {
val json = buildWeightJson(value, unit, stable)
lastWeightJson = json
broadcast(json)
if (stable) {
synchronized(pendingWeightRequests) {
// Clients that requested on-demand readings are already served by broadcast;
// just clear the pending set.
pendingWeightRequests.clear()
}
}
}
// ─── JSON builders ─────────────────────────────────────────────────────────
private fun buildStatusJson(state: String, device: String?, battery: Int?): String {
val obj = JSONObject()
obj.put("type", "status")
obj.put("state", state)
if (device != null) obj.put("device", device)
if (battery != null) obj.put("battery", battery)
return obj.toString()
}
private fun buildWeightJson(value: Float, unit: String, stable: Boolean): String {
val obj = JSONObject()
obj.put("type", "weight")
// Round to 1 decimal to avoid floating point noise (e.g. 17.000001)
val rounded = Math.round(value * 10f) / 10.0
obj.put("value", rounded)
obj.put("unit", unit)
obj.put("stable", stable)
obj.put("timestamp", System.currentTimeMillis())
return obj.toString()
}
}
@@ -1,674 +0,0 @@
package it.dadaloop.evershelf.scalegate
import android.Manifest
import android.app.DownloadManager
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.app.PendingIntent
import android.content.pm.PackageInstaller
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.button.MaterialButton
import it.dadaloop.evershelf.scalegate.databinding.ActivityMainBinding
import java.net.Inet4Address
import java.net.NetworkInterface
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import org.json.JSONObject
private const val WS_PORT = 8765
class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener {
private lateinit var binding: ActivityMainBinding
private lateinit var bleManager: BleScaleManager
private var wsServer: GatewayWebSocketServer? = null
private val devices = mutableListOf<BleDeviceInfo>()
private lateinit var deviceAdapter: DeviceAdapter
private var batteryLevel: Int? = null
private val debugLines = mutableListOf<String>()
private var debugVisible = false
private var lastDebugUpdate = 0L
private val debugTimeFmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
private var isAutoReconnecting = false
// Update banner
private var pendingApkDownloadUrl = ""
private var pendingInstallFile: java.io.File? = null
private companion object {
const val MAX_DEBUG_LINES = 150
const val DEBUG_THROTTLE_MS = 200L
const val GITHUB_RELEASES_API = "https://api.github.com/repos/dadaloop82/EverShelf/releases/latest"
const val APK_DOWNLOAD_URL = "https://github.com/dadaloop82/EverShelf/releases/latest/download/evershelf-scale-gateway.apk"
}
// ─── Permission launcher ───────────────────────────────────────────────────
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { granted ->
if (granted.values.all { it }) {
startGatewayServer()
} else {
showDialog("Missing permissions",
"The app requires Bluetooth and Location permissions to function.")
}
}
private val enableBtLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == RESULT_OK) checkPermissionsAndStart()
else showDialog("Bluetooth required", "Please enable Bluetooth to use the gateway.")
}
/** Returns from ACTION_MANAGE_UNKNOWN_APP_SOURCES — retry the download. */
private val installPermLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { _ ->
val url = pendingApkDownloadUrl
if (url.isNotEmpty()) triggerApkDownload(url)
}
/** Returns from system installer dialog — if not OK the install failed (signature conflict?). */
private val installConfirmLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode != RESULT_OK) {
val f = pendingInstallFile
if (f != null && f.exists()) {
runOnUiThread {
AlertDialog.Builder(this)
.setTitle("⚠️ Installazione non riuscita")
.setMessage("Se hai visto un errore di conflitto firma, devi disinstallare la versione precedente.\n\nDisinstalla ora? L'installazione ripartirà automaticamente.")
.setPositiveButton("Disinstalla") { _, _ ->
uninstallLauncher.launch(
Intent(Intent.ACTION_DELETE, android.net.Uri.parse("package:$packageName"))
)
}
.setNegativeButton("Annulla", null)
.show()
}
}
}
}
/** Returns from uninstall screen — auto-retry the install with the saved APK file. */
private val uninstallLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { _ ->
val f = pendingInstallFile
if (f != null && f.exists()) installApk(f)
}
// ─── Lifecycle ─────────────────────────────────────────────────────────────
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
bleManager = BleScaleManager(this, this)
// Initialise error reporter early so the UncaughtExceptionHandler is installed
// and any pending crash from a previous session is sent
ErrorReporter.init(this)
deviceAdapter = DeviceAdapter(devices) { info ->
bleManager.connect(info.device)
}
binding.rvDevices.apply {
layoutManager = LinearLayoutManager(this@MainActivity)
adapter = deviceAdapter
}
binding.btnScan.setOnClickListener { startScanIfPermitted() }
binding.btnDisconnect.setOnClickListener {
bleManager.disconnect()
updateUiDisconnected()
}
binding.btnDebug.setOnClickListener {
debugVisible = !debugVisible
binding.svDebugLog.visibility = if (debugVisible) View.VISIBLE else View.GONE
binding.btnCopyLog.visibility = if (debugVisible) View.VISIBLE else View.GONE
binding.btnShareLog.visibility = if (debugVisible) View.VISIBLE else View.GONE
binding.btnDebug.text = if (debugVisible) "\uD83D\uDC1B Hide Debug" else "\uD83D\uDC1B Debug"
}
binding.btnCopyLog.setOnClickListener {
val log = debugLines.joinToString("\n")
val cm = getSystemService(CLIPBOARD_SERVICE) as android.content.ClipboardManager
cm.setPrimaryClip(android.content.ClipData.newPlainText("EverShelf Scale Log", log))
Toast.makeText(this, "Log copied to clipboard", Toast.LENGTH_SHORT).show()
}
binding.btnShareLog.setOnClickListener {
val log = debugLines.joinToString("\n")
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_SUBJECT, "EverShelf Scale Gateway - Debug Log")
putExtra(Intent.EXTRA_TEXT, log)
}
startActivity(Intent.createChooser(intent, "Share log"))
}
// Show app version
try {
val pInfo = packageManager.getPackageInfo(packageName, 0)
binding.tvVersion.text = "v${pInfo.versionName} (${pInfo.longVersionCode})"
} catch (_: Exception) { }
updateGatewayUrl()
checkPermissionsAndStart()
// Wire update banner buttons
binding.btnDismissUpdate.setOnClickListener { binding.updateBanner.visibility = View.GONE }
binding.btnInstallUpdate.setOnClickListener { triggerApkDownload(pendingApkDownloadUrl) }
// Check for a newer release (background thread, at most once every 6 h)
checkForUpdates()
// Auto-connect: if we have a saved device, start scanning with auto-connect enabled
if (bleManager.getSavedDeviceAddress() != null) {
binding.tvScanHint.visibility = View.VISIBLE
binding.tvScanHint.text = "\uD83D\uDD04 Reconnecting to saved scale\u2026"
}
}
override fun onDestroy() {
super.onDestroy()
bleManager.disconnect()
wsServer?.stop(1000)
}
// ─── Permissions & startup ─────────────────────────────────────────────────
private fun checkPermissionsAndStart() {
val required = buildList {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
add(Manifest.permission.BLUETOOTH_SCAN)
add(Manifest.permission.BLUETOOTH_CONNECT)
} else {
add(Manifest.permission.ACCESS_FINE_LOCATION)
}
}
val missing = required.filter {
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
}
when {
missing.isNotEmpty() -> permissionLauncher.launch(missing.toTypedArray())
!isBluetoothEnabled() -> enableBtLauncher.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE))
else -> startGatewayServer()
}
}
private fun isBluetoothEnabled(): Boolean {
val adapter = android.bluetooth.BluetoothManager::class.java.let {
getSystemService(it)
} as? android.bluetooth.BluetoothManager
return adapter?.adapter?.isEnabled == true
}
private fun startScanIfPermitted() {
if (!bleManager.hasRequiredPermissions()) {
checkPermissionsAndStart()
return
}
devices.clear()
deviceAdapter.notifyDataSetChanged()
debugLines.clear()
binding.tvDebugLog.text = ""
binding.tvScanHint.visibility = View.VISIBLE
binding.tvScanHint.text = "Scanning for BLE scales\u2026"
binding.btnScan.isEnabled = false
bleManager.enableAutoConnect()
isAutoReconnecting = false // manual scan — stop any pending auto-reconnect cycle
bleManager.startScan()
}
// ─── WebSocket gateway ─────────────────────────────────────────────────────
private fun startGatewayServer() {
if (wsServer != null) return
try {
wsServer = GatewayWebSocketServer(WS_PORT, this)
wsServer!!.start()
updateGatewayUrl()
binding.tvGatewayStatus.text = "\u2705 Gateway active on port $WS_PORT"
} catch (e: Exception) {
binding.tvGatewayStatus.text = "\u274C Failed to start gateway: ${e.message}"
ErrorReporter.report(e, "startGatewayServer", mapOf("port" to WS_PORT))
}
// Auto-scan if there's a saved device
if (bleManager.getSavedDeviceAddress() != null && bleManager.hasRequiredPermissions()) {
bleManager.enableAutoConnect()
bleManager.startScan()
}
}
private fun updateGatewayUrl() {
val ip = getLocalIpAddress() ?: ""
val url = "ws://$ip:$WS_PORT"
binding.tvGatewayUrl.text = url
binding.tvGatewayUrlHint.text = "Paste this URL in EverShelf \u2192 Settings \u2192 Smart Scale"
binding.btnCopyUrl.setOnClickListener {
val cm = getSystemService(CLIPBOARD_SERVICE) as android.content.ClipboardManager
cm.setPrimaryClip(android.content.ClipData.newPlainText("EverShelf Gateway URL", url))
binding.btnCopyUrl.text = "\u2705 Copied!"
binding.btnCopyUrl.postDelayed({ binding.btnCopyUrl.text = "\uD83D\uDCCB Copy URL" }, 2000)
}
}
// ─── BleScaleListener ─────────────────────────────────────────────────────
override fun onDeviceFound(info: BleDeviceInfo) {
if (devices.none { it.device.address == info.device.address }) {
// Insert keeping descending scaleScore order (scale-likely devices first)
val insertAt = devices.indexOfFirst { it.scaleScore < info.scaleScore }
.let { if (it < 0) devices.size else it }
devices.add(insertAt, info)
deviceAdapter.notifyItemInserted(insertAt)
}
}
override fun onConnecting(device: BluetoothDevice) {
val name = try { device.name ?: device.address } catch (e: SecurityException) { device.address }
binding.tvScaleStatus.text = "\u23f3 Connecting to $name\u2026"
binding.tvWeight.text = "— — —"
binding.cardConnection.setCardBackgroundColor(getColor(android.R.color.holo_orange_light))
}
override fun onConnected(deviceName: String) {
isAutoReconnecting = false
binding.tvScaleStatus.text = "\u2705 Connected: $deviceName"
binding.tvWeight.text = "Waiting for weight\u2026"
binding.cardConnection.setCardBackgroundColor(getColor(android.R.color.holo_green_light))
binding.btnDisconnect.visibility = View.VISIBLE
binding.rvDevices.visibility = View.GONE
binding.btnScan.visibility = View.GONE
binding.tvScanHint.visibility = View.GONE
wsServer?.publishStatus("connected", deviceName, batteryLevel)
}
override fun onDisconnected() {
wsServer?.publishStatus("disconnected", null, null)
updateUiDisconnected()
// Auto-reconnect: if a saved device exists, restart scan after a short delay.
// This handles the scale turning off by itself (auto-off) — when it powers
// back on it will start advertising again and we will pick it up.
if (bleManager.getSavedDeviceAddress() != null && bleManager.hasRequiredPermissions()) {
isAutoReconnecting = true
binding.tvScanHint.visibility = View.VISIBLE
binding.tvScanHint.text = "\uD83D\uDD04 Reconnecting to saved scale in 5 s\u2026"
binding.root.postDelayed({
if (!bleManager.isConnected && isAutoReconnecting) {
bleManager.enableAutoConnect()
bleManager.startScan()
}
}, 5_000L)
}
}
override fun onWeightReceived(reading: WeightReading) {
val displayValue = if (reading.value % 1f == 0f) reading.value.toInt().toString()
else "%.1f".format(reading.value)
binding.tvWeight.text = "$displayValue ${reading.unit}"
if (reading.stable) {
binding.tvWeightHint.text = "\u2713 Stable reading"
} else {
binding.tvWeightHint.text = "\u23f3 Measuring\u2026"
}
wsServer?.publishWeight(reading.value, reading.unit, reading.stable, batteryLevel)
}
override fun onBatteryReceived(level: Int) {
batteryLevel = level
binding.tvBattery.text = "🔋 $level%"
binding.tvBattery.visibility = View.VISIBLE
wsServer?.publishStatus("connected", binding.tvScaleStatus.text.toString()
.removePrefix("\u2705 Connected: "), level)
}
override fun onError(message: String) {
binding.tvScaleStatus.text = "$message"
binding.cardConnection.setCardBackgroundColor(getColor(android.R.color.holo_red_light))
ErrorReporter.reportMessage(
type = "ble-error",
message = message,
extra = mapOf("connected_device" to (bleManager.getSavedDeviceAddress() ?: "none"))
)
}
override fun onScanStopped() {
binding.btnScan.isEnabled = true
if (isAutoReconnecting && !bleManager.isConnected && bleManager.getSavedDeviceAddress() != null) {
// Scale not found yet — retry scan after 10 s indefinitely until reconnected
binding.tvScanHint.visibility = View.VISIBLE
binding.tvScanHint.text = "\uD83D\uDD04 Bilancia non trovata, riprovo tra 10 s\u2026"
binding.root.postDelayed({
if (!bleManager.isConnected && isAutoReconnecting) {
binding.tvScanHint.text = "\uD83D\uDD04 Cerco la bilancia\u2026"
bleManager.enableAutoConnect()
bleManager.startScan()
}
}, 10_000L)
} else if (devices.isEmpty()) {
binding.tvScanHint.text = "No scale found. Make sure it's on, then scan again."
} else {
binding.tvScanHint.text = "Tap a scale to connect."
}
}
override fun onDebugEvent(message: String) {
runOnUiThread {
val ts = debugTimeFmt.format(Date())
debugLines.add("[$ts] $message")
// Keep only last MAX_DEBUG_LINES
while (debugLines.size > MAX_DEBUG_LINES) debugLines.removeAt(0)
// Throttle UI updates to avoid freezing
val now = System.currentTimeMillis()
if (now - lastDebugUpdate >= DEBUG_THROTTLE_MS) {
lastDebugUpdate = now
binding.tvDebugLog.text = debugLines.joinToString("\n")
if (debugVisible) {
binding.svDebugLog.post { binding.svDebugLog.fullScroll(View.FOCUS_DOWN) }
}
}
}
}
// ─── ServerEventListener ──────────────────────────────────────────────────
override fun onClientConnected(address: String) {
runOnUiThread {
binding.tvClientCount.text = "\uD83C\uDF10 Client connected: $address"
binding.tvClientCount.visibility = View.VISIBLE
}
}
override fun onClientDisconnected(address: String) {
runOnUiThread {
binding.tvClientCount.visibility = View.GONE
}
}
override fun onClientRequestedWeight() { /* Nothing extra needed */ }
// ─── UI helpers ───────────────────────────────────────────────────────────
private fun updateUiDisconnected() {
binding.tvScaleStatus.text = "\u26a1 Ready \u2014 scan for a scale"
binding.tvWeight.text = "— — —"
binding.tvWeightHint.text = ""
binding.tvBattery.visibility = View.GONE
binding.cardConnection.setCardBackgroundColor(getColor(android.R.color.darker_gray))
binding.btnDisconnect.visibility = View.GONE
binding.rvDevices.visibility = View.VISIBLE
binding.btnScan.visibility = View.VISIBLE
}
private fun getLocalIpAddress(): String? {
return try {
NetworkInterface.getNetworkInterfaces().toList()
.flatMap { it.inetAddresses.toList() }
.filterIsInstance<Inet4Address>()
.firstOrNull { !it.isLoopbackAddress }
?.hostAddress
} catch (e: Exception) { null }
}
private fun showDialog(title: String, message: String) {
AlertDialog.Builder(this)
.setTitle(title)
.setMessage(message)
.setPositiveButton("OK", null)
.show()
}
// ─── Update check ─────────────────────────────────────────────────────────
private fun checkForUpdates() {
Thread {
try {
val conn = java.net.URL(GITHUB_RELEASES_API).openConnection() as java.net.HttpURLConnection
conn.setRequestProperty("Accept", "application/vnd.github+json")
conn.connectTimeout = 5000
conn.readTimeout = 5000
val body = conn.inputStream.bufferedReader().readText()
conn.disconnect()
val json = JSONObject(body)
val latestTag = json.optString("tag_name", "").ifEmpty { return@Thread }
val current = try { packageManager.getPackageInfo(packageName, 0).versionName ?: "" } catch (_: Exception) { "" }
val norm = { v: String -> v.trimStart('v') }
val isSemver = latestTag.trimStart('v').matches(Regex("\\d+\\.\\d+.*"))
// Find scale-gateway APK in release assets
var apkUrl = ""
val assets = json.optJSONArray("assets")
if (assets != null) {
for (i in 0 until assets.length()) {
val a = assets.getJSONObject(i)
val name = a.optString("name", "").lowercase()
val url = a.optString("browser_download_url", "")
if ((name.contains("gateway") || name.contains("scale")) && url.isNotEmpty()) {
apkUrl = url; break
}
}
}
// Only show banner if the release actually contains our APK
if (apkUrl.isEmpty()) return@Thread
// Proper semver comparison: only update if remote is strictly newer
fun semverNewer(remote: String, local: String): Boolean {
val r = remote.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 }
val l = local.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 }
val len = maxOf(r.size, l.size)
for (i in 0 until len) {
val rv = r.getOrElse(i) { 0 }
val lv = l.getOrElse(i) { 0 }
if (rv != lv) return rv > lv
}
return false
}
if (current.isEmpty()) return@Thread
if (isSemver && !semverNewer(norm(latestTag), norm(current))) return@Thread
val label = if (isSemver) "$current$latestTag" else latestTag
val msg = "⬆️ Scale Gateway $label"
runOnUiThread { showNativeUpdateBanner(msg, apkUrl) }
} catch (_: Exception) {}
}.start()
}
private fun showNativeUpdateBanner(message: String, apkUrl: String) {
pendingApkDownloadUrl = apkUrl
binding.tvUpdateMessage.text = message
binding.updateBanner.visibility = View.VISIBLE
binding.updateBanner.postDelayed({ binding.updateBanner.visibility = View.GONE }, 30_000)
}
private fun triggerApkDownload(apkUrl: String) {
if (apkUrl.isEmpty()) return
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
!packageManager.canRequestPackageInstalls()) {
pendingApkDownloadUrl = apkUrl // remember for retry
installPermLauncher.launch(
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:$packageName"))
)
Toast.makeText(this, "Abilita 'Installa app sconosciute', poi torna qui", Toast.LENGTH_LONG).show()
return
}
// Download to app-private external dir — no storage permission needed
val destDir = getExternalFilesDir(null) ?: filesDir
val destFile = java.io.File(destDir, "evershelf-scale-update.apk")
pendingInstallFile = destFile
val dm = getSystemService(DOWNLOAD_SERVICE) as DownloadManager
val req = DownloadManager.Request(Uri.parse(apkUrl)).apply {
setTitle("EverShelf Scale Gateway — Aggiornamento")
setDescription("Scaricamento aggiornamento…")
setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
setDestinationUri(Uri.fromFile(destFile))
setMimeType("application/vnd.android.package-archive")
}
val downloadId = dm.enqueue(req)
Toast.makeText(this, "Download avviato…", Toast.LENGTH_SHORT).show()
val receiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context?, intent: Intent?) {
val id = intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1)
if (id != downloadId) return
unregisterReceiver(this)
val q = DownloadManager.Query().setFilterById(downloadId)
val c = (getSystemService(DOWNLOAD_SERVICE) as DownloadManager).query(q)
var ok = false
if (c.moveToFirst()) {
val status = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
ok = (status == DownloadManager.STATUS_SUCCESSFUL)
}
c.close()
if (ok) installApk(destFile)
else runOnUiThread {
Toast.makeText(this@MainActivity, "Download fallito, riprova", Toast.LENGTH_LONG).show()
}
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// RECEIVER_EXPORTED required: ACTION_DOWNLOAD_COMPLETE is sent by the system DownloadManager
// (an external process), so NOT_EXPORTED would silently block the broadcast on API 33+.
registerReceiver(receiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE), RECEIVER_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
registerReceiver(receiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE))
}
} catch (e: Exception) {
Toast.makeText(this, "Errore download: ${e.message}", Toast.LENGTH_LONG).show()
}
}
private fun installApk(file: java.io.File) {
if (!file.exists() || file.length() == 0L) {
runOnUiThread { Toast.makeText(this, "File APK non trovato", Toast.LENGTH_LONG).show() }
return
}
try {
val pi = packageManager.packageInstaller
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
params.setAppPackageName(packageName)
val sessionId = pi.createSession(params)
pi.openSession(sessionId).use { session ->
file.inputStream().use { input ->
session.openWrite("package", 0, file.length()).use { out ->
input.copyTo(out)
session.fsync(out)
}
}
val action = "it.dadaloop.evershelf.scalegate.INSTALL_RESULT_$sessionId"
val resultReceiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context?, intent: Intent?) {
unregisterReceiver(this)
val status = intent?.getIntExtra(
PackageInstaller.EXTRA_STATUS,
PackageInstaller.STATUS_FAILURE
) ?: PackageInstaller.STATUS_FAILURE
when (status) {
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
// Use launcher so we get notified if system installer fails
@Suppress("DEPRECATION")
val confirmIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
intent?.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
else intent?.getParcelableExtra(Intent.EXTRA_INTENT)
if (confirmIntent != null) installConfirmLauncher.launch(confirmIntent)
}
PackageInstaller.STATUS_SUCCESS ->
runOnUiThread { Toast.makeText(this@MainActivity, "✅ Aggiornamento installato", Toast.LENGTH_SHORT).show() }
PackageInstaller.STATUS_FAILURE_INCOMPATIBLE,
PackageInstaller.STATUS_FAILURE_CONFLICT -> {
runOnUiThread {
AlertDialog.Builder(this@MainActivity)
.setTitle("⚠️ Conflitto firma APK")
.setMessage("L'app installata usa una firma diversa.\n\nDisinstalla la versione precedente: al termine l'installazione riparte automaticamente.")
.setPositiveButton("Disinstalla") { _, _ ->
uninstallLauncher.launch(
Intent(Intent.ACTION_DELETE, android.net.Uri.parse("package:$packageName"))
)
}
.setNegativeButton("Annulla", null)
.show()
}
}
else -> {
val msg = intent?.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
?: "status=$status"
runOnUiThread { Toast.makeText(this@MainActivity, "Installazione: $msg", Toast.LENGTH_LONG).show() }
}
}
}
}
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
RECEIVER_NOT_EXPORTED else 0
registerReceiver(resultReceiver, IntentFilter(action), flags)
val pi2 = PendingIntent.getBroadcast(
this, sessionId,
Intent(action).setPackage(packageName),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
session.commit(pi2.intentSender)
}
Toast.makeText(this, "Installazione in corso…", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
runOnUiThread { Toast.makeText(this, "Errore installazione: ${e.message}", Toast.LENGTH_LONG).show() }
}
}
// ─── RecyclerView adapter ──────────────────────────────────────────────────
inner class DeviceAdapter(
private val items: List<BleDeviceInfo>,
private val onClick: (BleDeviceInfo) -> Unit,
) : RecyclerView.Adapter<DeviceAdapter.VH>() {
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
val tvName: TextView = view.findViewById(R.id.tv_device_name)
val tvAddr: TextView = view.findViewById(R.id.tv_device_addr)
val tvRssi: TextView = view.findViewById(R.id.tv_device_rssi)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_device, parent, false)
return VH(view)
}
override fun onBindViewHolder(holder: VH, position: Int) {
val info = items[position]
holder.tvName.text = info.name
holder.tvAddr.text = info.device.address
holder.tvRssi.text = info.proximity
holder.itemView.setOnClickListener { onClick(info) }
}
override fun getItemCount() = items.size
}
}
@@ -1,208 +0,0 @@
package it.dadaloop.evershelf.scalegate
import android.bluetooth.BluetoothGattCharacteristic
import java.util.UUID
// --- Data model ---
/**
* A single weight reading from a BLE scale.
* [value] is in the scale's current display unit (grams, oz, ml, lb).
* [unit] is "g", "oz", "ml", or "lb".
*/
data class WeightReading(
val value: Float,
val unit: String,
val stable: Boolean,
)
// --- UUIDs ---
val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
object BleUuids {
// BLE SIG Weight Scale (some kitchen scales use this)
val WEIGHT_SCALE_SERVICE = UUID.fromString("0000181d-0000-1000-8000-00805f9b34fb")
val WEIGHT_MEASUREMENT_CHAR = UUID.fromString("00002a9d-0000-1000-8000-00805f9b34fb")
// Battery
val BATTERY_SERVICE = UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb")
val BATTERY_LEVEL_CHAR = UUID.fromString("00002a19-0000-1000-8000-00805f9b34fb")
// Common vendor services used by kitchen scales
val FFE0 = UUID.fromString("0000ffe0-0000-1000-8000-00805f9b34fb")
val FFE1 = UUID.fromString("0000ffe1-0000-1000-8000-00805f9b34fb")
val FFF0 = UUID.fromString("0000fff0-0000-1000-8000-00805f9b34fb")
val FFF1 = UUID.fromString("0000fff1-0000-1000-8000-00805f9b34fb")
val FFF4 = UUID.fromString("0000fff4-0000-1000-8000-00805f9b34fb")
// Acaia / Brewista coffee scales
val ACAIA_SERVICE = UUID.fromString("49535343-fe7d-4ae5-8fa9-9fafd205e455")
val ACAIA_CHAR = UUID.fromString("49535343-8841-43f4-a8d4-ecbe34729bb3")
// QN/Yolanda food scale secondary service (QN-KS, etc.)
val QN_AE00 = UUID.fromString("0000ae00-0000-1000-8000-00805f9b34fb")
val QN_AE02 = UUID.fromString("0000ae02-0000-1000-8000-00805f9b34fb")
}
// --- Food scale protocol parser ---
object ScaleProtocol {
// Plausible kitchen scale range
private const val MAX_GRAMS = 15000f
private const val MIN_GRAMS = 0.5f // allow tare/small values
fun resetState() { /* reserved for future use */ }
fun parse(
char: BluetoothGattCharacteristic,
data: ByteArray,
debug: ((String) -> Unit)? = null,
): WeightReading? {
if (data.size < 2) {
debug?.invoke("skip: packet too short (" + data.size + "B)")
return null
}
// UUID-specific parsers
when (char.uuid) {
BleUuids.WEIGHT_MEASUREMENT_CHAR -> return parseSigWeight(data, debug)
}
// QN/Yolanda food scale (QN-KS, BC-KS, etc.):
// 18-byte frame starting with 0x10 0x12 on FFF1
if (data.size == 18
&& (data[0].toInt() and 0xFF) == 0x10
&& (data[1].toInt() and 0xFF) == 0x12) {
return parseQNFood(data, debug)
}
return parseGeneric(data, debug)
}
// -------------------------------------------------------------------------
// BLE SIG 0x2A9D Weight Measurement
// -------------------------------------------------------------------------
private fun parseSigWeight(data: ByteArray, debug: ((String) -> Unit)? = null): WeightReading? {
if (data.size < 3) return null
val flags = data[0].toInt() and 0xFF
val isImperial = (flags and 0x01) != 0
val raw = u16le(data, 1)
return if (isImperial) {
val lb = raw * 0.01f
debug?.invoke("SIG 2A9D: raw=$raw -> ${lb}lb")
if (lb < 0.01f || lb > 33f) null
else WeightReading(lb, "lb", stable = true)
} else {
val g = raw * 5f // 0.005 kg resolution = 5 g/unit
debug?.invoke("SIG 2A9D: raw=$raw -> ${g}g")
if (g < MIN_GRAMS || g > MAX_GRAMS) null
else WeightReading(g, "g", stable = true)
}
}
// -------------------------------------------------------------------------
// QN / Yolanda food scale (QN-KS, BC-KS, YolandaKS, ...)
//
// 18-byte notification on service 0xFFF0, char 0xFFF1:
// [0x10][0x12][00][??][unit][02][05][01][flags][w_hi][w_lo][7E][1F][02][58][02][01][crc]
// index: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
//
// weight = u16BE(data, 9) / 10.0 (0.1-unit resolution)
// unit = byte[4]: 0x01=g, 0x02=oz, 0x03=ml(water), 0x04=ml(milk)
// stable = bit3 of byte[8] != 0 (0xF8=stable, 0xF0=settling)
// crc = sum(bytes[0..16]) mod 256
// -------------------------------------------------------------------------
private fun parseQNFood(data: ByteArray, debug: ((String) -> Unit)? = null): WeightReading? {
// Verify checksum
val calc = data.take(17).sumOf { it.toInt() and 0xFF } and 0xFF
if (calc != (data[17].toInt() and 0xFF)) {
debug?.invoke("QN-KS: CRC mismatch (calc=0x%02X got=0x%02X)".format(calc, data[17].toInt() and 0xFF))
return null
}
val rawValue = u16be(data, 9)
val stable = (data[8].toInt() and 0x08) != 0
val unit = when (data[4].toInt() and 0xFF) {
0x01 -> "g"
0x02 -> "oz"
0x03 -> "ml" // water mode
0x04 -> "ml" // milk mode
else -> "g"
}
// Resolution is 0.1 unit (e.g. 170 raw = 17.0 g, 195 raw = 19.5 g)
val value = rawValue / 10f
debug?.invoke("QN-KS: ${value}${unit} stable=$stable (raw=$rawValue unit_byte=0x%02X)".format(data[4].toInt() and 0xFF))
if (rawValue == 0) return null
// Convert to grams for range check
val valueG = if (unit == "oz") value * 28.3495f else value
if (valueG < MIN_GRAMS || valueG > MAX_GRAMS) return null
return WeightReading(value, unit, stable)
}
// -------------------------------------------------------------------------
// Generic fallback parser
// Tries common frame layouts used by many BLE kitchen scales.
// -------------------------------------------------------------------------
private fun parseGeneric(data: ByteArray, debug: ((String) -> Unit)? = null): WeightReading? {
if (data.size < 3) {
debug?.invoke("generic: skip short packet (" + data.size + "B)")
return null
}
data class C(val pos: Int, val be: Boolean, val div: Float, val label: String)
val candidates = listOf(
// Direct grams (1g resolution)
C(1, false, 1f, "pos1 LE g"),
C(1, true, 1f, "pos1 BE g"),
C(2, false, 1f, "pos2 LE g"),
C(2, true, 1f, "pos2 BE g"),
C(3, false, 1f, "pos3 LE g"),
C(3, true, 1f, "pos3 BE g"),
// 0.1g resolution (high-precision scales)
C(1, false, 10f, "pos1 LE 0.1g"),
C(1, true, 10f, "pos1 BE 0.1g"),
C(2, false, 10f, "pos2 LE 0.1g"),
C(2, true, 10f, "pos2 BE 0.1g"),
C(3, false, 10f, "pos3 LE 0.1g"),
C(3, true, 10f, "pos3 BE 0.1g"),
// 0.5g resolution
C(1, false, 2f, "pos1 LE 0.5g"),
C(1, true, 2f, "pos1 BE 0.5g"),
// Raw = centgrams (raw*10 = g)
C(1, false, 0.1f, "pos1 LE cg"),
C(1, true, 0.1f, "pos1 BE cg"),
C(3, false, 0.1f, "pos3 LE cg"),
C(3, true, 0.1f, "pos3 BE cg"),
)
for (c in candidates) {
if (c.pos + 1 >= data.size) continue
val raw = if (c.be) u16be(data, c.pos) else u16le(data, c.pos)
if (raw == 0) continue
val g = raw / c.div
if (g in MIN_GRAMS..MAX_GRAMS) {
debug?.invoke("generic [${c.label}]: raw=$raw -> ${g}g (unstable)")
return WeightReading(g, "g", stable = false)
}
}
debug?.invoke("generic: no valid candidate in " + data.size + " bytes")
return null
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private fun u16le(b: ByteArray, off: Int): Int =
(b[off].toInt() and 0xFF) or ((b[off + 1].toInt() and 0xFF) shl 8)
private fun u16be(b: ByteArray, off: Int): Int =
((b[off].toInt() and 0xFF) shl 8) or (b[off + 1].toInt() and 0xFF)
}
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#6C63FF"
android:pathData="M0,0h108v108H0z" />
</vector>
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FFFFFF"
android:pathData="M54,30L54,70 M40,38L68,38 M36,54L44,38 M64,54L72,38 M36,54C36,56 44,56 44,54 M64,54C64,56 72,56 72,54" />
</vector>
@@ -1,340 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="#F3F4F6">
<!-- ── Update banner (shown at the TOP when a new version is available) ─ -->
<LinearLayout
android:id="@+id/updateBanner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:background="#1e293b"
android:paddingStart="16dp"
android:paddingEnd="8dp"
android:paddingTop="10dp"
android:paddingBottom="10dp"
android:gravity="center_vertical"
android:visibility="gone">
<TextView
android:id="@+id/tvUpdateMessage"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textColor="#fbbf24"
android:textSize="13sp"
android:text="" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnInstallUpdate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="⬇ Scarica"
android:textSize="12sp"
android:textColor="#1e293b"
android:backgroundTint="#fbbf24"
style="@style/Widget.MaterialComponents.Button" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnDismissUpdate"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_marginStart="4dp"
android:text="✕"
android:textSize="14sp"
android:textColor="#94a3b8"
android:backgroundTint="@android:color/transparent"
style="@style/Widget.MaterialComponents.Button.TextButton" />
</LinearLayout>
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<!-- ── Header ─────────────────────────────────────────────────────── -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="⚖️ EverShelf Scale Gateway"
android:textSize="22sp"
android:textStyle="bold"
android:textColor="#1E293B"
android:paddingBottom="4dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingBottom="16dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Connect your smart scale to EverShelf via Bluetooth"
android:textSize="13sp"
android:textColor="#64748B" />
<TextView
android:id="@+id/tv_version"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="v?.?.?"
android:textSize="11sp"
android:textColor="#94A3B8"
android:fontFamily="monospace"
android:gravity="end" />
</LinearLayout>
<!-- ── Gateway URL card ───────────────────────────────────────────── -->
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
app:cardCornerRadius="12dp"
app:cardElevation="2dp"
app:cardBackgroundColor="#EFF6FF">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="🌐 Gateway URL (paste into EverShelf)"
android:textSize="12sp"
android:textColor="#64748B"
android:paddingBottom="4dp" />
<TextView
android:id="@+id/tv_gateway_url"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="ws://…:8765"
android:textSize="18sp"
android:textStyle="bold"
android:textColor="#1D4ED8"
android:fontFamily="monospace"
android:paddingBottom="4dp" />
<TextView
android:id="@+id/tv_gateway_url_hint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Settings → Smart Scale"
android:textSize="11sp"
android:textColor="#94A3B8"
android:paddingBottom="8dp" />
<Button
android:id="@+id/btn_copy_url"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="📋 Copy URL"
android:backgroundTint="#1D4ED8"
android:textColor="#FFFFFF"
style="@style/Widget.MaterialComponents.Button" />
</LinearLayout>
</androidx.cardview.widget.CardView>
<!-- ── Gateway status ────────────────────────────────────────────── -->
<TextView
android:id="@+id/tv_gateway_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="⏳ Starting gateway…"
android:textSize="13sp"
android:textColor="#64748B"
android:paddingBottom="4dp" />
<TextView
android:id="@+id/tv_client_count"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text=""
android:textSize="12sp"
android:textColor="#059669"
android:paddingBottom="12dp"
android:visibility="gone" />
<!-- ── Scale connection card ──────────────────────────────────────── -->
<androidx.cardview.widget.CardView
android:id="@+id/card_connection"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
app:cardCornerRadius="12dp"
app:cardElevation="2dp"
app:cardBackgroundColor="@android:color/darker_gray">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/tv_scale_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="⚡ Ready — scan for a scale"
android:textSize="15sp"
android:textStyle="bold"
android:textColor="#FFFFFF"
android:paddingBottom="8dp" />
<TextView
android:id="@+id/tv_weight"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="— — —"
android:textSize="46sp"
android:textStyle="bold"
android:textColor="#FFFFFF"
android:gravity="center"
android:paddingTop="8dp"
android:paddingBottom="4dp" />
<TextView
android:id="@+id/tv_weight_hint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text=""
android:textSize="12sp"
android:textColor="#E2E8F0"
android:gravity="center"
android:paddingBottom="8dp" />
<TextView
android:id="@+id/tv_battery"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text=""
android:textSize="12sp"
android:textColor="#E2E8F0"
android:visibility="gone" />
<Button
android:id="@+id/btn_disconnect"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="🔌 Disconnect scale"
android:backgroundTint="#EF4444"
android:textColor="#FFFFFF"
android:visibility="gone"
android:layout_marginTop="8dp"
style="@style/Widget.MaterialComponents.Button" />
</LinearLayout>
</androidx.cardview.widget.CardView>
<!-- ── Scan controls ──────────────────────────────────────────────── -->
<Button
android:id="@+id/btn_scan"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="🔍 Scan for Bluetooth Scales"
android:backgroundTint="#7C3AED"
android:textColor="#FFFFFF"
android:layout_marginBottom="8dp"
style="@style/Widget.MaterialComponents.Button" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<Button
android:id="@+id/btn_debug"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="\uD83D\uDC1B Debug"
android:backgroundTint="#374151"
android:textColor="#FFFFFF"
android:layout_marginEnd="4dp"
style="@style/Widget.MaterialComponents.Button" />
<Button
android:id="@+id/btn_copy_log"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="\uD83D\uDCCB"
android:backgroundTint="#374151"
android:textColor="#FFFFFF"
android:minWidth="48dp"
android:layout_marginEnd="4dp"
android:visibility="gone"
style="@style/Widget.MaterialComponents.Button" />
<Button
android:id="@+id/btn_share_log"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="\uD83D\uDCE4"
android:backgroundTint="#374151"
android:textColor="#FFFFFF"
android:minWidth="48dp"
android:visibility="gone"
style="@style/Widget.MaterialComponents.Button" />
</LinearLayout>
<ScrollView
android:id="@+id/sv_debug_log"
android:layout_width="match_parent"
android:layout_height="220dp"
android:layout_marginBottom="12dp"
android:background="#111827"
android:visibility="gone">
<TextView
android:id="@+id/tv_debug_log"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text=""
android:textSize="10sp"
android:fontFamily="monospace"
android:textColor="#4ADE80"
android:padding="8dp" />
</ScrollView>
<TextView
android:id="@+id/tv_scan_hint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Press to scan for nearby BLE scales.\nMake sure the scale is turned on."
android:textSize="12sp"
android:textColor="#64748B"
android:paddingBottom="12dp"
android:visibility="gone" />
<!-- ── Device list ─────────────────────────────────────────────────── -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_devices"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:nestedScrollingEnabled="false" />
</LinearLayout>
</ScrollView>
</LinearLayout>
@@ -1,58 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
app:cardCornerRadius="10dp"
app:cardElevation="1dp"
app:cardBackgroundColor="#FFFFFF">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="14dp"
android:gravity="center_vertical">
<TextView
android:layout_width="32dp"
android:layout_height="32dp"
android:text="⚖️"
android:textSize="20sp"
android:gravity="center"
android:layout_marginEnd="12dp" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tv_device_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="15sp"
android:textStyle="bold"
android:textColor="#1E293B" />
<TextView
android:id="@+id/tv_device_addr"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="11sp"
android:fontFamily="monospace"
android:textColor="#94A3B8" />
</LinearLayout>
<TextView
android:id="@+id/tv_device_rssi"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="11sp"
android:textColor="#64748B" />
</LinearLayout>
</androidx.cardview.widget.CardView>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 413 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 413 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 546 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 546 B

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="white">#FFFFFFFF</color>
<color name="black">#FF000000</color>
<color name="accent">#7C3AED</color>
<color name="green">#059669</color>
<color name="red">#EF4444</color>
<color name="blue">#1D4ED8</color>
</resources>
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">EverShelf Scale Gateway</string>
</resources>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<!-- App-private external dir: no storage permission needed -->
<external-files-path name="apk_downloads" path="." />
</paths>
-5
View File
@@ -1,5 +0,0 @@
// Top-level build file
plugins {
id("com.android.application") version "8.2.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
}
@@ -1,2 +0,0 @@
android.useAndroidX=true
android.enableJetifier=true
@@ -1,6 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
@@ -1,17 +0,0 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "EverShelf Scale Gateway"
include(":app")
+45 -8
View File
@@ -11,7 +11,7 @@
<title>EverShelf</title> <title>EverShelf</title>
<link rel="manifest" href="manifest.json"> <link rel="manifest" href="manifest.json">
<link rel="icon" type="image/png" href="assets/img/logo/logo_icon.png"> <link rel="icon" type="image/png" href="assets/img/logo/logo_icon.png">
<link rel="stylesheet" href="assets/css/style.css?v=20260513a"> <link rel="stylesheet" href="assets/css/style.css?v=20260516a">
<!-- QuaggaJS for barcode scanning --> <!-- QuaggaJS for barcode scanning -->
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2@1.8.4/dist/quagga.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2@1.8.4/dist/quagga.min.js"></script>
<!-- @xenova/transformers: ES-module bootstrap that exposes a lazy category-classifier as window._categoryPipelinePromise --> <!-- @xenova/transformers: ES-module bootstrap that exposes a lazy category-classifier as window._categoryPipelinePromise -->
@@ -67,7 +67,7 @@
<!-- Title — left-aligned; grows to fill space --> <!-- Title — left-aligned; grows to fill space -->
<div class="header-title-wrap"> <div class="header-title-wrap">
<h1 class="header-title" onclick="showPage('dashboard')"> <h1 class="header-title" onclick="showPage('dashboard')">
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.12</span> <img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.14</span>
</h1> </h1>
<!-- Update badge — shown alongside title, never replaces it --> <!-- Update badge — shown alongside title, never replaces it -->
<span class="header-update-badge" id="header-update-badge" style="display:none"></span> <span class="header-update-badge" id="header-update-badge" style="display:none"></span>
@@ -407,7 +407,7 @@
<div class="use-inventory-info" id="use-inventory-info"></div> <div class="use-inventory-info" id="use-inventory-info"></div>
<div id="use-expiry-hint" style="display:none"></div> <div id="use-expiry-hint" style="display:none"></div>
<form class="form" onsubmit="submitUse(event)"> <form class="form" onsubmit="submitUse(event)">
<div class="form-group"> <div class="form-group" id="use-location-group">
<label>📍 Da dove?</label> <label>📍 Da dove?</label>
<div class="location-selector" id="use-location-selector"> <div class="location-selector" id="use-location-selector">
<button type="button" class="loc-btn active" onclick="selectUseLocation(this, 'dispensa')">🗄️ Dispensa</button> <button type="button" class="loc-btn active" onclick="selectUseLocation(this, 'dispensa')">🗄️ Dispensa</button>
@@ -1302,6 +1302,18 @@
<p class="settings-hint" style="margin-top:8px" data-i18n="settings.kiosk.download_sub">Modalità kiosk full-screen + gateway bilancia integrato. Sorgente: <code>evershelf-kiosk/</code></p> <p class="settings-hint" style="margin-top:8px" data-i18n="settings.kiosk.download_sub">Modalità kiosk full-screen + gateway bilancia integrato. Sorgente: <code>evershelf-kiosk/</code></p>
</div> </div>
<!-- Kiosk native settings panel (visible only inside kiosk WebView) -->
<div id="kiosk-native-settings-panel" style="display:none;background:rgba(99,102,241,0.06);border:1.5px solid rgba(99,102,241,0.2);border-radius:12px;padding:16px;margin-top:16px">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px">
<span style="font-size:1.4rem">🖥️</span>
<div>
<p style="margin:0;font-weight:700;font-size:0.9rem" data-i18n="settings.kiosk.native_title">Configurazione Kiosk</p>
<p class="settings-hint" style="margin:2px 0 0" data-i18n="settings.kiosk.native_hint">URL server, bilancia BLE, salvaschermo e setup wizard.</p>
</div>
</div>
<button class="btn btn-secondary full-width" onclick="_openKioskNativeSettings()">⚙️ <span data-i18n="settings.kiosk.native_btn">Apri configurazione kiosk</span></button>
</div>
<!-- Kiosk self-update panel (visible only inside kiosk WebView) --> <!-- Kiosk self-update panel (visible only inside kiosk WebView) -->
<div id="kiosk-update-panel" style="display:none;background:rgba(16,185,129,0.06);border:1.5px solid rgba(16,185,129,0.2);border-radius:12px;padding:16px;margin-top:16px"> <div id="kiosk-update-panel" style="display:none;background:rgba(16,185,129,0.06);border:1.5px solid rgba(16,185,129,0.2);border-radius:12px;padding:16px;margin-top:16px">
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:10px"> <div style="display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:10px">
@@ -1320,6 +1332,29 @@
<button class="btn btn-large btn-success full-width mt-2" onclick="saveSettings()" data-i18n="btn.save_config">💾 Salva Configurazione</button> <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> <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? Inviaci una segnalazione direttamente dall'app.</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>
</section> </section>
<!-- ===== GEMINI CHAT ===== --> <!-- ===== GEMINI CHAT ===== -->
@@ -1430,9 +1465,6 @@
</div> </div>
<div id="recipe-result" style="display:none" class="recipe-result"> <div id="recipe-result" style="display:none" class="recipe-result">
<div id="recipe-content"></div> <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"> <button class="btn btn-large btn-secondary full-width mt-2" onclick="regenerateRecipe()" data-i18n="recipes.regenerate">
🔄 Generane un'altra 🔄 Generane un'altra
</button> </button>
@@ -1501,13 +1533,18 @@
<button class="cooking-tts-btn" id="cooking-tts-btn" onclick="toggleCookingTTS()" title="Leggi ad alta voce">🔊</button> <button class="cooking-tts-btn" id="cooking-tts-btn" onclick="toggleCookingTTS()" title="Leggi ad alta voce">🔊</button>
</div> </div>
<div id="cooking-timers-bar" class="cooking-timers-bar" style="display:none"></div> <div id="cooking-timers-bar" class="cooking-timers-bar" style="display:none"></div>
<div id="cooking-tools-bar" class="cooking-tools-bar" style="display:none"></div>
<div class="cooking-body"> <div class="cooking-body">
<div class="cooking-step-header"> <div class="cooking-step-header">
<div class="cooking-step-num" id="cooking-step-num">1 / 1</div> <div class="cooking-step-num" id="cooking-step-num">1 / 1</div>
<button class="cooking-restart-btn" onclick="restartCookingMode()" title="Ricomincia dall'inizio">↺ Ricomincia</button> <button class="cooking-restart-btn" onclick="restartCookingMode()" title="Ricomincia dall'inizio">↺ Ricomincia</button>
</div> </div>
<div class="cooking-progress-dots" id="cooking-progress-dots"></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> <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"> <div class="cooking-timer-suggest" id="cooking-timer-suggest" style="display:none">
<button class="cooking-timer-add-btn" onclick="addSuggestedCookingTimer()"> <button class="cooking-timer-add-btn" onclick="addSuggestedCookingTimer()">
@@ -1522,6 +1559,6 @@
</div> </div>
</div> </div>
<script src="assets/js/app.js?v=20260513a"></script> <script src="assets/js/app.js?v=20260516a"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "EverShelf", "name": "EverShelf",
"short_name": "EverShelf", "short_name": "EverShelf",
"description": "Gestione completa della dispensa di casa con scansione barcode", "description": "Gestione completa della dispensa di casa con scansione barcode",
"version": "1.7.12", "version": "1.7.14",
"start_url": "/evershelf/", "start_url": "/evershelf/",
"display": "standalone", "display": "standalone",
"background_color": "#f0f4e8", "background_color": "#f0f4e8",
+36 -6
View File
@@ -91,8 +91,8 @@
"banner_review_action_edit": "Korrigieren", "banner_review_action_edit": "Korrigieren",
"banner_review_action_weigh": "Wiegen", "banner_review_action_weigh": "Wiegen",
"banner_review_dismiss": "Ignorieren", "banner_review_dismiss": "Ignorieren",
"banner_prediction_title": "Ungewöhnlicher Verbrauch", "banner_prediction_title": "Verbrauch zur Prüfung",
"banner_prediction_hint": "Laut Vorhersage stimmt diese Menge nicht mit dem erwarteten Verbrauch überein.", "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_confirm": "{qty} {unit} bestätigen",
"banner_prediction_action_weigh": "Jetzt wiegen", "banner_prediction_action_weigh": "Jetzt wiegen",
"banner_prediction_action_edit": "Menge aktualisieren", "banner_prediction_action_edit": "Menge aktualisieren",
@@ -128,8 +128,8 @@
"banner_prediction_rate_day": "Durchschnitt ~{n} {unit}/Tag", "banner_prediction_rate_day": "Durchschnitt ~{n} {unit}/Tag",
"banner_prediction_rate_week": "Durchschnitt ~{n} {unit}/Woche", "banner_prediction_rate_week": "Durchschnitt ~{n} {unit}/Woche",
"banner_prediction_days_ago": "Vor {n} Tagen aufgefüllt", "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_more": "frühere Schätzung: {expected} {unit}{time}; aktuelle Menge: {actual} {unit}.",
"banner_prediction_less": "Ich erwartete {expected} {unit}{time}, du hast aber nur {actual} {unit}. Hast du mehr als üblich verbraucht?", "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_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_expected": "Laut Aufzeichnungen solltest du noch {qty} {unit} haben.",
"banner_finished_check": "Kannst du nachschauen?", "banner_finished_check": "Kannst du nachschauen?",
@@ -341,6 +341,7 @@
"regenerate": "🔄 Noch eins generieren", "regenerate": "🔄 Noch eins generieren",
"close_btn": "✅ Schließen", "close_btn": "✅ Schließen",
"ingredients_title": "🧾 Zutaten", "ingredients_title": "🧾 Zutaten",
"tools_title": "Benötigte Geräte",
"steps_title": "👨‍🍳 Zubereitung", "steps_title": "👨‍🍳 Zubereitung",
"no_steps": "Keine Zubereitungsschritte verfügbar", "no_steps": "Keine Zubereitungsschritte verfügbar",
"generate_error": "Fehler bei der Generierung", "generate_error": "Fehler bei der Generierung",
@@ -676,7 +677,12 @@
"kiosk": { "kiosk": {
"hint": "Verwandeln Sie ein Android-Tablet in ein EverShelf-Panel mit integriertem BLE-Waagen-Gateway.", "hint": "Verwandeln Sie ein Android-Tablet in ein EverShelf-Panel mit integriertem BLE-Waagen-Gateway.",
"download_btn": "📥 EverShelf Kiosk herunterladen (APK)", "download_btn": "📥 EverShelf Kiosk herunterladen (APK)",
"download_sub": "Vollbild-Kioskmodus + integriertes Waagen-Gateway. Quellcode: evershelf-kiosk/" "download_sub": "Vollbild-Kioskmodus + integriertes Waagen-Gateway. Quellcode: evershelf-kiosk/",
"native_title": "Kiosk-Konfiguration",
"native_hint": "Server-URL, BLE-Waage, Bildschirmschoner und Einrichtungsassistent.",
"native_btn": "Kiosk-Konfiguration öffnen",
"native_tap_hint": "Zahnrad oben rechts antippen",
"native_update_hint": "Kiosk-App aktualisieren, um diese Funktion zu nutzen"
}, },
"saved": "✅ Konfiguration gespeichert!", "saved": "✅ Konfiguration gespeichert!",
"saved_local": "✅ Konfiguration lokal gespeichert", "saved_local": "✅ Konfiguration lokal gespeichert",
@@ -717,7 +723,8 @@
"opened_suffix": "— Zu lange geöffnet!", "opened_suffix": "— Zu lange geöffnet!",
"opened_suffix_ok": "— Geöffnet (noch ok)", "opened_suffix_ok": "— Geöffnet (noch ok)",
"opened_suffix_warning": "— Geöffnet (erst prüfen)", "opened_suffix_warning": "— Geöffnet (erst prüfen)",
"days_compact": "{n}T" "days_compact": "{n}T",
"badge_check_soon": "Bald prüfen"
}, },
"status": { "status": {
"ok": "OK", "ok": "OK",
@@ -1085,5 +1092,28 @@
}, },
"appliances": { "appliances": {
"empty": "Kein Haushaltsgerät hinzugefügt" "empty": "Kein Haushaltsgerät hinzugefügt"
},
"about": {
"title": "Über",
"version": "Version",
"report_bug": "Fehler melden",
"report_bug_hint": "Etwas funktioniert nicht? Sende uns direkt aus der App eine Meldung.",
"report_bug_modal_title": "Fehler melden",
"report_type_bug": "Fehler",
"report_type_feature": "Funktion",
"report_type_question": "Frage",
"report_field_title": "Titel",
"report_field_title_ph": "Kurze Beschreibung des Problems",
"report_field_desc": "Beschreibung",
"report_field_desc_ph": "Problem detailliert beschreiben…",
"report_field_steps": "Schritte zum Reproduzieren (optional)",
"report_field_steps_ph": "1. Gehe zu…\n2. Tippe auf…\n3. Fehler erscheint…",
"report_auto_info": "Automatisch beigefügt: Version {version}, Sprache {lang}.",
"report_send_btn": "Bericht senden",
"report_bug_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"
} }
} }
+37 -7
View File
@@ -91,9 +91,9 @@
"banner_review_action_edit": "Correct", "banner_review_action_edit": "Correct",
"banner_review_action_weigh": "Weigh", "banner_review_action_weigh": "Weigh",
"banner_review_dismiss": "Dismiss", "banner_review_dismiss": "Dismiss",
"banner_prediction_title": "Anomalous consumption", "banner_prediction_title": "Consumption to review",
"banner_prediction_hint": "Based on predictions, this quantity doesn't match expected consumption.", "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} is correct", "banner_prediction_action_confirm": "Confirm {qty} {unit}",
"banner_prediction_action_weigh": "Weigh now", "banner_prediction_action_weigh": "Weigh now",
"banner_prediction_action_edit": "Update quantity", "banner_prediction_action_edit": "Update quantity",
"banner_expired_title": "Expired product", "banner_expired_title": "Expired product",
@@ -128,8 +128,8 @@
"banner_prediction_rate_day": "Average ~{n} {unit}/day", "banner_prediction_rate_day": "Average ~{n} {unit}/day",
"banner_prediction_rate_week": "Average ~{n} {unit}/week", "banner_prediction_rate_week": "Average ~{n} {unit}/week",
"banner_prediction_days_ago": "{n} days ago you restocked", "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_more": "previous estimate: {expected} {unit}{time}; current quantity: {actual} {unit}.",
"banner_prediction_less": "I expected {expected} {unit}{time}, but you only have {actual} {unit}. Did you use more than usual?", "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_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_expected": "According to records you should still have {qty} {unit}.",
"banner_finished_check": "Can you check?", "banner_finished_check": "Can you check?",
@@ -341,6 +341,7 @@
"regenerate": "🔄 Generate another one", "regenerate": "🔄 Generate another one",
"close_btn": "✅ Close", "close_btn": "✅ Close",
"ingredients_title": "🧾 Ingredients", "ingredients_title": "🧾 Ingredients",
"tools_title": "Equipment needed",
"steps_title": "👨‍🍳 Steps", "steps_title": "👨‍🍳 Steps",
"no_steps": "No steps available", "no_steps": "No steps available",
"generate_error": "Generation error", "generate_error": "Generation error",
@@ -676,7 +677,12 @@
"kiosk": { "kiosk": {
"hint": "Turn an Android tablet into an always-on EverShelf panel with built-in BLE scale gateway.", "hint": "Turn an Android tablet into an always-on EverShelf panel with built-in BLE scale gateway.",
"download_btn": "📥 Download EverShelf Kiosk (APK)", "download_btn": "📥 Download EverShelf Kiosk (APK)",
"download_sub": "Full-screen kiosk mode + integrated scale gateway. Source: evershelf-kiosk/" "download_sub": "Full-screen kiosk mode + integrated scale gateway. Source: evershelf-kiosk/",
"native_title": "Kiosk Configuration",
"native_hint": "Server URL, BLE scale, screensaver and setup wizard.",
"native_btn": "Open kiosk configuration",
"native_tap_hint": "Tap the gear button at the top right",
"native_update_hint": "Update the kiosk app to use this feature"
}, },
"saved": "✅ Configuration saved!", "saved": "✅ Configuration saved!",
"saved_local": "✅ Configuration saved locally", "saved_local": "✅ Configuration saved locally",
@@ -717,7 +723,8 @@
"opened_suffix": "— Opened too long!", "opened_suffix": "— Opened too long!",
"opened_suffix_ok": "— Opened (still ok)", "opened_suffix_ok": "— Opened (still ok)",
"opened_suffix_warning": "— Opened (check first)", "opened_suffix_warning": "— Opened (check first)",
"days_compact": "{n}d" "days_compact": "{n}d",
"badge_check_soon": "Check soon"
}, },
"status": { "status": {
"ok": "OK", "ok": "OK",
@@ -1085,5 +1092,28 @@
}, },
"appliances": { "appliances": {
"empty": "No appliances added" "empty": "No appliances added"
},
"about": {
"title": "About",
"version": "Version",
"report_bug": "Report a Bug",
"report_bug_hint": "Something not working? Send us a report directly from the app.",
"report_bug_modal_title": "Report a Bug",
"report_type_bug": "Bug",
"report_type_feature": "Feature",
"report_type_question": "Question",
"report_field_title": "Title",
"report_field_title_ph": "Brief description of the issue",
"report_field_desc": "Description",
"report_field_desc_ph": "Describe the issue in detail…",
"report_field_steps": "Steps to reproduce (optional)",
"report_field_steps_ph": "1. Go to…\n2. Tap…\n3. See the error…",
"report_auto_info": "Automatically attached: version {version}, language {lang}.",
"report_send_btn": "Send report",
"report_bug_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"
} }
} }
+37 -7
View File
@@ -91,9 +91,9 @@
"banner_review_action_edit": "Correggi", "banner_review_action_edit": "Correggi",
"banner_review_action_weigh": "Pesa", "banner_review_action_weigh": "Pesa",
"banner_review_dismiss": "Ignora", "banner_review_dismiss": "Ignora",
"banner_prediction_title": "Consumo anomalo", "banner_prediction_title": "Consumo da verificare",
"banner_prediction_hint": "Secondo le previsioni, questa quantità non corrisponde al consumo previsto.", "banner_prediction_hint": "La stima di consumo si adatta ai dati recenti: verifica solo se la quantità corrente è corretta.",
"banner_prediction_action_confirm": "Confermo la quantità di {qty} {unit}", "banner_prediction_action_confirm": "Confermo {qty} {unit}",
"banner_prediction_action_weigh": "Pesa ora", "banner_prediction_action_weigh": "Pesa ora",
"banner_prediction_action_edit": "Aggiorna quantità", "banner_prediction_action_edit": "Aggiorna quantità",
"banner_expired_title": "Prodotto scaduto", "banner_expired_title": "Prodotto scaduto",
@@ -128,8 +128,8 @@
"banner_prediction_rate_day": "Media ~{n} {unit}/giorno", "banner_prediction_rate_day": "Media ~{n} {unit}/giorno",
"banner_prediction_rate_week": "Media ~{n} {unit}/settimana", "banner_prediction_rate_week": "Media ~{n} {unit}/settimana",
"banner_prediction_days_ago": "{n} giorni fa hai rifornito", "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_more": "stima precedente: {expected} {unit}{time}; quantità attuale: {actual} {unit}.",
"banner_prediction_less": "mi aspettavo {expected} {unit}{time}, ne hai solo {actual} {unit}. Hai consumato di più del solito?", "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_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_expected": "Secondo le registrazioni dovresti averne ancora {qty} {unit}.",
"banner_finished_check": "Puoi controllare?", "banner_finished_check": "Puoi controllare?",
@@ -341,6 +341,7 @@
"regenerate": "🔄 Generane un'altra", "regenerate": "🔄 Generane un'altra",
"close_btn": "✅ Chiudi", "close_btn": "✅ Chiudi",
"ingredients_title": "🧾 Ingredienti", "ingredients_title": "🧾 Ingredienti",
"tools_title": "Strumenti necessari",
"steps_title": "👨‍🍳 Procedimento", "steps_title": "👨‍🍳 Procedimento",
"no_steps": "Nessun procedimento disponibile", "no_steps": "Nessun procedimento disponibile",
"generate_error": "Errore nella generazione", "generate_error": "Errore nella generazione",
@@ -676,7 +677,12 @@
"kiosk": { "kiosk": {
"hint": "Trasforma un tablet Android in un pannello EverShelf sempre acceso, con bilancia BLE integrata.", "hint": "Trasforma un tablet Android in un pannello EverShelf sempre acceso, con bilancia BLE integrata.",
"download_btn": "📥 Scarica EverShelf Kiosk (APK)", "download_btn": "📥 Scarica EverShelf Kiosk (APK)",
"download_sub": "Modalità kiosk full-screen + gateway bilancia integrato. Sorgente: evershelf-kiosk/" "download_sub": "Modalità kiosk full-screen + gateway bilancia integrato. Sorgente: evershelf-kiosk/",
"native_title": "Configurazione Kiosk",
"native_hint": "URL server, bilancia BLE, salvaschermo e setup wizard.",
"native_btn": "Apri configurazione kiosk",
"native_tap_hint": "Tocca la rotella in alto a destra",
"native_update_hint": "Aggiorna l'app kiosk per usare questa funzione"
}, },
"saved": "✅ Configurazione salvata!", "saved": "✅ Configurazione salvata!",
"saved_local": "✅ Configurazione salvata localmente", "saved_local": "✅ Configurazione salvata localmente",
@@ -717,7 +723,8 @@
"opened_suffix": "— Aperto da troppo tempo!", "opened_suffix": "— Aperto da troppo tempo!",
"opened_suffix_ok": "— Aperto (ancora ok)", "opened_suffix_ok": "— Aperto (ancora ok)",
"opened_suffix_warning": "— Aperto (controlla)", "opened_suffix_warning": "— Aperto (controlla)",
"days_compact": "{n}gg" "days_compact": "{n}gg",
"badge_check_soon": "Controlla presto"
}, },
"status": { "status": {
"ok": "OK", "ok": "OK",
@@ -1085,5 +1092,28 @@
}, },
"appliances": { "appliances": {
"empty": "Nessun elettrodomestico aggiunto" "empty": "Nessun elettrodomestico aggiunto"
},
"about": {
"title": "Informazioni",
"version": "Versione",
"report_bug": "Segnala un problema",
"report_bug_hint": "Qualcosa non funziona? Inviaci una segnalazione direttamente dall'app.",
"report_bug_modal_title": "Segnala un problema",
"report_type_bug": "Bug",
"report_type_feature": "Funzionalità",
"report_type_question": "Domanda",
"report_field_title": "Titolo",
"report_field_title_ph": "Breve descrizione del problema",
"report_field_desc": "Descrizione",
"report_field_desc_ph": "Descrivi il problema in dettaglio…",
"report_field_steps": "Passi per riprodurlo (opzionale)",
"report_field_steps_ph": "1. Vai su…\n2. Tocca…\n3. Vedi l'errore…",
"report_auto_info": "Saranno allegati automaticamente: versione {version}, lingua {lang}.",
"report_send_btn": "Invia segnalazione",
"report_bug_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"
} }
} }